Release ProxyDoctor 0.5.0 stage-one diagnostics

This commit is contained in:
griffinmx committed 2026-10-31 22:16:21 -07:00
commit aa7f12d705
38 files changed
+3648

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
name: tests
on: [push, pull_request, workflow_dispatch]
permissions:
contents: read
jobs:
ubuntu:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
python: '3.10'
- os: ubuntu-24.04
python: '3.12'
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python }}
- run: python -m pip install pytest 'setuptools>=77' wheel
- run: python -m pytest -q
- run: python -m pip wheel --no-build-isolation --no-deps . --wheel-dir work/wheels
- run: python -m proxydoctor suite --offline --lang en
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
.pytest_cache/
.pytest-tmp/
*.egg-info/
build/
dist/
.venv/
work/
outputs/
reports/
+28
View File
@@ -0,0 +1,28 @@
# Contributing / 参与贡献
Python 3.10+; runtime uses the standard library only.
```console
python -m venv .venv
.venv/bin/python -m pip install pytest
.venv/bin/python -m pytest -q
```
Windows: use `.venv/Scripts/python.exe` instead.
Diagnostics must remain read-only: no automatic sudo, package installation,
firewall/SSH/network changes, or fetching/executing remote scripts. Explicit
local third-party adapters must remain opt-in and disclose their behavior.
Use fixed HTTPS endpoints, bounded requests, identity validation and no automatic
quota retries. Never report missing data as a safe flag or HTTP 200 as playback.
Keep source disagreement and scores separate. Do not commit tokens, reports,
VM connection information or private keys.
New rules need evidence-based fixtures for positive, restricted and unknown
cases where applicable. Tests must not query live services. Validate Ubuntu
22.04 and 24.04 changes in disposable test environments before release; record
test parameters and distinguish code failures from external service results.
中文:保持检测只读;不自动提权、安装组件或修改 SSH/防火墙。联网请求必须有边界,
未知不能视为安全,页面可达不能视为播放解锁。新增规则注明来源、语义和限制,
用模拟数据测试;禁止把真实凭证、VM 信息及未脱敏报告提交到仓库。
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ProxyDoctor contributors
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.
+172
View File
@@ -0,0 +1,172 @@
# 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.
+155
View File
@@ -0,0 +1,155 @@
# 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) 也说明了目录限制,不保证页面可达就能播放。
+20
View File
@@ -0,0 +1,20 @@
# Security / 安全反馈
Do not post vulnerabilities containing passwords, subscription URLs, API keys,
SSH keys or full diagnostic reports to public issues. If this repository is
published with GitHub private vulnerability reporting enabled, use that channel.
Otherwise request a private contact without publishing exploit details or secrets.
This source snapshot does not yet have a public repository or security mailbox.
Rotate any exposed credential at its provider. Report files hide IP addresses by
default, but redaction does not guarantee removal of all identifying information.
Review files before sharing. Native diagnostics do not upload reports or modify
SSH/firewall settings. The default suite contacts external test/data/media
services, which see the request source. `check` and `suite --offline` do not.
Keys are accepted through local environment variables; the product never asks
for them on a public web form. Future deployment/repair features require a
separate review and SSH rollback safeguards.
中文:不要公开提交密码、密钥、订阅地址或完整诊断报告。仓库发布并开启私密漏洞报告后
优先使用该渠道;未配置前先询问私密联系方式,不公开敏感细节。报告脱敏不是匿名保证,
分享前需检查。当前原生检测不修改 SSH/防火墙,不上传报告;联网模式的服务会看到请求来源。
+39
View File
@@ -0,0 +1,39 @@
# References and acknowledgements / 借鉴来源与致谢
ProxyDoctor's default Python checks are independently implemented. The projects
below informed the feature selection and terminal presentation. Their code is
not bundled in this source release. Attribution does not replace license compliance:
any future source reuse must retain the upstream notices and follow its license.
当前默认检测由本项目独立实现。以下项目用于功能调研、输出设计参考;未将其脚本源码
复制进本发布包。致谢不能替代许可证合规;后续实际复用源码时必须保留版权声明、记录修改,
并按相应许可证处理。
| Source / 来源 | Reference scope / 参考范围 | License note / 许可说明 |
| --- | --- | --- |
| [spiritLHLS/ecs](https://github.com/spiritLHLS/ecs) and [oneclickvirt/ecs](https://github.com/oneclickvirt/ecs) | 综合 VPS 检测的模块组织与直接终端回显 / benchmark workflow | See upstream LICENSE; no source bundled |
| [LloydAsp/NodeQuality](https://github.com/LloydAsp/NodeQuality) | 多种节点检测统一展示 / unified node tests | AGPL-3.0; optional local-script adapter only |
| [xykt/NetQuality](https://github.com/xykt/NetQuality) | 紧凑网络结果、分区布局 / compact network presentation | AGPL-3.0; no source bundled |
| [xykt/IPQuality](https://github.com/xykt/IPQuality) | IP 归属与风险字段分类 / IP field categories | AGPL-3.0; no source bundled |
| [nxtrace/NTrace-core](https://github.com/nxtrace/NTrace-core) | IP、延迟和位置的路由显示形式 / route output format | See upstream LICENSE; no code or database bundled |
| [lmc999/RegionRestrictionCheck](https://github.com/lmc999/RegionRestrictionCheck) | 流媒体服务清单、区域限制场景调研 / media test coverage research | AGPL-3.0; optional local-script adapter only |
| [IPIP BestTrace](https://www.ipip.net/product/client.html) | 路由追踪产品功能参考 / route tracing reference | Third-party software; binary/database not redistributed |
## Runtime data / 运行时数据来源
- [Cloudflare Speedtest](https://github.com/cloudflare/speedtest): HTTPS upload/download endpoints. Our repeated, bounded measurement implementation is independent.
- [Hetzner official test files](https://docs.hetzner.com/storage/object-storage/troubleshooting/bandwidth-latency-issues/): Falkenstein HTTPS download samples using validated Range requests; no full test file redistributed.
- [OVHcloud Network Speedtest](https://proof.ovh.net/): Roubaix HTTPS download samples using validated Range requests; download-only in our adapter.
- [ipapi.is](https://ipapi.is/developers.html): IP ownership/ASN metadata; optional fields depend on provider access and limits.
- [proxycheck.io](https://proxycheck.io/api/): keyless v3 network/proxy/VPN/Tor/hosting observations and provider-specific risk/confidence; fixed June 24, 2026 schema, tag=0. Results are not a safety guarantee.
- [IP2Location.io](https://www.ip2location.io/ip2location-documentation): keyless IP metadata and limited proxy observation; API/data terms remain with the provider, no database bundled.
- [AbuseIPDB](https://docs.abuseipdb.com/): optional key-authenticated read-only IP reputation check, 90-day window. Keys stay in HTTPS headers; no reports are submitted.
- [ipwho.is](https://ipwhois.io/documentation): route IP geolocation estimates, cached within a run. Private/reserved IPs are not queried.
- [ipify](https://www.ipify.org/): explicit optional egress IP lookup for the check command.
- Netflix, Disney+, YouTube, Spotify, Amazon Prime Video, Max, Apple TV+, Hulu, BBC iPlayer and Paramount+: service pages and BBC public test-channel metadata queried by native media rules. Results are not account-level playback tests.
No affiliation or endorsement by these projects or providers is claimed.
API availability, accuracy and terms are controlled by each provider. IP location
is an estimate, not verified physical hardware location.
Last reviewed / 最后核对:2026-10-31.
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
if ! command -v python3 >/dev/null 2>&1; then
echo '需要 Python 3.10+ / Python 3.10+ is required.' >&2
exit 2
fi
if [ "$#" -eq 0 ]; then
exec python3 -u -m proxydoctor suite
fi
exec python3 -u -m proxydoctor "$@"
+3
View File
@@ -0,0 +1,3 @@
"""ProxyDoctor: collect evidence before proposing changes."""
__version__ = "0.5.0"
+8
View File
@@ -0,0 +1,8 @@
from .cli import main
try:
main()
except KeyboardInterrupt:
import sys
print('\n已中止 / Interrupted',file=sys.stderr)
sys.exit(130)
+228
View File
@@ -0,0 +1,228 @@
"""Repeated, traffic-bounded HTTPS throughput measurements; not line capacity."""
from dataclasses import asdict, dataclass
import math
import os
import re
import statistics
import time
import urllib.error
import urllib.request
from .net import opener, read_limited
@dataclass(frozen=True)
class Source:
name: str
region: str
download_url: str
upload_url: str | None = None
use_range: bool = False
# Fixed official test endpoints only. No arbitrary URLs, account keys or binaries.
SOURCES = {
'cloudflare': Source('Cloudflare','Anycast','https://speed.cloudflare.com/__down',
'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),
}
# Keep the slower, environment-sensitive Hetzner endpoint explicitly selectable.
DEFAULT_SOURCES = ('cloudflare','ovh-rbx')
def source_ids(selected=None):
selected = DEFAULT_SOURCES if selected is None else tuple(dict.fromkeys(selected))
if not selected or any(identifier not in SOURCES for identifier in selected):
raise ValueError('Invalid bandwidth source')
return selected
def payload_budget(rounds, sample_mib, selected=None):
return sum((2 if SOURCES[name].upload_url else 1) * (rounds * sample_mib * 1024**2 + 131072)
for name in source_ids(selected))
@dataclass
class Sample:
direction: str
size_bytes: int
elapsed_s: float = 0
mbps: float | None = None
error: str | None = None
pop: str | None = None
source: str = 'cloudflare'
def transfer(direction, size, source='cloudflare'):
if direction not in ('download', 'upload') or not 1 <= size <= 20 * 1024**2:
raise ValueError('Invalid transfer limits')
if source not in SOURCES:
raise ValueError('Invalid bandwidth source')
provider = SOURCES[source]
result = Sample(direction, size, source=source)
if direction == 'upload' and provider.upload_url is None:
result.error = 'unsupported'
return result
headers = {'User-Agent': 'ProxyDoctor/0.4.1', 'Accept-Encoding': 'identity',
'Cache-Control': 'no-cache'}
url = provider.download_url
if not provider.use_range:
url += '?bytes=' + str(size)
else:
headers['Range'] = f'bytes=0-{size-1}'
data = None
if direction == 'upload':
url = provider.upload_url
headers.pop('Range',None)
data = os.urandom(size) # Avoid compressible uploads artificially inflating results.
headers['Content-Type'] = 'application/octet-stream'
request = urllib.request.Request(url, data=data, headers=headers)
start = time.monotonic()
try:
with opener().open(request, timeout=12) as response:
ranged = direction == 'download' and provider.use_range
if ranged and response.status == 200:
# Never fall back to downloading an entire 100 MB test file.
result.error = 'range_not_supported'
return result
if response.status != (206 if ranged else 200):
result.error = 'http_' + str(response.status)
return result
if ranged:
match = re.fullmatch(r'bytes 0-(\d+)/(\d+)',response.headers.get('Content-Range',''))
length = response.headers.get('Content-Length')
if (not match or int(match[1]) != size-1 or int(match[2]) < size
or (length is not None and length != str(size))):
result.error = 'invalid_content_range'
return result
if ('text/html' in response.headers.get('Content-Type','').lower()
or response.headers.get('Content-Encoding','identity').lower() not in ('','identity')):
result.error = 'unexpected_content'
return result
ray = response.headers.get('CF-Ray', '')
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 len(body) != size:
result.error = 'short_response'
return result
result.elapsed_s = time.monotonic() - start
if result.elapsed_s <= 0 or not math.isfinite(result.elapsed_s):
result.error = 'invalid_duration'
else:
result.mbps = round(size * 8 / result.elapsed_s / 1_000_000, 3)
except urllib.error.HTTPError as exc:
result.error = 'http_' + str(exc.code)
exc.close()
except ValueError:
result.error = 'response_too_large'
except (OSError, urllib.error.URLError):
result.error = 'request_failed'
finally:
result.elapsed_s = round(max(0, time.monotonic() - start), 4)
return result
def summarize(samples):
good = [s for s in samples if s.error is None and s.mbps is not None and math.isfinite(s.mbps) and s.mbps > 0]
speeds = [s.mbps for s in good]
median = statistics.median(speeds) if speeds else None
spread = (max(speeds) - min(speeds)) / median if median else None
enough = len(good) >= 3 and len(good) == len(samples)
return {'successful': len(good), 'attempted': len(samples),
'median_mbps': round(median, 3) if median is not None else None,
'min_mbps': min(speeds) if speeds else None, 'max_mbps': max(speeds) if speeds else None,
'spread_percent': round(spread * 100, 1) if spread is not None else None,
'quality': 'unavailable' if not speeds else 'insufficient' if not enough else
'short_samples' if any(s.elapsed_s < 1 for s in good) else
'variable' if spread > .3 else 'consistent',
'samples': [asdict(s) for s in samples]}
def measure(rounds=3, sample_mib=4, on_sample=None, source='cloudflare'):
if not 3 <= rounds <= 8 or not 1 <= sample_mib <= 20:
raise ValueError('Invalid benchmark limits')
source_ids((source,))
provider = SOURCES[source]
report = {'source':source,'provider':provider.name,'region':provider.region,
'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,)),
'warmups': {}, 'directions': {}}
def sample_transfer(direction,size):
# Preserve the original single-source Python API.
return transfer(direction,size) if source=='cloudflare' else transfer(direction,size,source=source)
for direction in (('download','upload') if provider.upload_url else ('download',)):
warmup = sample_transfer(direction, 131072)
report['warmups'][direction] = asdict(warmup)
samples = []
for number in range(1, rounds + 1):
# Do not hammer an endpoint that explicitly refuses or rate-limits us.
previous = samples[-1] if samples else warmup
if previous.error in ('http_403','http_429','provider_refused'):
sample = Sample(direction,sample_mib*1024**2,error='provider_refused',source=source)
elif previous.error in ('range_not_supported','invalid_content_range','unexpected_content','source_unavailable'):
sample = Sample(direction,sample_mib*1024**2,error=previous.error,source=source)
elif samples and previous.error and (samples[-2].error if len(samples)>1 else warmup.error):
sample = Sample(direction,sample_mib*1024**2,error='source_unavailable',source=source)
else:
sample = sample_transfer(direction,sample_mib*1024**2)
samples.append(sample)
if on_sample:
on_sample(direction, number, rounds, sample)
report['directions'][direction] = summarize(samples)
return report
def measure_many(rounds=3, sample_mib=4, sources=None, on_source=None, on_sample=None):
if not 3 <= rounds <= 8 or not 1 <= sample_mib <= 20:
raise ValueError('Invalid benchmark limits')
selected=source_ids(sources)
report={'schema_version':2,'method':'sequential_https_wall_time',
'payload_budget_bytes':payload_budget(rounds,sample_mib,selected),'sources':{}}
for identifier in selected:
if on_source: on_source(identifier,'start',None)
callback=(lambda *args: on_sample(identifier,*args)) if on_sample else None
result=measure(rounds,sample_mib,callback,source=identifier)
report['sources'][identifier]=result
if on_source: on_source(identifier,'complete',result)
return report
def is_complete(report):
return all(result['quality']=='consistent' for source in report['sources'].values()
for result in source['directions'].values())
def run_console(lang='zh',color='auto',rounds=3,sample_mib=4,sources=None):
from .console import Console
out=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)
def source_output(identifier,event,result):
provider=SOURCES[identifier]
if event=='start':
print('\n'+out.paint(f'{provider.name} · {provider.region}','title'),flush=True)
if not provider.upload_url: out.row(tr('上传','Upload'),tr('不支持(该来源仅提供下载)','Not supported (download-only source)'),'unknown')
return
for direction,summary in result['directions'].items():
label=tr('下载统计','Download summary') if direction=='download' else tr('上传统计','Upload summary')
median=summary['median_mbps']
value=tr('无有效样本','No valid samples') if median is None else tr(
f"中位 {median:.2f} · 范围 {summary['min_mbps']:.2f}–{summary['max_mbps']:.2f} Mbps · {summary['successful']}/{summary['attempted']} 成功",
f"Median {median:.2f} · Range {summary['min_mbps']:.2f}–{summary['max_mbps']:.2f} Mbps · {summary['successful']}/{summary['attempted']} OK")
out.row(label,value,'ok' if summary['quality']=='consistent' else 'unknown')
note={'short_samples':tr('样本短于 1 秒,容量未知','Samples below 1s; capacity unknown'),
'variable':tr('轮间波动较大','High variation between rounds'),
'insufficient':tr('有效轮次不足','Insufficient successful rounds')}.get(summary['quality'])
if note: out.row('',note,'unknown')
def sample_output(identifier,direction,number,total,sample):
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)
+428
View File
@@ -0,0 +1,428 @@
"""Read-only checks. No shell, privilege escalation or system mutations."""
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
import ipaddress
import json
import os
import platform
import re
from pathlib import Path
import shutil
import subprocess
import urllib.error
import urllib.request
from .i18n import translator
from . import __version__
@dataclass
class Result:
id: str
status: str
title: str
detail: str
evidence: str = ""
advice: str = ""
@dataclass
class Command:
code: int | None
output: str = ""
reason: str = ""
# Only these fixed command arguments may reach subprocess.
ALLOWED = {
("ip", "-j", "address", "show"),
("ip", "-j", "-4", "route", "show", "default"),
("ip", "-j", "-6", "route", "show", "default"),
("ss", "-H", "-lntu"),
("sshd", "-T"),
("ufw", "status"),
("nft", "list", "ruleset"),
("iptables", "-S"),
("systemctl", "is-active", "sing-box.service"),
("systemctl", "is-enabled", "sing-box.service"),
}
SYSTEM_PATH = "/usr/sbin:/usr/bin:/sbin:/bin"
OUTPUT_LIMIT = 131072
class Host:
def __init__(self, lang="zh"):
self.lang = lang
def system(self):
return platform.system(), platform.machine()
def read(self, path):
try:
return Path(path).read_text(encoding="utf-8")
except (OSError, UnicodeError):
return None
def disk_free(self):
return shutil.disk_usage("/").free
def disk_total(self):
return shutil.disk_usage("/").total
def cpu_count(self):
return os.cpu_count()
def kernel(self):
return platform.release()
def ssh_connection(self):
return os.environ.get("SSH_CONNECTION", "")
def detect_public_ip(self):
# Explicit opt-in; no environment proxies or redirects.
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
with opener.open('https://api64.ipify.org', timeout=5) as response:
payload = response.read(129)
if len(payload) > 128:
return None
address = ipaddress.ip_address(payload.decode('ascii').strip())
return str(address) if address.is_global else None
except (urllib.error.URLError, OSError, UnicodeError, ValueError):
return None
def run(self, *args):
t = translator(self.lang)
if args not in ALLOWED:
raise ValueError("Command is not allowlisted")
# Ignore the caller's PATH, especially when a user chooses to run as root.
executable = shutil.which(args[0], path=SYSTEM_PATH)
if executable is None and args[0] in {"sshd", "ufw", "nft", "iptables"}:
for directory in ("/usr/sbin", "/sbin"):
candidate = Path(directory) / args[0]
if candidate.is_file():
executable = str(candidate)
break
if executable is None:
return Command(None, reason=t("命令不可用"))
try:
proc = subprocess.run(
[executable, *args[1:]], capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=8, check=False,
env={"PATH": SYSTEM_PATH, "LC_ALL": "C"},
)
# Never return stderr: it can contain unrelated host details.
if len(proc.stdout) > OUTPUT_LIMIT:
return Command(None, reason=t("输出超过读取上限,不能作为完整检查结果"))
return Command(proc.returncode, proc.stdout,
t("读取命令失败,可能是权限、配置或服务状态问题") if proc.returncode else "")
except subprocess.TimeoutExpired:
return Command(None, reason=t("读取超时(8 秒)"))
except OSError:
return Command(None, reason=t("无法执行读取命令"))
def parse_os(text):
values = {}
for line in (text or "").splitlines():
if "=" in line and not line.startswith("#"):
key, value = line.split("=", 1)
values[key] = value.strip().strip('"').strip("'")
return values
def port_rows(text):
"""Parse ss -H -lntu without relying on process ownership visibility."""
rows = []
malformed = False
for line in text.splitlines():
fields = line.split()
if len(fields) < 6 or fields[0] not in {"tcp", "udp"}:
malformed = True
continue
endpoint = fields[4]
try:
port = int(endpoint.rsplit(":", 1)[1])
if not 1 <= port <= 65535:
raise ValueError("Invalid port")
except (ValueError, IndexError):
malformed = True
continue
rows.append((fields[0], endpoint, port))
return rows, malformed
def ubuntu_version(value):
"""Parse VERSION_ID numerically; never infer Ubuntu from ID_LIKE."""
if not re.fullmatch(r"[0-9]{2}\.(04|10)", value or ""):
return None
return tuple(int(part) for part in value.split("."))
def route_check(host, t):
evidence, valid, failed = [], 0, False
for family in ("-4", "-6"):
result = host.run("ip", "-j", family, "route", "show", "default")
try:
if result.code != 0:
raise ValueError
routes = json.loads(result.output)
if not isinstance(routes, list):
raise ValueError
for route in routes:
if not isinstance(route, dict) or route.get("dst") not in ("default", "0.0.0.0/0", "::/0"):
raise ValueError
if route.get("type", "unicast") != "unicast":
continue
dev = route.get("dev")
gateway = route.get("gateway")
if not isinstance(dev, str) or not dev or (gateway is not None and not isinstance(gateway, str)):
raise ValueError
if gateway is not None:
address = ipaddress.ip_address(gateway)
if address.version != (4 if family == "-4" else 6):
raise ValueError
evidence.append(f"IPv{family[-1]}: {dev}" + (f" via {gateway}" if gateway else ""))
valid += 1
except (ValueError, TypeError, KeyError):
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("默认路由信息不完整或未找到,无法确认网络出口"), "\n".join(evidence),
t("仅检查当前网络命名空间的默认路由,不覆盖策略路由。"))
def dns_check(host, t):
content = host.read("/etc/resolv.conf")
if content is None:
return Result("dns_config", "unknown", t("DNS 配置"), t("无法读取 DNS 配置"))
addresses, invalid = [], False
for line in content.splitlines():
line = line.split("#", 1)[0].split(";", 1)[0].strip()
fields = line.split()
if fields and fields[0] == "nameserver":
if len(fields) != 2:
invalid = True
continue
try:
address = ipaddress.ip_address(fields[1])
if address.is_unspecified or address.is_multicast:
raise ValueError
addresses.append(address)
except ValueError:
invalid = True
evidence = "\n".join(str(addr) for addr in addresses)
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 服务器配置;尚未验证解析是否成功"))
return Result("dns_config", status, t("DNS 配置"), detail, evidence,
t("仅检查 resolv.conf,不主动发送 DNS 请求;实际解析路径可能不同。"))
def ssh_port_check(host, ssh, port, t):
configured, malformed = [], False
if ssh.code == 0:
for line in ssh.output.splitlines():
fields = line.split()
if fields and fields[0] == "port":
try:
if len(fields) != 2 or not 1 <= int(fields[1]) <= 65535:
raise ValueError
configured.append(int(fields[1]))
except ValueError:
malformed = True
try:
session = host.ssh_connection()
except AttributeError:
session = ""
session_port = None
if session:
try:
fields = session.split()
if len(fields) != 4:
raise ValueError
ipaddress.ip_address(fields[0]); ipaddress.ip_address(fields[2])
if not 1 <= int(fields[1]) <= 65535 or not 1 <= int(fields[3]) <= 65535:
raise ValueError
session_port = int(fields[3])
except ValueError:
session_port = None
evidence = []
if configured:
evidence.append(t("sshd 默认配置端口:{ports}", ports=", ".join(map(str, sorted(set(configured))))))
if session_port is not None:
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)
def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=False):
t = translator(lang)
host = host or Host(lang)
results = []
add = results.append
system, arch = host.system()
os_info = parse_os(host.read("/etc/os-release")) if system == "Linux" else {}
version = ubuntu_version(os_info.get("VERSION_ID"))
is_ubuntu = os_info.get("ID") == "ubuntu"
supported = is_ubuntu and version is not None and version >= (22, 4)
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("无法读取 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 或更新版本验证。")))
add(Result("architecture", "pass" if arch.lower() in {"x86_64", "amd64", "aarch64", "arm64"} else "unknown",
t("系统架构"), t("检测架构;本阶段不验证安装包兼容性"), arch))
try:
count = host.cpu_count()
except (AttributeError, OSError):
count = None
cpuinfo = host.read('/proc/cpuinfo') if system == 'Linux' else None
model = next((line.split(':', 1)[1].strip() for line in (cpuinfo or '').splitlines()
if line.split(':', 1)[0].strip() in ('model name', 'Hardware') and ':' in line), None)
add(Result('cpu', 'pass' if count else 'unknown', t('CPU 信息'),
t('系统可见逻辑 CPU:{count}', count=count) if count else t('逻辑 CPU 数量未知'),
t('处理器型号:{model}', model=model) if model else t('处理器型号未知'),
t('逻辑 CPU 数不一定等于物理核心数;配额限制可能不同。')))
try:
kernel = host.kernel()
except (AttributeError, OSError):
kernel = None
add(Result('kernel', 'pass' if kernel else 'unknown', t('内核版本'), kernel or t('无法读取')))
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)))
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 购买的磁盘总容量。')))
except (AttributeError, OSError):
add(Result('disk_capacity', 'unknown', t('根分区容量'), t('无法读取')))
if public_ip and detect_public_ip:
raise ValueError("public_ip and detect_public_ip are mutually exclusive")
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 或代理地址;不是入站可达性证明。")))
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("提供的地址不是全局可路由公网地址"),
str(addr)))
else:
add(Result("public_ip", "unknown", t("公网 IP"), t("默认不向第三方查询出口 IP"),
advice=t("可用 --public-ip 填写云控制台地址;地址归属及入站可达性仍需外部验证。")))
add(Result("cloud_firewall", "unknown", t("云防火墙与外部可达性"),
t("本机读取不能确认云安全组、NAT 或外部网络路径"),
advice=t("后续从客户端独立测试;不能仅据此认定端口已开放或被封锁。")))
if system != "Linux":
for key, title in (("memory", t("内存")), ("interfaces", t("网络地址")),
("default_route", t("默认路由")), ("dns_config", t("DNS 配置")),
("ports", t("端口占用")), ("ssh", t("SSH 配置")),
("ssh_port_safety", t("SSH 端口保护")),
("firewall", t("本机防火墙")), ("service", t("sing-box 服务")),
("service_startup", t("sing-box 开机启动"))):
add(Result(key, "skip", title, t("此项需要 Linux;当前主机仅验证程序运行")))
return report(results, port, lang)
memory = host.read("/proc/meminfo")
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('系统可见内存可能与商家标称或容器限额不同。')))
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)))
except (ValueError, IndexError):
add(Result("memory", "unknown", t("可用内存"), t("无法读取 MemAvailable")))
network = host.run("ip", "-j", "address", "show")
try:
interfaces = json.loads(network.output) if network.code == 0 else None
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)))
except (TypeError, ValueError, KeyError, AttributeError):
add(Result("interfaces", "unknown", t("网络地址"), t(network.reason) or t("无法解析接口信息")))
add(route_check(host, t))
add(dns_check(host, t))
ports = host.run("ss", "-H", "-lntu")
rows, malformed = port_rows(ports.output)
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),
"\n".join(conflicts), t("选择未占用端口;不会停止已有服务。") if conflicts else ""))
ssh = host.run("sshd", "-T")
add(ssh_port_check(host, ssh, port, t))
# Only retain selected settings, never dump full ssh configuration.
settings = dict(line.split(None, 1) for line in ssh.output.splitlines() if len(line.split(None, 1)) == 2)
selected = {key: settings[key] for key in ("port", "permitrootlogin", "passwordauthentication", "pubkeyauthentication") if key in settings}
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 安全"),
"\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。")))
firewall = []
any_rules = False
complete = True
for args in (("ufw", "status"), ("nft", "list", "ruleset"), ("iptables", "-S")):
cmd = host.run(*args)
if cmd.code != 0:
complete = False
firewall.append(f"{args[0]}: {t(cmd.reason) or t('读取失败')}")
else:
meaningful = bool(cmd.output.strip()) and not cmd.output.strip().startswith("Status: inactive")
any_rules |= meaningful
firewall.append(f"{args[0]}: " + t("已读取;") + (t("存在状态或规则信息") if meaningful else t("未发现活动状态或规则")))
add(Result("firewall", "unknown", t("本机防火墙"),
t("发现防火墙状态或规则;未判断规则是否允许目标端口") if any_rules else t("没有足够证据确认防火墙保护状态"),
"\n".join(firewall), t("部分工具不可用或权限不足。") if not complete else t("规则存在不等于配置安全。")))
active = host.run("systemctl", "is-active", "sing-box.service")
enabled = host.run("systemctl", "is-enabled", "sing-box.service")
running = active.code == 0 and active.output.strip() == "active"
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 服务运行;新系统尚未安装属于正常情况"),
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("未配置持久开机启动或服务被屏蔽,重启后可能不可用") 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__,
"generated_at": datetime.now(timezone.utc).isoformat(), "mode": "read-only",
"target_port": port, "checks": [asdict(result) for result in results]}
+237
View File
@@ -0,0 +1,237 @@
import argparse
import ipaddress
import json
from pathlib import Path
import re
import shutil
import subprocess
import sys
from . import __version__
from .checks import inspect
from .i18n import translator
from .quality import run_quality
from .suite import run_suite
from .bandwidth import SOURCES
LABELS = {"pass": "通过", "warn": "注意", "unknown": "未知", "skip": "跳过", "fail": "失败"}
def redact(value, lang="zh"):
"""Mask IP addresses in exports; never claim arbitrary text is secret-free."""
if isinstance(value, dict):
return {key: redact(item, lang) for key, item in value.items()}
if isinstance(value, list):
return [redact(item, lang) for item in value]
if not isinstance(value, str):
return value
hidden = translator(lang)("[IP已隐藏]")
def mask_v6(match):
token = match.group(0)
address = token.rstrip(".")
try:
ipaddress.IPv6Address(address.split("%", 1)[0])
return hidden + token[len(address):]
except ValueError:
return token
# IPv6 first: replacing embedded IPv4 first would break mapped IPv6 parsing.
value = re.sub(r"[0-9a-fA-F:.]+(?:%[a-zA-Z0-9_.-]+)?", mask_v6, value)
return re.sub(r"(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?!\w|\.\d)", hidden, value)
class LocalizedParser(argparse.ArgumentParser):
def __init__(self, *args, lang="zh", **kwargs):
self.lang = lang
super().__init__(*args, add_help=False, **kwargs)
t = translator(lang)
self._positionals.title = t("命令")
self._optionals.title = t("选项")
self.add_argument("-h", "--help", action="help", help=t("显示帮助并退出"))
def error(self, message):
self.print_usage(sys.stderr)
self.exit(2, translator(self.lang)("参数错误:{message}", message=message) + "\n")
def main():
# Support Windows consoles that cannot encode Chinese.
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
# Discover language before building help/errors. --lang works on either side of check.
language_parser = argparse.ArgumentParser(add_help=False)
language_parser.add_argument("--lang", choices=("zh", "en"), default="zh")
language_args, remaining = language_parser.parse_known_args()
lang = language_args.lang
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("显示版本并退出"))
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("读取系统信息并生成体检报告"))
check.add_argument("--lang", choices=("zh", "en"), default=lang, help=t("界面和报告语言:zh 中文,en 英文(默认 zh)"))
check.add_argument("--port", type=int, default=8388, help=t("拟使用的代理端口,默认 8388"))
ips = check.add_mutually_exclusive_group()
ips.add_argument("--public-ip", help=t("云控制台提供的 IP;本工具不验证归属"))
ips.add_argument("--detect-public-ip", action="store_true", help=t("向 ipify 发送 HTTPS 请求查询出口 IP(第三方会看到该地址)"))
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;已存在的文件不会覆盖"))
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
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('--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)
bandwidth.add_argument('--color',choices=('auto','always','never'),default='auto')
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('--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)
media.add_argument('--color',choices=('auto','always','never'),default='auto')
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('--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('--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'))
suite.add_argument('--skip-route',action='store_true',help=st('跳过路由追踪','Skip route trace'))
suite.add_argument('--json-output',type=Path,help=st('保存脱敏检测依据,不覆盖文件','Save redacted evidence; never overwrite'))
suite.add_argument('--route-target',default='1.1.1.1',help=st('路由目标,IPv4 或 IPv6 地址','Numeric IPv4 or IPv6 route destination'))
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('--no-geo',action='store_true',help=st('不查询路由 IP 位置','Skip route IP geolocation lookups'))
suite.add_argument('--color',choices=('auto','always','never'),default='auto')
args = parser.parse_args(remaining)
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)
if args.command == 'bandwidth':
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)
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.
if args.command == 'suite':
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'))
if args.json_output and not args.json_output.parent.is_dir():
parser.error(st('输出目录不存在','Output directory does not exist'))
try:
ipaddress.ip_address(args.route_target)
except ValueError:
parser.error(st('--route-target 必须是 IPv4 或 IPv6 地址','Invalid --route-target: use an IPv4 or IPv6 address'))
try:
code=run_suite(lang=lang,offline=args.offline,sample_mib=args.sample_mib,
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)
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))
if args.command == 'nodequality':
if not args.script.is_file():
parser.error(t("找不到本地脚本文件"))
bash = shutil.which('bash')
if bash is None:
parser.error(t("未找到 bash,无法运行脚本"))
print(t("即将运行第三方 NodeQuality;其可能下载组件、进行网络及性能测试,并向第三方上传结果。输出可能包含公网 IP。"), flush=True)
try:
result = subprocess.run([bash, str(args.script.resolve())], check=False)
except OSError:
parser.error(t("无法启动 NodeQuality 脚本"))
sys.exit(result.returncode if result.returncode >= 0 else 128 - result.returncode)
if not 1 <= args.port <= 65535:
parser.error(t("端口必须在 1~65535 之间"))
if args.public_ip:
try:
ipaddress.ip_address(args.public_ip)
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)
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)
if args.output:
try:
with args.output.open("x", encoding="utf-8") as stream:
stream.write(serialized + "\n")
except OSError as exc:
parser.error(t("无法保存报告:{error}", error=exc))
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")
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()
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;分享前请检查内容。通过某一项不代表整体安全或代理可用。"))
# 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
sys.exit(return_code)
+31
View File
@@ -0,0 +1,31 @@
"""Compact terminal output with optional ANSI colour."""
import os
import re
import sys
import unicodedata
def clean(value):
return re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', str(value)).strip()
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)
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]
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 section(self,index,zh,en):
print('\n'+self.paint(f'===== {index}. {self.text(zh,en)} =====','title'),flush=True)
+37
View File
@@ -0,0 +1,37 @@
"""Best-effort IP database location, not physical-location verification."""
import ipaddress
import json
import urllib.error
import urllib.request
class GeoLocator:
def __init__(self, lang='zh', enabled=True):
self.lang,self.enabled,self.cache=lang,enabled,{}
def lookup(self,address):
addr=ipaddress.ip_address(address)
if not addr.is_global:
return '内网/保留地址' if self.lang=='zh' else 'Private/reserved'
unknown='位置未知' if self.lang=='zh' else 'Unknown location'
if not self.enabled: return unknown
if address in self.cache: return self.cache[address]
result=unknown
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self,*args): return None
try:
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect())
language='zh-CN' if self.lang=='zh' else 'en'
with opener.open('https://ipwho.is/'+str(addr)+'?lang='+language,timeout=2) as response:
payload=response.read(16385)
if len(payload)<=16384:
data=json.loads(payload)
if isinstance(data,dict) and data.get('success') is True:
location=[data.get(k) for k in ('country','region','city')]
values=list(dict.fromkeys(v for v in location if isinstance(v,str) and v))
if values: result=' / '.join(values)
except urllib.error.HTTPError as exc:
if exc.code==429: self.enabled=False
except (OSError,ValueError,urllib.error.URLError): pass
self.cache[address]=result
return result
+149
View File
@@ -0,0 +1,149 @@
"""Shared messages; identifiers and raw command evidence remain language neutral."""
MESSAGES = {
'联网检测 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 ...',
'DNS 查询成功:{versions},耗时 {ms} ms': 'DNS lookup succeeded: {versions}, {ms} ms',
'其他': 'other',
'DNS 查询未成功;原因可能是超时、网络或解析器配置,不能确定唯一原因。': 'DNS lookup did not succeed; timeout, network or resolver configuration may be responsible.',
'正在测试 {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.',
'运行本地 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',
'未找到 bash,无法运行脚本': 'bash was not found; cannot run the script',
'无法启动 NodeQuality 脚本': 'Unable to start the NodeQuality script',
'即将运行第三方 NodeQuality;其可能下载组件、进行网络及性能测试,并向第三方上传结果。输出可能包含公网 IP。': 'Running third-party NodeQuality; it may download components, run network and performance tests, and upload results to third parties. Output may contain public IP addresses.',
'CPU 信息': 'CPU information',
'系统可见逻辑 CPU:{count}': 'Logical CPUs visible to the system: {count}',
'逻辑 CPU 数量未知': 'Logical CPU count unknown',
'处理器型号:{model}': 'Processor model: {model}',
'处理器型号未知': 'Processor model unknown',
'逻辑 CPU 数不一定等于物理核心数;配额限制可能不同。': 'Logical CPUs may differ from physical cores and CPU quota limits.',
'内核版本': 'Kernel version',
'根分区容量': 'Root filesystem capacity',
'总容量 {total:.1f} GiB,可用 {free:.1f} GiB': 'Total {total:.1f} GiB, available {free:.1f} GiB',
'这是根文件系统容量,不一定等于 VPS 购买的磁盘总容量。': 'This is the root filesystem capacity, which may differ from the purchased disk capacity.',
'内存总量': 'Total memory',
'系统可见 {total:.0f} MiB': 'Visible to the system: {total:.0f} MiB',
'系统可见内存可能与商家标称或容器限额不同。': 'System-visible memory may differ from the advertised amount or container limits.',
'服务器信息': 'Server information',
'体检结果': 'Diagnostic results',
"仅在终端显示完整 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",
"出口 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 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",
"仅检查 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}",
"当前 SSH 会话本地端口:{port}": "Current SSH session local port: {port}",
"目标端口与 SSH 已知端口冲突;不要用于代理": "The target port conflicts with a known SSH port; do not use it for the proxy",
"未能排除与运行中 SSH 端口冲突": "A conflict with a running SSH port cannot be ruled out",
"检查运行中 SSH、socket 激活及自定义实例;证据不足时不要进行端口或防火墙修改。": "Check running SSH, socket activation and custom instances; do not change ports or firewall rules when evidence is insufficient.",
"SSH 端口输出不完整或无效": "SSH port output is incomplete or invalid",
"输出超过读取上限,不能作为完整检查结果": "Output exceeded the read limit; it cannot be treated as a complete result",
"无法读取 Linux 发行版标识,不能确认系统要求": "Unable to read the Linux distribution ID; system requirements are unconfirmed",
"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 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",
"命令返回非零状态,可能需要读取权限": "Command returned a nonzero status; read permissions may be required",
"读取命令失败,可能是权限、配置或服务状态问题": "Read command failed; permissions, configuration or service state may be responsible",
"sshd -T 读取默认配置;权限不足、配置错误或 SSH 运行目录尚未创建均可能导致失败。结果可能与运行中服务或 Match 条件不同;不自动修改 SSH。": "sshd -T reads default configuration; it can fail due to permissions, configuration errors or a missing SSH runtime directory. Results may differ from the running service or Match conditions; SSH is not modified.",
"读取超时(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 版本,不能确认是否符合最低要求": "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.",
"系统架构": "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)",
"无法读取": "Unable to read",
"公网 IP": "Public IP",
"用户提供的地址具备公网地址属性,但未验证归属或可达性": "The supplied address is globally routable; ownership and reachability are unverified",
"提供的地址不是全局可路由公网地址": "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.",
"云防火墙与外部可达性": "Cloud firewall and external reachability",
"本机读取不能确认云安全组、NAT 或外部网络路径": "Local reads cannot verify cloud security groups, NAT or external network paths",
"后续从客户端独立测试;不能仅据此认定端口已开放或被封锁。": "Test independently from a client; this result alone cannot establish whether a port is reachable or blocked.",
"内存": "Memory", "网络地址": "Network addresses", "端口占用": "Port usage",
"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)",
"无法读取 MemAvailable": "Unable to read MemAvailable",
"仅表示本机接口地址;不证明互联网可达": "Local interface addresses only; not proof of Internet reachability",
"无法解析接口信息": "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",
"选择未占用端口;不会停止已有服务。": "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.",
"读取失败": "Read failed", "已读取;": "Read completed; ",
"存在状态或规则信息": "state or rule information exists",
"未发现活动状态或规则": "no active state or rules found",
"发现防火墙状态或规则;未判断规则是否允许目标端口": "Firewall state or rules found; permission for the target port is not evaluated",
"没有足够证据确认防火墙保护状态": "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",
"未确认 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",
"读取系统信息并生成体检报告": "Read system information and generate a diagnostic report",
"拟使用的代理端口,默认 8388": "Intended proxy port (default: 8388)",
"云控制台提供的 IP;本工具不验证归属": "IP from your cloud console; ownership is not verified",
"输出 JSON(默认隐藏 IP)": "Print JSON (IP addresses are redacted by default)",
"另存为脱敏 JSON;已存在的文件不会覆盖": "Save redacted JSON; existing files are never overwritten",
"界面和报告语言:zh 中文,en 英文(默认 zh)": "Interface and report language: zh Chinese, en English (default: zh)",
"端口必须在 1~65535 之间": "Port must be between 1 and 65535",
"--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.",
"参数错误:{message}": "Argument error: {message}",
"显示帮助并退出": "Show this help message and exit",
"显示版本并退出": "Show version and exit",
"命令": "Commands", "选项": "Options",
}
def translator(lang):
if lang not in {"zh", "en"}:
raise ValueError("Unsupported language; choose zh or en")
def translate(key, **values):
text = MESSAGES.get(key, key) if lang == "en" else key
return text.format(**values) if values else text
return translate
+219
View File
@@ -0,0 +1,219 @@
"""IP ownership, type and reputation with provenance and explicit missing data."""
from datetime import datetime, timezone
import ipaddress
import os
import urllib.parse
from .net import request_json
FLAGS = ('is_datacenter', 'is_proxy', 'is_vpn', 'is_tor', 'is_abuser',
'is_mobile', 'is_satellite', 'is_anycast', 'is_bogon')
FIELDS = ('company', 'asn', 'country', 'region', 'city', 'timezone', 'network_type',
'network_abuser_score') + FLAGS
def valid_ip(value):
if not isinstance(value,str): return None
try:
address = ipaddress.ip_address(value)
return str(address) if address.is_global else None
except (ValueError, TypeError):
return None
def text(value):
return value[:200] if isinstance(value, str) and value.strip() else None
def normalize(data):
values = {field: None for field in FIELDS}
company = data.get('company')
asn = data.get('asn')
location = data.get('location') if isinstance(data.get('location'), dict) else data
if isinstance(company, dict):
values['company'] = text(company.get('name'))
values['network_type'] = text(company.get('type'))
values['network_abuser_score'] = text(company.get('abuser_score'))
else:
values['company'] = text(company)
if isinstance(asn, dict):
number = asn.get('asn')
if isinstance(number, int) and not isinstance(number, bool) and number > 0:
values['asn'] = 'AS' + str(number) + (' ' + text(asn.get('org')) if text(asn.get('org')) else '')
values['network_type'] = values['network_type'] or text(asn.get('type'))
else:
values['asn'] = text(asn)
for name in ('country', 'region', 'city', 'timezone'):
values[name] = text(location.get(name))
for name in FLAGS:
if type(data.get(name)) is bool:
values[name] = data[name]
return values
def normalize_proxycheck(item):
values={field:None for field in FIELDS}
network=item.get('network') if isinstance(item.get('network'),dict) else {}
location=item.get('location') if isinstance(item.get('location'),dict) else {}
detections=item.get('detections') if isinstance(item.get('detections'),dict) else {}
values.update(company=text(network.get('provider')),asn=text(network.get('asn')),
network_type=text(network.get('type')))
for target,source in [('country','country_name'),('region','region_name'),('city','city_name'),('timezone','timezone')]:
values[target]=text(location.get(source))
for target,source in [('is_datacenter','hosting'),('is_proxy','proxy'),('is_vpn','vpn'),('is_tor','tor')]:
if type(detections.get(source)) is bool: values[target]=detections[source]
return values
def normalize_ip2location(item):
values={field:None for field in FIELDS}
values['company']=text(item.get('isp')) or text(item.get('as'))
number=item.get('asn')
if isinstance(number,(int,str)) and not isinstance(number,bool) and str(number).isdigit():
values['asn']='AS'+str(number)
for target,source in [('country','country_name'),('region','region_name'),('city','city_name'),('timezone','time_zone')]:
values[target]=text(item.get(source))
# The free plan's proxy flag excludes some VPN types. Do not merge it into
# the common proxy/VPN flags; keep it as a provider-specific observation.
return values
def reconcile(report):
observations=report['observations']
report['field_sources']={}
report['conflicts']={}
for name in FIELDS:
candidates={source:values[name] for source,values in observations.items() if values.get(name) is not None}
if name in FLAGS and len(set(candidates.values()))>1:
report['values'][name]=None
report['conflicts'][name]=candidates
report['field_sources'][name]=list(candidates)
elif candidates:
first=next(iter(candidates.values()))
report['values'][name]=first
report['field_sources'][name]=[source for source,value in candidates.items() if value==first]
available=sum(value is not None for value in report['values'].values())
report['coverage']={'available':available,'total':len(FIELDS),
'complete':available==len(FIELDS) and report['sources']['AbuseIPDB']['state']=='ok'}
def enrich_report(report):
address=report['ip']
if not address: return
url='https://proxycheck.io/v3/'+urllib.parse.quote(address,safe=':')+'?tag=0&ver=24-June-2026'
data,state=request_json(url)
item=None
if state=='ok':
if data.get('status')!='ok': state='provider_error'
else:
item=next((value for key,value in data.items() if valid_ip(key)==address),None)
if not isinstance(item,dict) or not isinstance(item.get('detections'),dict):
state='invalid_response'
report['sources']['proxycheck.io']={'state':state,'access':'anonymous'}
if state=='ok':
report['observations']['proxycheck.io']=normalize_proxycheck(item)
detections=item['detections']
risk={key:detections.get(key) if type(detections.get(key)) is int and 0<=detections[key]<=100 else None
for key in ('risk','confidence')}
for key in ('anonymous','compromised','scraper'):
risk[key]=detections.get(key) if type(detections.get(key)) is bool else None
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
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'}
if state=='ok':
report['observations']['IP2Location']=normalize_ip2location(data)
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):
"""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()
data, state = request_json('https://api.ipapi.is/', payload={'key': key} if key else None)
address = valid_ip(data.get('ip')) if data else None
if state == 'ok' and (not address or data.get('error')):
state = 'invalid_response'
if state != 'ok':
address = None
values = normalize(data) if state == 'ok' else {field: None for field in FIELDS}
missing = [name for name, value in values.items() if value is None]
report = {'checked_at': datetime.now(timezone.utc).isoformat(), 'ip': address,
'ip_family': ipaddress.ip_address(address).version if address else None,
'scope': 'request_egress_only', 'values': values,
'sources': {'ipapi.is': {'state': state, 'access': 'key' if key else 'anonymous',
'missing_fields': missing}},
'reputation': {'score': None, 'total_reports': None, 'distinct_reporters': None,
'last_reported_at': None, 'window_days': 90}}
report['observations']={'ipapi.is':values.copy()}
report['provider_risk']={}
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
if fallback_state=='ok' and not address: fallback_state='invalid_response'
report['sources']['ipify']={'state':fallback_state}
report['ip']=address
report['ip_family']=ipaddress.ip_address(address).version if address else None
abuse_state = 'key_required' if not abuse_key else 'ip_unavailable'
if abuse_key and address:
url = 'https://api.abuseipdb.com/api/v2/check?' + urllib.parse.urlencode(
{'ipAddress': address, 'maxAgeInDays': 90})
abuse, abuse_state = request_json(url, headers={'Key': abuse_key})
item = abuse.get('data') if abuse else None
if abuse_state == 'ok':
if not isinstance(item, dict) or valid_ip(item.get('ipAddress')) != address:
abuse_state = 'invalid_response'
else:
for source, target, maximum in [('abuseConfidenceScore', 'score', 100),
('totalReports', 'total_reports', None),
('numDistinctUsers', 'distinct_reporters', None)]:
value = item.get(source)
if type(value) is int and value >= 0 and (maximum is None or value <= maximum):
report['reputation'][target] = value
report['reputation']['last_reported_at'] = text(item.get('lastReportedAt'))
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)
reconcile(report)
return report
def render(report,out,show_ip=False):
tr=out.text;values=report['values'];unknown=tr('未知','Unknown')
address=report['ip']
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'),
('network_type','网络类型','Network type'),('network_abuser_score','网段滥用比例','Network abuse share')]:
value=values.get(key)
out.row(tr(zh,en),value if value is not None else unknown,'ok' if value is not None else 'unknown')
labels=[('数据中心','Datacenter'),('代理','Proxy'),('VPN','VPN'),('Tor','Tor'),('近期滥用','Recent abuse'),
('移动网络','Mobile'),('卫星','Satellite'),('Anycast','Anycast'),('保留地址','Bogon')]
for key,(zh,en) in zip(FLAGS,labels):
value=values.get(key);risk=key in ('is_proxy','is_vpn','is_tor','is_abuser','is_bogon')
label=tr('来源不一致','Sources disagree') if key in report.get('conflicts',{}) else tr('是','Yes') if value is True else tr('否','No') if value is False else unknown
out.row(tr(zh,en),label,'unknown' if value is None else 'bad' if risk and value else 'ok')
pc=report.get('provider_risk',{}).get('proxycheck.io',{})
risk=pc.get('risk');confidence=pc.get('confidence')
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')
coverage=report['coverage']
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]
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)
+308
View File
@@ -0,0 +1,308 @@
"""Conservative, independent media checks. No cookies, accounts or upstream scripts."""
from dataclasses import dataclass
from html.parser import HTMLParser
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from .net import read_limited
SERVICES = {
'Netflix': ('https://www.netflix.com/', ('netflix is not available in your country',
'netflix is not available in your region')),
'Disney+': ('https://www.disneyplus.com/', ('disney+ is not available in your region',
'disney+ is not available in your country',
'disney plus is not available in your region')),
'YouTube Premium': ('https://www.youtube.com/premium', ('premium is not available in your country',
'premium is not available in your region')),
'Spotify': ('https://www.spotify.com/', ('spotify is currently not available in your country',
'spotify is not available in your country')),
'Prime Video': ('https://www.primevideo.com/', ('prime video is not available in your location',
'prime video is not available in your country')),
'Max': ('https://www.max.com/', ('max is not available in your region',
'max is not available in your country',
'not available in your region yet')),
'Apple TV+': ('https://tv.apple.com/', ('apple tv is not available in your country',
'apple tv+ is not available in your region')),
'Hulu': ('https://www.hulu.com/', ('hulu is not available in your region',
'hulu is only available in the u.s.',
'hulu is only available in the united states')),
'BBC iPlayer': ('https://www.bbc.co.uk/iplayer', ('bbc iplayer only works in the uk',
'bbc iplayer is only available in the uk')),
'Paramount+': ('https://www.paramountplus.com/', ('paramount+ is not available in your region',
'paramount+ is not available in your country')),
}
LIMIT = 4 * 1024 * 1024
BBC_SELECTOR='https://open.live.bbc.co.uk/mediaselector/6/select/version/2.0/mediaset/pc/vpid/bbc_one_london/format/json'
REDIRECT_HOSTS = (
frozenset(('netflix.com','www.netflix.com')),
frozenset(('disneyplus.com','www.disneyplus.com')),
frozenset(('www.youtube.com','consent.youtube.com')),
frozenset(('spotify.com','www.spotify.com','open.spotify.com')),
frozenset(('max.com','www.max.com','hbomax.com','www.hbomax.com')),
frozenset(('www.bbc.co.uk','www.bbc.com')),
frozenset(('hulu.com','www.hulu.com')),
frozenset(('paramountplus.com','www.paramountplus.com')),
)
@dataclass
class Page:
status: int | None
body: str = ''
reason: str = ''
url: str = ''
class VisibleText(HTMLParser):
def __init__(self):
super().__init__()
self.blocked = []
self.parts = []
def handle_starttag(self, tag, attrs):
values=dict(attrs)
style=re.sub(r'\s+','',values.get('style','').lower())
hidden=('hidden' in values or values.get('aria-hidden','').lower()=='true'
or 'display:none' in style or 'visibility:hidden' in style)
if (self.blocked or tag in ('script','style','template') or hidden) and tag not in ('br','img','input','meta','link','hr','source','wbr','area','base','embed','param','track','col'):
self.blocked.append(tag)
def handle_endtag(self, tag):
if tag in self.blocked:
index=len(self.blocked)-1-self.blocked[::-1].index(tag)
del self.blocked[index:]
def handle_data(self, text):
if not self.blocked: self.parts.append(text)
def fetch_page(url):
expected = urllib.parse.urlparse(url).hostname
allowed = next((hosts for hosts in REDIRECT_HOSTS if expected in hosts),frozenset((expected,)))
deadline = time.monotonic() + 15
class SameSiteRedirect(urllib.request.HTTPRedirectHandler):
max_redirections = 4
def redirect_request(self, req, fp, code, msg, headers, newurl):
parsed=urllib.parse.urlparse(newurl)
if (parsed.scheme!='https' or parsed.hostname not in allowed
or parsed.username or parsed.password or parsed.port not in (None,443)
or time.monotonic() >= deadline):
return None
return super().redirect_request(req,fp,code,msg,headers,newurl)
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),SameSiteRedirect())
req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0 ProxyDoctor/0.4','Accept-Language':'en-US,en;q=0.9','Accept-Encoding':'identity'})
try:
response=opener.open(req,timeout=8)
except urllib.error.HTTPError as exc:
response=exc
except (ValueError,OSError,urllib.error.URLError):
return Page(None,reason='network_error')
try:
with response:
body=read_limited(response,LIMIT,deadline)
return Page(response.code,body.decode('utf-8',errors='replace'),url=response.geturl())
except ValueError:
return Page(response.code,reason='response_too_large')
except OSError:
return Page(None,reason='read_failed')
def classify(service,page):
# An HTTP 200, sign-up offer, HTTP 403 or WAF challenge alone is not proof
# of successful playback or geographical blocking.
if page.reason:
return {'state':'unknown','http':page.status,'evidence':page.reason}
parser=VisibleText()
parser.feed(page.body)
text=re.sub(r'\s+',' ',' '.join(parser.parts)).lower()
# A WAF/login/challenge response cannot establish region or entitlement.
if page.status in (401,403,407,429,451) or page.status is None or page.status >= 500:
evidence = {401:'login_required',403:'access_denied',407:'access_denied',
429:'rate_limited',451:'legal_restriction'}.get(page.status,'service_error')
return {'state':'unknown','http':page.status,'evidence':evidence}
if any(marker in text for marker in ('verify you are human','checking your browser',
'enable javascript and cookies to continue','access denied')):
return {'state':'unknown','http':page.status,'evidence':'challenge_page'}
if 'before you continue to youtube' in text:
return {'state':'unknown','http':page.status,'evidence':'consent_required'}
if page.status == 200 and any(phrase in text for phrase in SERVICES[service][1]):
return {'state':'restricted','http':page.status,'evidence':'explicit_region_message'}
# Only a named subscription offer AND an actual price is positive evidence.
# This proves an offer is displayed, never that DRM/account playback works.
offers = {'YouTube Premium': ('get youtube premium','try youtube premium'),
'Spotify': ('get premium individual','premium individual'),
'Disney+': ('disney+ premium','disney+ standard'),
'Hulu': ('hulu (with ads)','hulu (no ads)'),
'Paramount+': ('paramount+ essential','paramount+ premium')}
if page.status == 200 and service in offers:
phrases = offers[service]
priced_offer = any(re.search(re.escape(phrase) + r'.{0,180}(?:[$€£¥₹]\s*\d|\d[\d.,]*\s*(?:usd|eur|gbp|jpy|inr))',text)
or re.search(r'(?:[$€£¥₹]\s*\d|\d[\d.,]*\s*(?:usd|eur|gbp|jpy|inr)).{0,180}' + re.escape(phrase),text)
for phrase in phrases)
if priced_offer:
return {'state':'offer_available','http':page.status,'evidence':'visible_priced_subscription'}
return {'state':'unknown','http':page.status,'evidence':'playback_not_verified'}
class StructuredTitles(HTMLParser):
def __init__(self):
super().__init__()
self.active = False
self.blocks = []
self.buffer = []
def handle_starttag(self, tag, attrs):
if tag == 'script' and dict(attrs).get('type','').lower() == 'application/ld+json':
self.active = True
self.buffer = []
def handle_data(self, data):
if self.active:
self.buffer.append(data)
def handle_endtag(self, tag):
if tag == 'script' and self.active:
self.blocks.append(''.join(self.buffer))
self.active = False
def title_evidence(page, title_id):
if page.reason or page.status != 200:
return False
# Require matching structured title metadata, not arbitrary occurrences of ID.
parser = StructuredTitles()
parser.feed(page.body)
def matches(item):
if isinstance(item, list):
return any(matches(part) for part in item)
if not isinstance(item, dict):
return False
types = item.get('@type', [])
if isinstance(types, str):
types = [types]
if any(kind in types for kind in ('TVSeries','Movie','VideoObject')) and item.get('name'):
parsed = urllib.parse.urlparse(str(item.get('url','')))
if parsed.scheme == 'https' and parsed.hostname == 'www.netflix.com' and re.search(r'/title/' + title_id + r'/?$',parsed.path):
return True
return matches(item.get('@graph', []))
for block in parser.blocks:
try:
if matches(json.loads(block)):
return True
except (ValueError, RecursionError):
continue
return False
def classify_bbc_metadata(page):
result={'state':'unknown','http':page.status,'evidence':page.reason or 'unrecognized_metadata'}
if page.reason or page.status not in (200,403): return result
try: data=json.loads(page.body)
except (ValueError,RecursionError): return result
if not isinstance(data,dict): return result
if data.get('result')=='geolocation':
return dict(result,state='restricted',evidence='geo_api')
# Only acknowledge metadata, without following or retaining signed media URLs.
media=data.get('media')
if page.status==200 and isinstance(media,list):
for item in media:
if not isinstance(item,dict) or item.get('kind') not in ('video','audio'): continue
connections=item.get('connection')
if not isinstance(connections,list): continue
for connection in connections:
if not isinstance(connection,dict) or not isinstance(connection.get('href'),str): continue
try: parsed=urllib.parse.urlparse(connection['href'])
except ValueError: continue
if parsed.scheme=='https' and parsed.hostname:
return dict(result,state='metadata_available',evidence='channel_metadata')
return result
def check(service):
if service=='BBC iPlayer':
first=classify_bbc_metadata(fetch_page(BBC_SELECTOR))
if first['state']!='unknown':
second=classify_bbc_metadata(fetch_page(BBC_SELECTOR))
return dict(first,service=service,scope='bbc_one_metadata',playback='not_tested',
state=first['state'] if first['state']==second['state'] else 'unknown',
evidence='confirmed_'+first['evidence'] if first['state']==second['state'] else 'inconsistent_results',
checked_at=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),
probes=[{'url':BBC_SELECTOR,**item} for item in (first,second)])
url = SERVICES[service][0]
page = fetch_page(url)
first = classify(service,page)
result = dict(first, service=service, scope='public_region_and_offer',
playback='not_tested', checked_at=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),
probes=[{'url':url,'final_url':page.url, **first}])
if first['state'] in ('restricted','offer_available'):
page = fetch_page(url)
second = classify(service,page)
result['probes'].append({'url':url,'final_url':page.url, **second})
if second['state'] != first['state']:
result.update(state='unknown',evidence='inconsistent_results')
else:
result['evidence'] = 'confirmed_' + first['evidence']
elif service == 'Netflix' and first.get('http') == 200 and first['evidence'] == 'playback_not_verified':
observed = 0
for title_id in ('80057281','70143836'):
title_url = url + 'title/' + title_id
page = fetch_page(title_url)
found = title_evidence(page,title_id)
observed += int(found)
result['probes'].append({'url':title_url,'final_url':page.url,'http':page.status,
'evidence':'structured_catalog_title' if found else page.reason or 'no_title_evidence'})
if observed:
result.update(state='catalog_visible',evidence='structured_catalog_titles',catalog_titles_observed=observed)
return result
def display(result, lang='zh'):
"""Compact human-readable result while raw evidence remains exportable."""
zh = lang == 'zh'
state = result.get('state')
if result.get('scope')=='bbc_one_metadata' and state=='restricted':
return ('测试频道区域限制(两次一致)' if zh else 'Test channel region restricted (confirmed)'), 'bad'
labels = {'restricted':('区域限制(两次一致)','Region restricted (confirmed)'),
'offer_available':('地区套餐可见 · 播放待验证','Regional offer visible · Playback unverified'),
'catalog_visible':('片库条目可见 · 播放待验证','Catalog visible · Playback unverified'),
'metadata_available':('测试频道元数据可取 · 播放待验证','Test channel metadata available · Playback unverified')}
if state in labels:
return labels[state][0 if zh else 1], 'bad' if state == 'restricted' else 'unknown'
evidence = result.get('evidence')
reasons = {'network_error':('连接失败','Request failed'), 'read_failed':('读取失败','Read failed'),
'response_too_large':('响应超限','Response too large'),
'access_denied':('访问被拒 · 原因未知','Access denied · Cause unknown'),
'rate_limited':('触发限流','Rate limited'), 'challenge_page':('遇到人机验证','Challenge page'),
'consent_required':('需要隐私授权','Consent required'),
'inconsistent_results':('两次结果不一致','Inconsistent results'),
'login_required':('需要登录','Login required'),
'legal_restriction':('法律限制 · 区域原因未确定','Legal restriction · Region unknown')}
label = reasons.get(evidence, ('解锁未知','Unlock UNKNOWN'))[0 if zh else 1]
http = result.get('http')
if http == 403 and evidence == 'playback_not_verified':
label = '访问被拒 · 原因未知' if zh else 'Access denied · Cause unknown'
return label + f' · HTTP {http if http is not None else "---"}', 'unknown'
def run_console(lang='zh', color='auto'):
from concurrent.futures import ThreadPoolExecutor, as_completed
from .console import Console
out = Console(lang,color)
results = {}
with ThreadPoolExecutor(max_workers=4) as pool:
futures = {pool.submit(check,service):service for service in SERVICES}
for future in as_completed(futures):
service = futures[future]
try:
result = future.result()
except Exception:
# 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)
out.row(service,value,status)
return results
+55
View File
@@ -0,0 +1,55 @@
"""Bounded HTTPS JSON requests. Never return provider errors or credentials."""
import json
import time
import urllib.error
import urllib.request
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args):
return None
def opener():
return urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
def read_limited(response, limit, deadline):
chunks = []
total = 0
while total <= limit:
if time.monotonic() >= deadline:
raise TimeoutError()
# read1 returns after one underlying read instead of filling a huge buffer.
read = getattr(response, 'read1', response.read)
block = read(min(65536, limit + 1 - total))
if not block:
return b''.join(chunks)
total += len(block)
chunks.append(block)
raise ValueError('response_too_large')
def request_json(url, *, payload=None, headers=None):
request_headers = {'Accept': 'application/json', 'Accept-Encoding': 'identity',
'User-Agent': 'ProxyDoctor/0.4'}
request_headers.update(headers or {})
data = None
if payload is not None:
data = json.dumps(payload).encode()
request_headers['Content-Type'] = 'application/json'
request = urllib.request.Request(url, data=data, headers=request_headers)
deadline = time.monotonic() + 15
try:
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))
if not isinstance(result, dict):
return None, 'invalid_response'
return result, 'ok'
except urllib.error.HTTPError as exc:
exc.close()
return None, {401: 'invalid_key', 403: 'denied', 429: 'rate_limited'}.get(exc.code, 'http_' + str(exc.code))
except (ValueError, OSError, urllib.error.URLError):
return None, 'request_failed'
+89
View File
@@ -0,0 +1,89 @@
"""Opt-in, bounded outbound checks; do not infer reputation or universal reachability."""
import ipaddress
import json
import socket
import subprocess
import sys
import time
TARGETS = (("Cloudflare", "1.1.1.1", 443), ("Google", "8.8.8.8", 443))
DNS_NAME = "example.com"
TIMEOUT = 5
def dns_check():
# A subprocess permits a hard timeout even if the system resolver blocks.
program = ("import json,socket; "
"a=socket.getaddrinfo('example.com',443,type=socket.SOCK_STREAM); "
"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,
text=True, timeout=TIMEOUT, check=False)
if proc.returncode != 0:
return {'status':'unknown', 'reason':'lookup_failed'}
families = json.loads(proc.stdout)
if not isinstance(families, list) or not families:
return {'status':'unknown', 'reason':'no_answers'}
return {'status':'pass', 'elapsed_ms':round((time.monotonic()-start)*1000),
'ipv4':socket.AF_INET in families, 'ipv6':socket.AF_INET6 in families}
except subprocess.TimeoutExpired:
return {'status':'unknown', 'reason':'timeout'}
except (OSError, ValueError, TypeError):
return {'status':'unknown', 'reason':'lookup_failed'}
def tcp_check(label, address, port):
# Validate a numeric address so this check never triggers DNS.
ipaddress.ip_address(address)
start = time.monotonic()
try:
with socket.create_connection((address, port), timeout=TIMEOUT):
elapsed = round((time.monotonic()-start)*1000)
return {'target':label, 'port':port, 'status':'pass', 'elapsed_ms':elapsed}
except (OSError, TimeoutError):
return {'target':label, 'port':port, 'status':'unknown'}
def run_quality(t, emit=print, compact=False, lang='zh', color='auto', report=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 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")
else: console.row('DNS',console.text('未成功','Unsuccessful'),'bad')
complete=dns['status']=='pass'
for label,address,port in TARGETS:
result=tcp_check(label,address,port)
if report is not None: report['tcp'].append(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
return 0 if complete else 1
def show(message):
emit(message, flush=True)
show(t('网络质量检测:会向系统 DNS、Cloudflare 和 Google 发起请求。'))
show(t('正在查询 DNS:example.com ...'))
dns = dns_check()
if report is not None: report.update(dns=dns,tcp=[])
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('其他')
show(t('DNS 查询成功:{versions},耗时 {ms} ms', versions=versions, ms=dns['elapsed_ms']))
else:
show(t('DNS 查询未成功;原因可能是超时、网络或解析器配置,不能确定唯一原因。'))
for label, address, port in TARGETS:
show(t('正在测试 {target} TCP {port} ...', target=label, port=port))
result = tcp_check(label, address, port)
if report is not None: report['tcp'].append(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 信誉。'))
return 0 if complete else 1
+66
View File
@@ -0,0 +1,66 @@
"""Small Linux UDP trace using the socket error queue; no raw socket/root needed."""
import ipaddress
import socket
import struct
import sys
import time
import select
from .geo import GeoLocator
from .console import clean
def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=None):
if not 1<=hops<=64 or not 0<wait<=10: raise ValueError('Invalid trace limits')
address=ipaddress.ip_address(target)
locator=GeoLocator(lang,enabled=geo)
zh=lang=='zh';v6=address.version==6
if sys.platform != 'linux':
emit('当前平台无法进行原生追踪' if zh else 'Native trace unavailable on this platform',flush=True)
return 1
family=socket.AF_INET6 if v6 else socket.AF_INET
protocol=socket.IPPROTO_IPV6 if v6 else socket.IPPROTO_IP
recverr=25 if v6 else 11
try:
with socket.socket(family,socket.SOCK_DGRAM) as sock:
sock.setsockopt(protocol,recverr,1) # Linux IPV6_RECVERR/IP_RECVERR
sock.setblocking(False)
for ttl in range(1,hops+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)
sock.sendto(b'ProxyDoctor',endpoint)
found=False
while time.monotonic()-start < wait:
try:
_, controls, _, destination=sock.recvmsg(512,512,socket.MSG_ERRQUEUE)
except BlockingIOError:
remaining=wait-(time.monotonic()-start)
if remaining>0: select.select([sock],[],[sock],remaining)
continue
if not destination or ipaddress.ip_address(destination[0]) != address or destination[1] != 33434+ttl:
continue # Ignore late errors from previous TTL probes.
for level,kind,data in controls:
if level != protocol or kind != recverr or len(data)<(44 if v6 else 24):
continue
_,origin,icmp_type,icmp_code,_,_,_=struct.unpack('=IBBBBII',data[:16])
if origin != (3 if v6 else 2): continue
if icmp_type not in ((1,3) if v6 else (3,11)): continue
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)
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,
'icmp_type':icmp_type,'icmp_code':icmp_code,'state':'reached' if reached else 'reply'})
found=True
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'})
except (OSError,AttributeError):
emit('原生 UDP 追踪不可用或未收到允许的响应' if zh else 'Native UDP trace unavailable or blocked',flush=True)
return 1
+131
View File
@@ -0,0 +1,131 @@
"""Four-part console workflow. Capability and measurement limits are explicit."""
import hashlib
import ipaddress
from pathlib import Path
import shutil
import subprocess
import time
from .checks import inspect
from . import __version__
from .quality import run_quality
from .i18n import translator
from .routes import trace as native_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
def execute_provider(argv, timeout=180):
"""Preserve streaming terminal output; no shell interpolation."""
try:
return subprocess.run(argv,check=False,timeout=timeout).returncode
except (OSError,subprocess.TimeoutExpired):
return 1
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):
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')
out=Console(lang,color); tr=out.text
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')
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)
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
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
else:
report['media']=run_media(lang,color)
incomplete=True # Account/DRM playback is intentionally not claimed.
print(tr('\n说明:带宽按来源独立统计 HTTPS 多轮吞吐(含建连);地区套餐/片库可见不等于账号播放。IP 标记与举报为第三方记录,零举报不保证安全。',
'\nNotes: per-source repeated HTTPS throughput, including setup. Offers/catalogs do not prove account playback. Provider flags and reports are evidence; zero reports do not guarantee safety.'),flush=True)
report['finished_at']=datetime.now(timezone.utc).isoformat()
report['result']='incomplete' if incomplete else 'local_execution_complete'
counts={state:sum(item.get('state')==state for item in report['media'].values()) for state in ('restricted','offer_available','catalog_visible','metadata_available','unknown')}
report['summary']={'media':counts,'playback':'not_tested','system_findings':sum(item['status'] in ('warn','fail') for item in data['checks'])}
print(tr(f"体检结果:{report['summary']['system_findings']} 项本机提示 · 媒体限制 {counts['restricted']} · 媒体未知 {counts['unknown']}",
f"Results: {report['summary']['system_findings']} local findings · Media restricted {counts['restricted']} · Media unknown {counts['unknown']}"),flush=True)
if json_output:
import json
with Path(json_output).open('x',encoding='utf-8') as stream:
json.dump(redact(report,lang),stream,ensure_ascii=False,indent=2)
print(tr('脱敏 JSON 已保存:','Redacted JSON saved: ')+str(json_output),flush=True)
return 1 if incomplete else 0
+23
View File
@@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "proxydoctor"
version = "0.5.0"
description = "Read-only Ubuntu preflight and security checks"
readme = "README.en.md"
license = "MIT"
license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
requires-python = ">=3.10"
dependencies = []
[project.scripts]
proxydoctor = "proxydoctor.cli:main"
[tool.setuptools.packages.find]
include = ["proxydoctor*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--basetemp=.pytest-tmp"
+117
View File
@@ -0,0 +1,117 @@
import io
import pytest
from proxydoctor import bandwidth as bw
class Response(io.BytesIO):
def __init__(self,body,status=206,headers=None):
super().__init__(body)
self.status=status
self.headers=headers or {}
def fake_response(monkeypatch,response):
requests=[]
class Opener:
def open(self,request,timeout):
requests.append(request)
return response
monkeypatch.setattr(bw,'opener',lambda:Opener())
return requests
@pytest.mark.parametrize('source',['hetzner-fsn','ovh-rbx'])
def test_official_download_sources_use_exact_validated_range(monkeypatch,source):
clock=iter(range(10))
monkeypatch.setattr(bw.time,'monotonic',lambda:next(clock))
requests=fake_response(monkeypatch,Response(b'x'*100,headers={'Content-Range':'bytes 0-99/104857600','Content-Length':'100'}))
sample=bw.transfer('download',100,source)
assert sample.mbps is not None and sample.error is None
assert sample.source==source
assert requests[0].get_header('Range')=='bytes=0-99'
assert requests[0].full_url==bw.SOURCES[source].download_url
def test_ignored_range_never_reads_full_large_file(monkeypatch):
class FullFile(Response):
def read1(self,*args): raise AssertionError('Must not read a full-file response')
def read(self,*args): raise AssertionError('Must not read a full-file response')
fake_response(monkeypatch,FullFile(b'',status=200))
sample=bw.transfer('download',100,'hetzner-fsn')
assert sample.error=='range_not_supported' and sample.mbps is None
@pytest.mark.parametrize('range_header,length',[
('bytes 1-100/104857600','100'),('bytes 0-100/104857600','100'),
('bytes 0-99/99','100'),('bytes 0-99/*','100'),('', '100'),
('bytes 0-99/104857600','101'),
])
def test_wrong_content_range_never_becomes_speed(monkeypatch,range_header,length):
fake_response(monkeypatch,Response(b'x'*100,headers={'Content-Range':range_header,'Content-Length':length}))
sample=bw.transfer('download',100,'ovh-rbx')
assert sample.mbps is None and sample.error=='invalid_content_range'
def test_partial_payload_and_html_cannot_be_valid_samples(monkeypatch):
fake_response(monkeypatch,Response(b'x'*99,headers={'Content-Range':'bytes 0-99/104857600'}))
assert bw.transfer('download',100,'ovh-rbx').error=='short_response'
fake_response(monkeypatch,Response(b'x'*100,headers={'Content-Range':'bytes 0-99/104857600','Content-Type':'text/html'}))
assert bw.transfer('download',100,'hetzner-fsn').error=='unexpected_content'
@pytest.mark.parametrize('source',['hetzner-fsn','ovh-rbx'])
def test_download_only_never_attempts_upload(monkeypatch,source):
monkeypatch.setattr(bw,'opener',lambda:pytest.fail('Unexpected upload request'))
assert bw.transfer('upload',100,source).error=='unsupported'
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]
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['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']
assert 'median_mbps' not in report and bw.is_complete(report)
assert events==[(s,event) for s in bw.SOURCES for event in ('start','complete')]
def test_failed_source_is_not_silently_replaced_and_next_source_runs(monkeypatch):
calls=[]
def transfer(direction,size,source='cloudflare'):
calls.append(source)
return bw.Sample(direction,size,2,20 if source=='ovh-rbx' else None,
error=None if source=='ovh-rbx' else 'range_not_supported',source=source)
monkeypatch.setattr(bw,'transfer',transfer)
report=bw.measure_many(sources=['hetzner-fsn','ovh-rbx'])
assert calls.count('hetzner-fsn')==1
assert report['sources']['hetzner-fsn']['directions']['download']['successful']==0
assert report['sources']['ovh-rbx']['directions']['download']['successful']==3
assert not bw.is_complete(report)
def test_source_selection_deduplicates_and_rejects_arbitrary_urls():
assert bw.source_ids()==('cloudflare','ovh-rbx')
assert bw.payload_budget(3,4)==int(36.375*1024**2)
assert bw.source_ids(['ovh-rbx','ovh-rbx'])==('ovh-rbx',)
assert bw.payload_budget(3,4,['ovh-rbx'])==int(12.125*1024**2)
for selected in ([],['https://example.com/'],['not-a-source']):
with pytest.raises(ValueError): bw.source_ids(selected)
def test_console_reports_download_only_without_calling_it_failed(monkeypatch,capsys):
monkeypatch.setattr(bw,'transfer',lambda direction,size,source='cloudflare':bw.Sample(direction,size,2,12,source=source))
bw.run_console('en','always',sources=['ovh-rbx'])
output=capsys.readouterr().out
assert 'OVHcloud' in output and 'FR / Roubaix' in output
assert 'Not supported (download-only source)' in output
assert 'Download summary' in output and 'Upload summary' not in output
assert '\x1b[32m' in output
+152
View File
@@ -0,0 +1,152 @@
import json
import subprocess
import sys
import pytest
from proxydoctor.checks import Command, Host, inspect, port_rows
from proxydoctor.cli import redact
class Ubuntu:
def __init__(self, commands=None):
self.commands = commands or {}
self.calls = []
def system(self):
return "Linux", "x86_64"
def disk_free(self):
return 5 * 1024**3
def read(self, path):
return {"/etc/os-release": 'ID=ubuntu\nVERSION_ID="24.04"\nPRETTY_NAME="Ubuntu 24.04 LTS"',
"/proc/meminfo": "MemAvailable: 1048576 kB\n"}.get(path)
def run(self, *args):
self.calls.append(args)
return self.commands.get(args, Command(None, reason="命令不可用"))
def by_id(data, key):
return next(item for item in data["checks"] if item["id"] == key)
def test_unavailable_tools_never_become_success():
data = inspect(Ubuntu())
for key in ("ports", "ssh", "firewall", "service", "public_ip", "cloud_firewall"):
assert by_id(data, key)["status"] == "unknown"
assert by_id(data, "system")["status"] == "pass"
@pytest.mark.parametrize("distribution,version,status", [
("ubuntu", "20.04", "warn"), ("ubuntu", "21.10", "warn"),
("ubuntu", "22.04", "pass"), ("ubuntu", "22.10", "pass"),
("ubuntu", "24.04", "pass"), ("ubuntu", "26.04", "pass"),
("ubuntu", "26.10", "pass"), ("ubuntu", "", "unknown"),
("ubuntu", "noble", "unknown"), ("ubuntu", "22.4", "unknown"),
("ubuntu", "22.99", "unknown"), ("ubuntu", "24.04-dev", "unknown"),
("debian", "24.04", "warn"), ("linuxmint", "22.04", "warn"),
])
@pytest.mark.parametrize("lang", ["zh", "en"])
def test_minimum_ubuntu_version_and_distribution(distribution, version, status, lang):
host = Ubuntu()
original_read = host.read
host.read = lambda path: (f'ID={distribution}\nVERSION_ID="{version}"\nID_LIKE=ubuntu'
if path == "/etc/os-release" else original_read(path))
result = by_id(inspect(host, lang=lang), "system")
assert result["status"] == status
if status == "pass":
assert "22.04" in result["detail"]
assert "实机" in result["detail"] if lang == "zh" else "real system" in result["detail"]
@pytest.mark.parametrize("endpoint", ["0.0.0.0:8388", "[::]:8388", "127.0.0.1:8388", "*:8388"])
def test_any_existing_listener_is_a_conflict(endpoint):
host = Ubuntu({("ss", "-H", "-lntu"): Command(0, f"tcp LISTEN 0 4096 {endpoint} *:*\n")})
assert by_id(inspect(host), "ports")["status"] == "warn"
def test_failed_partial_ss_output_cannot_claim_free_port():
host = Ubuntu({("ss", "-H", "-lntu"): Command(1, "", "权限不足")})
assert by_id(inspect(host), "ports")["status"] == "unknown"
host.commands[("ss", "-H", "-lntu")] = Command(0, "unexpected output")
assert by_id(inspect(host), "ports")["status"] == "unknown"
def test_empty_successful_ss_only_confirms_no_listener():
host = Ubuntu({("ss", "-H", "-lntu"): Command(0, "")})
data = inspect(host)
assert by_id(data, "ports")["status"] == "pass"
assert by_id(data, "cloud_firewall")["status"] == "unknown"
def test_ssh_only_retains_selected_settings_and_flags_passwords():
host = Ubuntu({("sshd", "-T"): Command(0, "port 2222\npasswordauthentication yes\npermitrootlogin prohibit-password\nhostkey /private/secret-path\n")})
ssh = by_id(inspect(host), "ssh")
assert ssh["status"] == "warn"
assert "2222" in ssh["evidence"]
assert "secret-path" not in json.dumps(ssh)
def test_root_can_be_allowed_by_match_so_default_config_is_not_security_pass():
host = Ubuntu({("sshd", "-T"): Command(0, "passwordauthentication no\npermitrootlogin no\n")})
assert by_id(inspect(host), "ssh")["status"] == "unknown"
def test_active_service_does_not_pass_network_tests():
host = Ubuntu({("systemctl", "is-active", "sing-box.service"): Command(0, "active\n")})
data = inspect(host, public_ip="8.8.8.8")
assert by_id(data, "service")["status"] == "pass"
assert by_id(data, "public_ip")["status"] == "unknown"
def test_redaction_hides_ipv4_and_ipv6_and_preserves_versions():
data = {"version": "0.1.0", "items": ["eth0: 203.0.113.5", "eth0: 2001:db8::123", "[::]:8388"]}
result = redact(data)
assert "203.0.113.5" not in str(result)
assert "2001:db8::123" not in str(result)
assert result["version"] == "0.1.0"
assert result["items"][2] == "[[IP已隐藏]]:8388"
def test_execution_allowlist_blocks_mutations():
with pytest.raises(ValueError):
Host().run("ufw", "enable")
def test_timeout_and_permission_denied_are_unknown(monkeypatch):
monkeypatch.setattr("proxydoctor.checks.shutil.which", lambda _, **kwargs: "/usr/bin/ss")
def timeout(*args, **kwargs):
assert kwargs["timeout"] == 8
assert "shell" not in kwargs
raise subprocess.TimeoutExpired("ss", 8)
monkeypatch.setattr("proxydoctor.checks.subprocess.run", timeout)
assert Host().run("ss", "-H", "-lntu").code is None
def test_windows_does_not_execute_linux_checks():
host = Ubuntu()
host.system = lambda: ("Windows", "AMD64")
data = inspect(host)
assert not host.calls
assert by_id(data, "ports")["status"] == "skip"
def test_cli_rejects_bad_input_and_does_not_overwrite_report(tmp_path):
base = [sys.executable, "-m", "proxydoctor", "check"]
assert subprocess.run(base + ["--port", "0"], capture_output=True).returncode == 2
assert subprocess.run(base + ["--public-ip", "not-an-ip"], capture_output=True).returncode == 2
target = tmp_path / "report.json"
target.write_text("keep me", encoding="utf-8")
assert subprocess.run(base + ["--output", str(target)], capture_output=True).returncode == 2
assert target.read_text(encoding="utf-8") == "keep me"
def test_fixture_export_is_machine_readable():
host = Ubuntu({("ip", "-j", "address", "show"): Command(0, json.dumps([
{"ifname": "eth0", "addr_info": [{"scope": "global", "local": "2001:db8::1"}]}]))})
data = json.loads(json.dumps(redact(inspect(host))))
assert data["mode"] == "read-only"
assert "2001:db8::1" not in str(data)
assert by_id(data, "interfaces")["status"] == "pass"
+33
View File
@@ -0,0 +1,33 @@
import io
import json
from proxydoctor.console import Console
from proxydoctor.geo import GeoLocator
def test_colour_can_be_forced_or_disabled():
assert '\x1b[32m' in Console(color='always').paint('2 CPUs')
assert '\x1b[31m' in Console(color='always').paint('Restricted','bad')
assert '\x1b' not in Console(color='never').paint('2 CPUs')
assert '\x1b' not in Console(color='never').paint('\x1b[31mmalicious')
def test_private_hops_never_query_geolocation(monkeypatch):
monkeypatch.setattr('proxydoctor.geo.urllib.request.build_opener',lambda *args: (_ for _ in ()).throw(AssertionError()))
assert '内网' in GeoLocator().lookup('192.168.1.1')
assert '内网' in GeoLocator().lookup('100.64.0.1')
def test_public_hop_location_cached_and_deduplicated(monkeypatch):
calls=[]
class Response:
def __enter__(self): return self
def __exit__(self,*args): pass
def read(self,size): return json.dumps({'success':True,'country':'Test country','region':'Test city','city':'Test city'}).encode()
class Opener:
def open(self,url,timeout): calls.append(url);return Response()
monkeypatch.setattr('proxydoctor.geo.urllib.request.build_opener',lambda *args:Opener())
geo=GeoLocator()
assert geo.lookup('20.150.176.1')=='Test country / Test city'
assert geo.lookup('20.150.176.1')=='Test country / Test city'
assert len(calls)==1
+58
View File
@@ -0,0 +1,58 @@
import json
import re
import subprocess
import sys
import pytest
from proxydoctor.checks import Command, inspect
from proxydoctor.cli import redact
from proxydoctor.i18n import MESSAGES, translator
from test_checks import Ubuntu
def test_english_ubuntu_report_has_no_chinese_and_same_results():
host = Ubuntu()
zh = inspect(host, lang="zh")
en = inspect(host, lang="en")
assert not re.search(r"[\u4e00-\u9fff]", json.dumps(en, ensure_ascii=False))
assert en["language"] == "en"
assert [(c["id"], c["status"]) for c in zh["checks"]] == [(c["id"], c["status"]) for c in en["checks"]]
@pytest.mark.parametrize("lang", ["zh", "en"])
def test_reports_and_help_in_selected_language(lang):
base = [sys.executable, "-m", "proxydoctor"]
result = subprocess.run(base + ["check", "--lang", lang, "--json", "--public-ip", "2001:db8::123"],
capture_output=True, encoding="utf-8")
assert result.returncode in (0, 1)
data = json.loads(result.stdout)
assert data["language"] == lang
assert "2001:db8::123" not in result.stdout
assert data["checks"][0]["title"] == ("操作系统" if lang == "zh" else "Operating system")
for arguments in (["--lang", lang, "check", "--help"], ["check", "--lang=" + lang, "--help"]):
help_result = subprocess.run(base + arguments, capture_output=True, encoding="utf-8")
assert help_result.returncode == 0
assert ("代理端口" if lang == "zh" else "Intended proxy port") in help_result.stdout
error = subprocess.run(base + ["check", "--lang", lang, "--port", "0"], capture_output=True, encoding="utf-8")
assert error.returncode == 2
assert ("端口必须" if lang == "zh" else "Port must") in error.stderr
def test_success_conflict_and_firewall_branches_translate():
host = Ubuntu({("ss", "-H", "-lntu"): Command(0, "tcp LISTEN 0 5 [::]:8388 *:*\n"),
("ufw", "status"): Command(0, "Status: active"),
("nft", "list", "ruleset"): Command(0, ""),
("iptables", "-S"): Command(0, "-P INPUT ACCEPT"),
("sshd", "-T"): Command(0, "permitrootlogin yes\npasswordauthentication yes\n"),
("systemctl", "is-active", "sing-box.service"): Command(0, "active")})
en = redact(inspect(host, public_ip="8.8.8.8", lang="en"), "en")
assert not re.search(r"[\u4e00-\u9fff]", json.dumps(en, ensure_ascii=False))
assert "[IP REDACTED]" in json.dumps(en)
def test_catalog_preserves_placeholders_and_rejects_unknown_language():
for zh, en in MESSAGES.items():
assert re.findall(r"\{[^}]+\}", zh) == re.findall(r"\{[^}]+\}", en)
with pytest.raises(ValueError):
translator("fr")
+198
View File
@@ -0,0 +1,198 @@
import io
import json
import urllib.error
import pytest
from proxydoctor import bandwidth as bw, ipquality as iq, media, net
def test_bandwidth_median_excludes_failures_and_marks_insufficient():
samples=[bw.Sample('download',1024,2,10),bw.Sample('download',1024,3,30),
bw.Sample('download',1024,error='request_failed')]
result=bw.summarize(samples)
assert result['median_mbps']==20 and result['successful']==2
assert result['quality']=='insufficient'
assert result['min_mbps']==10 and result['max_mbps']==30
def test_fast_and_variable_samples_are_not_claimed_consistent():
assert bw.summarize([bw.Sample('download',1024,.1,10)]*3)['quality']=='short_samples'
assert bw.summarize([bw.Sample('download',1024,2,n) for n in (10,20,100)])['quality']=='variable'
assert bw.summarize([bw.Sample('download',1024,2,n) for n in (10,11,12)])['quality']=='consistent'
def test_warmups_are_excluded_and_each_round_streamed(monkeypatch):
calls=[];emitted=[]
def transfer(direction,size):
calls.append((direction,size))
return bw.Sample(direction,size,2,999 if size==131072 else 12)
monkeypatch.setattr(bw,'transfer',transfer)
result=bw.measure(rounds=3,sample_mib=4,on_sample=lambda *args:emitted.append(args))
assert len(calls)==8 and len(emitted)==6
assert result['directions']['download']['median_mbps']==12
assert result['payload_budget_bytes']==int(24.25*1024**2)
def test_provider_refusal_stops_further_transfers(monkeypatch):
calls=[]
def transfer(direction,size):
calls.append(direction)
return bw.Sample(direction,size,error='http_429')
monkeypatch.setattr(bw,'transfer',transfer)
result=bw.measure()
assert calls==['download','upload']
assert result['directions']['download']['successful']==0
@pytest.mark.parametrize('rounds,size',[(2,4),(9,4),(3,0),(3,21)])
def test_traffic_limits_validated(rounds,size):
with pytest.raises(ValueError): bw.measure(rounds,size)
def test_anonymous_missing_flags_never_become_false(monkeypatch):
monkeypatch.delenv('PROXYDOCTOR_IPAPI_KEY',raising=False)
monkeypatch.delenv('PROXYDOCTOR_ABUSEIPDB_KEY',raising=False)
monkeypatch.setattr(iq,'request_json',lambda *a,**k:({'ip':'8.8.8.8','company':'Example','country':'US'},'ok'))
result=iq.collect()
assert result['values']['is_proxy'] is None
assert result['sources']['AbuseIPDB']['state']=='key_required'
assert result['coverage']['complete'] is False
def test_keys_are_never_in_result_or_urls_and_abuse_ip_must_match(monkeypatch):
monkeypatch.setenv('PROXYDOCTOR_IPAPI_KEY','secret-one')
monkeypatch.setenv('PROXYDOCTOR_ABUSEIPDB_KEY','secret-two')
calls=[]
def request(url,**kwargs):
calls.append((url,kwargs))
return ({'ip':'8.8.8.8','is_vpn':'false'},'ok') if 'ipapi.is' in url else ({'data':{'ipAddress':'1.1.1.1','abuseConfidenceScore':0}},'ok')
monkeypatch.setattr(iq,'request_json',request)
result=iq.collect()
assert calls[0][1]['payload']=={'key':'secret-one'}
assert calls[1][1]['headers']=={'Key':'secret-two'}
assert all('secret' not in url for url,_ in calls)
assert 'secret' not in json.dumps(result)
assert result['values']['is_vpn'] is None
assert result['reputation']['score'] is None
assert result['sources']['AbuseIPDB']['state']=='invalid_response'
def test_full_ip_and_abuse_response_with_zero_reports_is_preserved(monkeypatch):
monkeypatch.setenv('PROXYDOCTOR_IPAPI_KEY','key')
monkeypatch.setenv('PROXYDOCTOR_ABUSEIPDB_KEY','key')
fixture={'ip':'8.8.8.8','company':{'name':'Example','type':'business','abuser_score':'0 (Very Low)'},
'asn':{'asn':15169,'org':'Example'},'location':{'country':'US','region':'CA','city':'Test','timezone':'Etc/UTC'},
**dict.fromkeys(iq.FLAGS,False)}
def request(url,**kwargs):
return (fixture,'ok') if 'ipapi.is' in url else ({'data':{'ipAddress':'8.8.8.8','abuseConfidenceScore':0,
'totalReports':0,'numDistinctUsers':0,'lastReportedAt':None}},'ok')
monkeypatch.setattr(iq,'request_json',request)
result=iq.collect()
assert result['coverage']['complete'] is True
assert result['reputation']['score']==0
assert result['reputation']['last_reported_at'] is None
def test_private_egress_does_not_trigger_abuse_lookup(monkeypatch):
monkeypatch.setenv('PROXYDOCTOR_ABUSEIPDB_KEY','key')
calls=[]
monkeypatch.setattr(iq,'request_json',lambda url,**kw:calls.append(url) or ({'ip':'127.0.0.1'},'ok'))
result=iq.collect()
assert result['sources']['ipapi.is']['state']=='invalid_response'
assert len(calls)==1
def test_region_needs_repeat_confirmation(monkeypatch):
responses=iter([media.Page(200,'Netflix is not available in your country'),media.Page(200,'Welcome')])
monkeypatch.setattr(media,'fetch_page',lambda url:next(responses))
result=media.check('Netflix')
assert result['state']=='unknown' and result['evidence']=='inconsistent_results'
assert result['playback']=='not_tested' and len(result['probes'])==2
def test_priced_offer_needs_visible_specific_evidence_and_is_not_playback(monkeypatch):
page=media.Page(200,'<h1>Get YouTube Premium</h1><p>$13.99 / month</p>')
monkeypatch.setattr(media,'fetch_page',lambda url:page)
result=media.check('YouTube Premium')
assert result['state']=='offer_available' and result['playback']=='not_tested'
assert result['evidence']=='confirmed_visible_priced_subscription'
assert media.classify('YouTube Premium',media.Page(200,'YouTube Premium'))['state']=='unknown'
assert media.classify('YouTube Premium',media.Page(200,'<script>Get YouTube Premium $13.99</script>'))['state']=='unknown'
def test_waf_never_becomes_region_or_offer():
assert media.classify('Netflix',media.Page(403,'Netflix is not available in your country'))['state']=='unknown'
assert media.classify('YouTube Premium',media.Page(200,'Verify you are human. Get YouTube Premium $10'))['state']=='unknown'
def test_netflix_structured_title_is_catalog_only(monkeypatch):
def page(url):
if '/title/' not in url: return media.Page(200,'Welcome')
body=json.dumps({'@context':'https://schema.org','@type':'TVSeries','name':'Example','url':url})
return media.Page(200,'<script type="application/ld+json">'+body+'</script>')
monkeypatch.setattr(media,'fetch_page',page)
result=media.check('Netflix')
assert result['state']=='catalog_visible' and result['catalog_titles_observed']==2
assert result['playback']=='not_tested'
assert not media.title_evidence(media.Page(200,'80057281 TVSeries'), '80057281')
assert not media.title_evidence(media.Page(200,'<script type="application/ld+json">{"@type":"TVSeries","name":"X","url":"https://evil.com/title/80057281"}</script>'),'80057281')
def test_json_transport_error_does_not_echo_provider_body_or_key(monkeypatch):
class Opener:
def open(self,*args,**kwargs):
raise urllib.error.HTTPError('https://api.example/?secret',429,'secret',{},io.BytesIO(b'secret'))
monkeypatch.setattr(net,'opener',lambda:Opener())
assert net.request_json('https://api.example',headers={'Key':'secret'})==(None,'rate_limited')
def test_bounded_read_rejects_oversize_and_deadline():
with pytest.raises(ValueError): net.read_limited(io.BytesIO(b'12345'),4,float('inf'))
with pytest.raises(TimeoutError): net.read_limited(io.BytesIO(b'1'),4,0)
def test_media_redirects_only_allow_explicit_https_canonical_hosts(monkeypatch):
import urllib.request
seen=[]
class Response(io.BytesIO):
code=200
def geturl(self): return 'https://www.max.com/'
class Opener:
def open(self,request,timeout): return Response(b'<p>Welcome</p>')
def build(*handlers):
seen.extend(handlers)
return Opener()
monkeypatch.setattr(media.urllib.request,'build_opener',build)
media.fetch_page('https://www.max.com/')
handler=next(h for h in seen if isinstance(h,urllib.request.HTTPRedirectHandler))
request=urllib.request.Request('https://www.max.com/')
for url in ('http://www.max.com/','https://www.max.com.evil.test/','https://evil.test/',
'https://www.max.com:444/','https://name:password@www.max.com/'):
assert handler.redirect_request(request,None,302,'',{},url) is None
redirected=handler.redirect_request(request,None,302,'',{},'https://www.hbomax.com/')
assert redirected.full_url=='https://www.hbomax.com/'
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)
results=media.run_console('en','always')
output=capsys.readouterr().out
assert len(results)==2 and '\x1b[31m' 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
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'
original=path.read_bytes()
with pytest.raises(FileExistsError): run_suite(offline=True,json_output=path)
assert path.read_bytes()==original
+33
View File
@@ -0,0 +1,33 @@
import pytest
from proxydoctor.media import Page, classify
@pytest.mark.parametrize('service,message',[
('Netflix','Netflix is not available in your country'),
('Disney+','Disney+ is not available in your region'),
('YouTube Premium','Premium is not available in your country'),
])
def test_explicit_region_restriction(service,message):
assert classify(service,Page(200,'<p>'+message+'</p>'))['state']=='restricted'
@pytest.mark.parametrize('status',[200,301,403,404,429,503,None])
def test_status_alone_never_means_playback_or_geoblock(status):
assert classify('Netflix',Page(status,'<h1>Hello</h1>'))['state']=='unknown'
def test_translations_in_scripts_are_not_geoblock_evidence():
page=Page(200,'<script>"Netflix is not available in your country"</script><p>Join now</p>')
assert classify('Netflix',page)['state']=='unknown'
def test_partial_response_does_not_support_classification():
page=Page(200,'Netflix is not available in your country',reason='response_too_large')
assert classify('Netflix',page)['state']=='unknown'
@pytest.mark.parametrize('attribute',['hidden','aria-hidden="true"','style="display: none"'])
def test_explicitly_hidden_region_messages_are_not_evidence(attribute):
page=Page(200,f'<div {attribute}>Netflix is not available in your country</div><p>Welcome</p>')
assert classify('Netflix',page)['state']=='unknown'
+30
View File
@@ -0,0 +1,30 @@
import subprocess
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
def test_nodequality_streams_output_and_propagates_exit(tmp_path, monkeypatch, capsys):
from proxydoctor import cli
script = tmp_path / 'script with spaces.sh'
script.write_text('# test', encoding='utf-8')
captured = []
monkeypatch.setattr(cli.shutil, 'which', lambda name: '/bin/bash')
def fake_run(args, **kwargs):
captured.append((args, kwargs))
print('LIVE OUTPUT')
return subprocess.CompletedProcess(args, 7)
monkeypatch.setattr(cli.subprocess, 'run', fake_run)
monkeypatch.setattr(sys, 'argv', ['proxydoctor', 'nodequality', '--lang', 'en', '--script', str(script)])
try:
cli.main()
except SystemExit as exc:
assert exc.code == 7
else:
raise AssertionError('expected exit')
assert captured == [(['/bin/bash', str(script.resolve())], {'check': False})]
assert 'LIVE OUTPUT' in capsys.readouterr().out
+117
View File
@@ -0,0 +1,117 @@
import json
import socket
import struct
from proxydoctor import ipquality as iq, media, routes, suite
def test_keyless_risk_sources_preserve_disagreement_and_identity(monkeypatch):
monkeypatch.delenv('PROXYDOCTOR_ABUSEIPDB_KEY',raising=False)
monkeypatch.delenv('PROXYDOCTOR_IPAPI_KEY',raising=False)
calls=[]
def request(url,**kwargs):
calls.append(url)
if 'api.ipapi.is' in url: return {'ip':'8.8.8.8','is_proxy':False,'company':'Primary'},'ok'
if 'proxycheck.io' in url:
return {'status':'ok','8.8.8.8':{'network':{'type':'Hosting','asn':'AS15169'},
'detections':{'proxy':True,'vpn':False,'hosting':True,'tor':False,'risk':33,'confidence':90},
'attack_history':{'login_attempt':2}}},'ok'
return {'ip':'1.1.1.1','country_name':'Wrong IP','is_proxy':False},'ok'
monkeypatch.setattr(iq,'request_json',request)
result=iq.collect()
assert len(calls)==3
assert result['values']['is_proxy'] is None
assert result['conflicts']['is_proxy']=={'ipapi.is':False,'proxycheck.io':True}
assert result['values']['is_datacenter'] is True
assert result['values']['company']=='Primary'
assert result['field_sources']['is_vpn']==['proxycheck.io']
assert result['provider_risk']['proxycheck.io']['risk']==33
assert result['provider_risk']['proxycheck.io']['attack_count']==2
assert result['sources']['IP2Location']['state']=='invalid_response'
assert 'Wrong IP' not in json.dumps(result)
def test_egress_fallback_then_keyless_sources_do_not_change_target(monkeypatch):
monkeypatch.delenv('PROXYDOCTOR_ABUSEIPDB_KEY',raising=False)
def request(url,**kwargs):
if 'ipapi' in url: return None,'rate_limited'
if 'ipify' in url: return {'ip':'8.8.8.8'},'ok'
if 'proxycheck' in url: return None,'rate_limited'
assert 'ip=8.8.8.8' in url
return {'ip':'8.8.8.8','country_name':'Example','asn':'15169','is_proxy':False},'ok'
monkeypatch.setattr(iq,'request_json',request)
result=iq.collect()
assert result['ip']=='8.8.8.8' and result['values']['country']=='Example'
assert result['values']['is_proxy'] is None # A limited open-proxy flag is not a general guarantee.
assert result['sources']['proxycheck.io']['state']=='rate_limited'
assert result['provider_risk']['IP2Location']['open_proxy_flag'] is False
def test_malformed_provider_risk_does_not_invent_values():
assert iq.valid_ip(134744072) is None
values=iq.normalize_proxycheck({'detections':{'proxy':'false','hosting':0}})
assert values['is_proxy'] is None and values['is_datacenter'] is None
def test_bbc_geo_json_needs_repeat_and_does_not_fetch_media(monkeypatch):
calls=[]
def page(url):
calls.append(url)
return media.Page(403,json.dumps({'result':'geolocation'}))
monkeypatch.setattr(media,'fetch_page',page)
result=media.check('BBC iPlayer')
assert result['state']=='restricted' and result['scope']=='bbc_one_metadata'
assert result['playback']=='not_tested'
assert calls==[media.BBC_SELECTOR]*2
assert media.classify_bbc_metadata(media.Page(403,'Access denied geolocation'))['state']=='unknown'
def test_bbc_metadata_does_not_export_signed_stream_url(monkeypatch):
page=media.Page(200,json.dumps({'media':[{'kind':'video','connection':[{'href':'https://media.example/live.m3u8?token=private'}]}]}))
monkeypatch.setattr(media,'fetch_page',lambda url:page)
result=media.check('BBC iPlayer')
assert result['state']=='metadata_available' and result['playback']=='not_tested'
assert 'private' not in json.dumps(result)
def test_ipv6_trace_uses_icmp6_error_queue_and_exports_hop(monkeypatch):
monkeypatch.setattr(routes.sys,'platform','linux')
monkeypatch.setattr(routes.GeoLocator,'lookup',lambda self,ip:'Estimated place')
target='2606:4700:4700::1111'
seen=[]
class Socket:
def __enter__(self):return self
def __exit__(self,*args):pass
def setsockopt(self,*args):seen.append(args)
def setblocking(self,*args):pass
def sendto(self,data,destination):self.destination=destination
def recvmsg(self,*args):
extended=struct.pack('=IBBBBII',111,3,1,4,0,0,0)
offender=struct.pack('=HHI',socket.AF_INET6,0,0)+socket.inet_pton(socket.AF_INET6,target)+b'\x00'*4
return b'',[(socket.IPPROTO_IPV6,25,extended+offender)],0,self.destination
monkeypatch.setattr(routes.socket,'socket',lambda family,kind:Socket())
records=[]
assert routes.trace(target,records=records,emit=lambda *a,**k:None)==0
assert (socket.IPPROTO_IPV6,25,1) in seen
assert records[0]['ip']==target and records[0]['state']=='reached'
def test_suite_includes_security_network_and_route_evidence(monkeypatch,tmp_path,capsys):
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)
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'
assert suite.run_suite(lang='en',port=2222,json_output=path)==1
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['route']['state']=='reached'
assert result['route']['hops'][0]['ip']!='8.8.8.8'
assert result['summary']['playback']=='not_tested'
+39
View File
@@ -0,0 +1,39 @@
import json
import socket
import subprocess
import pytest
from proxydoctor import quality
from proxydoctor.i18n import translator
def test_dns_success_and_hard_timeout(monkeypatch):
monkeypatch.setattr(quality.subprocess, 'run', lambda *a, **kw: subprocess.CompletedProcess(a, 0, json.dumps([int(socket.AF_INET)])))
assert quality.dns_check()['ipv4'] is True
def timeout(*args, **kwargs):
assert kwargs['timeout'] == quality.TIMEOUT
raise subprocess.TimeoutExpired(args[0], 5)
monkeypatch.setattr(quality.subprocess, 'run', timeout)
assert quality.dns_check() == {'status':'unknown', 'reason':'timeout'}
def test_tcp_failure_does_not_claim_cause(monkeypatch):
def fail(*args, **kwargs): raise OSError('refused')
monkeypatch.setattr(quality.socket, 'create_connection', fail)
assert quality.tcp_check('test', '127.0.0.1', 443)['status'] == 'unknown'
@pytest.mark.parametrize('lang', ['zh', 'en'])
def test_streaming_continues_after_failure_and_propagates_status(monkeypatch,lang):
monkeypatch.setattr(quality, 'dns_check', lambda: {'status':'unknown','reason':'timeout'})
called=[]
def tcp(label, ip, port):
called.append(label)
return {'status':'pass','target':label,'port':port,'elapsed_ms':7}
monkeypatch.setattr(quality, 'tcp_check', tcp)
output=[]
result=quality.run_quality(translator(lang),lambda text, **kwargs: output.append(text))
assert result==1 and called==['Cloudflare','Google']
assert len(output)==8
assert ('DNS 查询未成功' if lang=='zh' else 'DNS lookup did not succeed') in ' '.join(output)
+75
View File
@@ -0,0 +1,75 @@
import subprocess
import pytest
from proxydoctor.checks import Command, Host, OUTPUT_LIMIT, SYSTEM_PATH, inspect
from proxydoctor.cli import redact
from test_checks import Ubuntu, by_id
@pytest.mark.parametrize("address", ["2001:db8::1", "::ffff:192.0.2.1", "fe80::1%eth0", "192.0.2.1"])
@pytest.mark.parametrize("lang", ["zh", "en"])
def test_address_redaction_with_punctuation(address, lang):
result = redact(f"Address ({address}).", lang)
assert address not in result
assert "IP" in result
assert result.endswith(").")
def test_truncated_output_cannot_be_used_as_complete_evidence(monkeypatch):
def resolve(name, *, path):
assert path == SYSTEM_PATH
return "/usr/bin/ss"
monkeypatch.setattr("proxydoctor.checks.shutil.which", resolve)
monkeypatch.setattr("proxydoctor.checks.subprocess.run", lambda *a, **kw:
subprocess.CompletedProcess(a, 0, "x" * (OUTPUT_LIMIT + 1)))
result = Host("en").run("ss", "-H", "-lntu")
assert result.code is None
assert not result.output
assert "limit" in result.reason
def test_failed_service_is_not_reported_as_fresh_install():
host = Ubuntu({("systemctl", "is-active", "sing-box.service"): Command(3, "failed\n"),
("systemctl", "is-enabled", "sing-box.service"): Command(1, "disabled\n")})
data = inspect(host, lang="en")
assert by_id(data, "service")["status"] == "warn"
assert "failed state" in by_id(data, "service")["detail"]
assert by_id(data, "service_startup")["status"] == "warn"
@pytest.mark.parametrize("state,code,status", [("enabled", 0, "pass"), ("disabled", 1, "warn"),
("masked", 1, "warn"), ("enabled-runtime", 0, "warn"), ("static", 0, "unknown"), ("", None, "unknown")])
def test_boot_state_has_its_own_result(state, code, status):
host = Ubuntu({("systemctl", "is-enabled", "sing-box.service"): Command(code, state)})
assert by_id(inspect(host), "service_startup")["status"] == status
def test_empty_ssh_output_does_not_claim_settings_were_checked():
data = inspect(Ubuntu({("sshd", "-T"): Command(0, "")}), lang="en")
assert "missing required fields" in by_id(data, "ssh")["detail"]
def test_sshd_failure_advice_covers_socket_activated_runtime_directory():
for lang in ('zh', 'en'):
data = inspect(Ubuntu({('sshd', '-T'): Command(255, reason='读取命令失败,可能是权限、配置或服务状态问题')}), lang=lang)
ssh = by_id(data, 'ssh')
assert ssh['status'] == 'unknown'
assert ('运行目录' if lang == 'zh' else 'runtime directory') in ssh['advice']
def test_all_ssh_listen_ports_are_preserved():
host = Ubuntu({("sshd", "-T"): Command(0, "port 22\nport 2222\npasswordauthentication no\npermitrootlogin no\n")})
evidence = by_id(inspect(host), "ssh")["evidence"]
assert "port 22\nport 2222" in evidence
def test_missing_os_file_is_unknown():
host = Ubuntu()
host.read = lambda _: None
assert by_id(inspect(host), "system")["status"] == "unknown"
def test_malformed_port_is_not_free():
host = Ubuntu({("ss", "-H", "-lntu"): Command(0, "tcp LISTEN 0 5 *:99999 *:*\n")})
assert by_id(inspect(host), "ports")["status"] == "unknown"
+50
View File
@@ -0,0 +1,50 @@
import socket
import struct
from proxydoctor import routes
def test_native_trace_recognizes_destination_port_unreachable(monkeypatch):
monkeypatch.setattr(routes.GeoLocator,'lookup',lambda self,address:'Test location')
monkeypatch.setattr(routes.sys,'platform','linux')
class FakeSocket:
def __enter__(self): return self
def __exit__(self,*args): pass
def setsockopt(self,*args): pass
def setblocking(self,*args): pass
def sendto(self,data,address): self.dest=address
def recvmsg(self,*args):
error=struct.pack('=IBBBBII',111,2,3,3,0,0,0)
offender=b'\x02\x00\x00\x00'+socket.inet_aton('1.1.1.1')+b'\x00'*8
return b'',[(socket.IPPROTO_IP,11,error+offender)],0,self.dest
monkeypatch.setattr(routes.socket,'socket',lambda *args: FakeSocket())
output=[]
assert routes.trace('1.1.1.1',emit=lambda text,**kwargs: output.append(text))==0
assert '1.1.1.1' in output[0]
def test_unsupported_platform_does_not_open_sockets(monkeypatch):
monkeypatch.setattr(routes.sys,'platform','win32')
monkeypatch.setattr(routes.socket,'socket',lambda *args: (_ for _ in ()).throw(AssertionError()))
assert routes.trace('1.1.1.1',emit=lambda *args,**kwargs: None)==1
def test_trace_can_reach_a_destination_after_twelve_hops(monkeypatch):
monkeypatch.setattr(routes.sys,'platform','linux')
monkeypatch.setattr(routes.GeoLocator,'lookup',lambda self,address:'Test location')
class FakeSocket:
def __enter__(self): return self
def __exit__(self,*args): pass
def setsockopt(self,level,option,value):
if option==socket.IP_TTL: self.ttl=value
def setblocking(self,*args): pass
def sendto(self,data,address): self.dest=address
def recvmsg(self,*args):
reached=self.ttl==20
error=struct.pack('=IBBBBII',111,2,3 if reached else 11,3 if reached else 0,0,0,0)
offender=b'\x02\x00\x00\x00'+socket.inet_aton('1.1.1.1' if reached else '192.0.2.1')+b'\x00'*8
return b'',[(socket.IPPROTO_IP,11,error+offender)],0,self.dest
monkeypatch.setattr(routes.socket,'socket',lambda *args:FakeSocket())
output=[]
assert routes.trace('1.1.1.1',emit=lambda text,**kwargs:output.append(text))==0
assert len(output)==20
+24
View File
@@ -0,0 +1,24 @@
from proxydoctor.checks import inspect
from test_checks import Ubuntu, by_id
def test_server_information_uses_visible_resources():
host = Ubuntu()
original_read = host.read
host.read = lambda path: {'/proc/cpuinfo': 'processor : 0\nmodel name : Example CPU\n',
'/proc/meminfo': 'MemTotal: 2097152 kB\nMemAvailable: 1048576 kB\n'}.get(path, original_read(path))
host.cpu_count = lambda: 2
host.kernel = lambda: '6.8.0-test'
host.disk_total = lambda: 20 * 1024**3
data = inspect(host, lang='en')
assert '2' in by_id(data, 'cpu')['detail']
assert 'Example CPU' in by_id(data, 'cpu')['evidence']
assert '2048 MiB' in by_id(data, 'memory_total')['detail']
assert '20.0 GiB' in by_id(data, 'disk_capacity')['detail']
assert '6.8.0-test' in by_id(data, 'kernel')['detail']
def test_unavailable_hardware_information_is_explicitly_unknown():
data = inspect(Ubuntu())
for key in ('cpu', 'kernel', 'memory_total', 'disk_capacity'):
assert by_id(data, key)['status'] == 'unknown'
+149
View File
@@ -0,0 +1,149 @@
import json
import subprocess
import sys
import pytest
from proxydoctor.checks import Command, inspect
from proxydoctor.cli import redact
from test_checks import Ubuntu, by_id
class ConfiguredHost(Ubuntu):
def __init__(self, routes4='[]', routes6='[]', dns='nameserver 8.8.8.8\n', ssh=None, session=''):
commands = {
('ip','-j','-4','route','show','default'): Command(0, routes4),
('ip','-j','-6','route','show','default'): Command(0, routes6),
('sshd','-T'): ssh or Command(None, reason='命令不可用'),
}
super().__init__(commands)
self.dns, self.session = dns, session
def read(self, path):
return self.dns if path == '/etc/resolv.conf' else super().read(path)
def ssh_connection(self):
return self.session
@pytest.mark.parametrize('lang', ['zh','en'])
def test_ipv4_default_route_detected_without_connectivity_claim(lang):
host = ConfiguredHost(routes4=json.dumps([{'dst':'default', 'dev':'eth0', 'gateway':'192.0.2.1'}]))
result = by_id(inspect(host, lang=lang), 'default_route')
assert result['status'] == 'pass'
assert 'eth0' in result['evidence']
assert '192.0.2.1' not in str(redact(result, lang))
@pytest.mark.parametrize('routes4,routes6', [
('[]','[]'), ('broken','[]'), ('{}','[]'),
('[{"dst":"default"}]','[]'),
('[{"dst":"default","dev":"eth0","gateway":"not-an-ip"}]','[]'),
('[{"dst":"default","type":"blackhole"}]','[]'),
('[{"dst":"default","dev":"eth0"}]','broken'),
])
def test_routes_incomplete_or_malformed_never_pass(routes4,routes6):
assert by_id(inspect(ConfiguredHost(routes4, routes6)), 'default_route')['status'] == 'unknown'
@pytest.mark.parametrize('dns,status', [
('nameserver 1.1.1.1\nnameserver 2001:4860:4860::8888\n','pass'),
('nameserver 127.0.0.53\n','unknown'),
('nameserver ::1\n','unknown'), ('','unknown'), (None,'unknown'),
('nameserver invalid\n','unknown'), ('nameserver 0.0.0.0\n','unknown'),
('nameserver 1.1.1.1\nnameserver invalid','unknown'),
('# nameserver 1.1.1.1\nsearch example.com','unknown'),
])
def test_dns_config_results_do_not_claim_resolution(dns,status):
result=by_id(inspect(ConfiguredHost(dns=dns)), 'dns_config')
assert result['status']==status
assert '不主动发送 DNS' in result.get('advice','') or dns is None
assert '1.1.1.1' not in str(redact(result))
@pytest.mark.parametrize('config,session,port,status', [
('port 22\nport 2222\n','',22,'warn'),
('port 22\nport 2222\n','',2222,'warn'),
('port 2222\n','',8388,'unknown'),
('','192.0.2.1 50000 192.0.2.2 2200',2200,'warn'),
('','bad session',22,'unknown'),
('port invalid\n','',22,'unknown'),
('','',22,'unknown'),
])
@pytest.mark.parametrize('lang',['zh','en'])
def test_ssh_protection_warns_known_ports_otherwise_remains_unknown(config,session,port,status,lang):
host=ConfiguredHost(ssh=Command(0,config),session=session)
result=by_id(inspect(host,port=port,lang=lang),'ssh_port_safety')
assert result['status']==status
assert '192.0.2.' not in json.dumps(result)
def test_failed_ssh_configuration_does_not_suppress_valid_active_session():
host=ConfiguredHost(ssh=Command(1,''),session='192.0.2.1 40000 192.0.2.2 2222')
assert by_id(inspect(host,port=2222),'ssh_port_safety')['status']=='warn'
def test_failed_route_command_is_unknown_even_when_other_family_has_route():
host=ConfiguredHost(routes4='[{"dst":"default","dev":"eth0"}]')
host.commands[('ip','-j','-6','route','show','default')]=Command(None,reason='命令不可用')
assert by_id(inspect(host),'default_route')['status']=='unknown'
def test_public_ip_lookup_is_opt_in_and_never_implies_inbound_reachability():
host=ConfiguredHost()
calls=[]
host.detect_public_ip=lambda: calls.append(True) or '8.8.8.8'
inspect(host)
assert not calls
result=by_id(inspect(host,detect_public_ip=True,lang='en'),'public_ip')
assert calls == [True]
assert result['status']=='unknown'
assert 'inbound reachability' in result['detail']
assert '8.8.8.8' not in str(redact(result,'en'))
def test_public_ip_lookup_failure_remains_unknown():
host=ConfiguredHost()
host.detect_public_ip=lambda: None
result=by_id(inspect(host,detect_public_ip=True),'public_ip')
assert result['status']=='unknown'
assert '失败' in result['detail']
def test_public_ip_lookup_disabled_by_default_in_cli(monkeypatch):
from proxydoctor.checks import Host
def unexpected(_):
raise AssertionError('network used without opt-in')
monkeypatch.setattr(Host,'detect_public_ip',unexpected)
inspect(Host())
@pytest.mark.parametrize('payload,expected', [(b'8.8.8.8\n','8.8.8.8'), (b'127.0.0.1',None), (b'bad',None), (b'9'*129,None)])
def test_public_ip_lookup_validates_bounded_response(monkeypatch,payload,expected):
from proxydoctor.checks import Host
class Response:
def __enter__(self): return self
def __exit__(self,*args): pass
def read(self,size):
assert size==129
return payload
class Opener:
def open(self,url,timeout):
assert url=='https://api64.ipify.org' and timeout==5
return Response()
monkeypatch.setattr('proxydoctor.checks.urllib.request.build_opener',lambda *args: Opener())
assert Host().detect_public_ip()==expected
def test_public_ip_options_are_mutually_exclusive():
command=[sys.executable,'-m','proxydoctor','check','--public-ip','8.8.8.8','--detect-public-ip']
assert subprocess.run(command,capture_output=True).returncode==2
def test_show_ip_only_affects_terminal_never_saved_report(tmp_path):
report=tmp_path/'report.json'
command=[sys.executable,'-m','proxydoctor','check','--public-ip','8.8.8.8','--show-ip','--json','--output',str(report)]
result=subprocess.run(command,capture_output=True,encoding='utf-8')
assert result.returncode in (0,1)
assert '8.8.8.8' in result.stdout
assert '8.8.8.8' not in report.read_text(encoding='utf-8')
+78
View File
@@ -0,0 +1,78 @@
import subprocess
import sys
import pytest
from proxydoctor import suite
from proxydoctor import bandwidth, ipquality
def unavailable_bandwidth(**kwargs):
return {'sources':{'cloudflare':{'directions':{direction:bandwidth.summarize([bandwidth.Sample(direction,1024,error='request_failed')]*3)
for direction in ('download','upload')}}}}
def unavailable_ip(**kwargs):
from unittest.mock import patch
with patch.object(ipquality,'request_json',return_value=(None,'request_failed')):
return ipquality.collect()
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(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
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(suite.shutil,'which',lambda *args,**kwargs: None)
monkeypatch.setattr('proxydoctor.media.check',lambda *args: {'state':'unknown','http':403,'evidence':'playback_not_verified'})
monkeypatch.setattr(suite,'collect_ipquality',unavailable_ip)
monkeypatch.setattr(suite,'native_trace',lambda *args,**kwargs: 1)
assert suite.run_suite(lang='en')==1
output=capsys.readouterr().out
assert 'up to 30 hops' in output
assert 'Access denied' in output
def test_besttrace_argv_is_not_shell_interpolated(tmp_path,monkeypatch):
executable=tmp_path/'best trace'
executable.write_text('example',encoding='utf-8')
monkeypatch.setattr(suite,'measure_bandwidth',unavailable_bandwidth)
monkeypatch.setattr(suite,'run_quality',lambda *args,**kwargs: 0)
monkeypatch.setattr('proxydoctor.media.check',lambda *args: {'state':'unknown','http':200,'evidence':'playback_not_verified'})
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']]
@pytest.mark.parametrize('target',[';reboot','--help','not-a-host'])
def test_suite_rejects_invalid_route_target(target):
result=subprocess.run([sys.executable,'-m','proxydoctor','suite','--offline','--route-target='+target],capture_output=True)
assert result.returncode==2
def test_short_download_is_not_reported_as_bandwidth(monkeypatch):
import io
class Response:
status=200
headers={}
read=io.BytesIO(b'short').read
def __enter__(self): return self
def __exit__(self,*args): pass
class Opener:
def open(self,*args,**kwargs): return Response()
monkeypatch.setattr(bandwidth,'opener',lambda: Opener())
sample=bandwidth.transfer('download',1024)
assert sample.mbps is None and sample.error=='short_response'