Release ProxyDoctor 0.5.0 stage-one diagnostics
This commit is contained in:
commit
aa7f12d705
38 files changed
+3648
No files matched your search
@@ -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
@@ -0,0 +1,11 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.pytest-tmp/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
work/
|
||||
outputs/
|
||||
reports/
|
||||
@@ -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 信息及未脱敏报告提交到仓库。
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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/防火墙,不上传报告;联网模式的服务会看到请求来源。
|
||||
@@ -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.
|
||||
@@ -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 "$@"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""ProxyDoctor: collect evidence before proposing changes."""
|
||||
|
||||
__version__ = "0.5.0"
|
||||
@@ -0,0 +1,8 @@
|
||||
from .cli import main
|
||||
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
import sys
|
||||
print('\n已中止 / Interrupted',file=sys.stderr)
|
||||
sys.exit(130)
|
||||
@@ -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)
|
||||
@@ -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]}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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')
|
||||
@@ -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'
|
||||
Reference in new issue
Block a user