commit aa7f12d70526b0256fb56ddb61c2a7576675b8a1 Author: griffinmx <104078533+griffinmx@users.noreply.github.com> Date: Sat Oct 31 22:16:21 2026 -0700 Release ProxyDoctor 0.5.0 stage-one diagnostics diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..53ae30e --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b017a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.pytest-tmp/ +*.egg-info/ +build/ +dist/ +.venv/ +work/ +outputs/ +reports/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4feabea --- /dev/null +++ b/CONTRIBUTING.md @@ -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 信息及未脱敏报告提交到仓库。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d556de0 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..639db88 --- /dev/null +++ b/README.en.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..60cbdd8 --- /dev/null +++ b/README.md @@ -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) 也说明了目录限制,不保证页面可达就能播放。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0eb8000 --- /dev/null +++ b/SECURITY.md @@ -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/防火墙,不上传报告;联网模式的服务会看到请求来源。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..a1a91f0 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -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. diff --git a/proxydoctor.sh b/proxydoctor.sh new file mode 100644 index 0000000..5b2c31b --- /dev/null +++ b/proxydoctor.sh @@ -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 "$@" diff --git a/proxydoctor/__init__.py b/proxydoctor/__init__.py new file mode 100644 index 0000000..442ad3e --- /dev/null +++ b/proxydoctor/__init__.py @@ -0,0 +1,3 @@ +"""ProxyDoctor: collect evidence before proposing changes.""" + +__version__ = "0.5.0" diff --git a/proxydoctor/__main__.py b/proxydoctor/__main__.py new file mode 100644 index 0000000..e950ada --- /dev/null +++ b/proxydoctor/__main__.py @@ -0,0 +1,8 @@ +from .cli import main + +try: + main() +except KeyboardInterrupt: + import sys + print('\n已中止 / Interrupted',file=sys.stderr) + sys.exit(130) diff --git a/proxydoctor/bandwidth.py b/proxydoctor/bandwidth.py new file mode 100644 index 0000000..95f0549 --- /dev/null +++ b/proxydoctor/bandwidth.py @@ -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) diff --git a/proxydoctor/checks.py b/proxydoctor/checks.py new file mode 100644 index 0000000..292bc25 --- /dev/null +++ b/proxydoctor/checks.py @@ -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]} diff --git a/proxydoctor/cli.py b/proxydoctor/cli.py new file mode 100644 index 0000000..a22f327 --- /dev/null +++ b/proxydoctor/cli.py @@ -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"(?= 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) diff --git a/proxydoctor/console.py b/proxydoctor/console.py new file mode 100644 index 0000000..66cc8e0 --- /dev/null +++ b/proxydoctor/console.py @@ -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) diff --git a/proxydoctor/geo.py b/proxydoctor/geo.py new file mode 100644 index 0000000..2d4fa43 --- /dev/null +++ b/proxydoctor/geo.py @@ -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 diff --git a/proxydoctor/i18n.py b/proxydoctor/i18n.py new file mode 100644 index 0000000..ccc85e0 --- /dev/null +++ b/proxydoctor/i18n.py @@ -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 diff --git a/proxydoctor/ipquality.py b/proxydoctor/ipquality.py new file mode 100644 index 0000000..2213871 --- /dev/null +++ b/proxydoctor/ipquality.py @@ -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) diff --git a/proxydoctor/media.py b/proxydoctor/media.py new file mode 100644 index 0000000..34b99d8 --- /dev/null +++ b/proxydoctor/media.py @@ -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 diff --git a/proxydoctor/net.py b/proxydoctor/net.py new file mode 100644 index 0000000..3d78b49 --- /dev/null +++ b/proxydoctor/net.py @@ -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' diff --git a/proxydoctor/quality.py b/proxydoctor/quality.py new file mode 100644 index 0000000..3fb969a --- /dev/null +++ b/proxydoctor/quality.py @@ -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 diff --git a/proxydoctor/routes.py b/proxydoctor/routes.py new file mode 100644 index 0000000..80043fe --- /dev/null +++ b/proxydoctor/routes.py @@ -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 00: 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 diff --git a/proxydoctor/suite.py b/proxydoctor/suite.py new file mode 100644 index 0000000..3dc44ff --- /dev/null +++ b/proxydoctor/suite.py @@ -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 + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aa11142 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/tests/test_bandwidth_sources.py b/tests/test_bandwidth_sources.py new file mode 100644 index 0000000..0b1da11 --- /dev/null +++ b/tests/test_bandwidth_sources.py @@ -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 diff --git a/tests/test_checks.py b/tests/test_checks.py new file mode 100644 index 0000000..188f9a3 --- /dev/null +++ b/tests/test_checks.py @@ -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" diff --git a/tests/test_console_geo.py b/tests/test_console_geo.py new file mode 100644 index 0000000..c165342 --- /dev/null +++ b/tests/test_console_geo.py @@ -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 diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 0000000..d3e1394 --- /dev/null +++ b/tests/test_i18n.py @@ -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") diff --git a/tests/test_measurements.py b/tests/test_measurements.py new file mode 100644 index 0000000..4a5ee58 --- /dev/null +++ b/tests/test_measurements.py @@ -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,'

Get YouTube Premium

$13.99 / month

') + 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,''))['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,'') + 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,''),'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'

Welcome

') + 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 diff --git a/tests/test_media.py b/tests/test_media.py new file mode 100644 index 0000000..70c0edf --- /dev/null +++ b/tests/test_media.py @@ -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,'

'+message+'

'))['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,'

Hello

'))['state']=='unknown' + + +def test_translations_in_scripts_are_not_geoblock_evidence(): + page=Page(200,'

Join now

') + 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'
Netflix is not available in your country

Welcome

') + assert classify('Netflix',page)['state']=='unknown' diff --git a/tests/test_nodequality.py b/tests/test_nodequality.py new file mode 100644 index 0000000..96a99a0 --- /dev/null +++ b/tests/test_nodequality.py @@ -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 diff --git a/tests/test_phase1_delivery.py b/tests/test_phase1_delivery.py new file mode 100644 index 0000000..c7d2df9 --- /dev/null +++ b/tests/test_phase1_delivery.py @@ -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' diff --git a/tests/test_quality.py b/tests/test_quality.py new file mode 100644 index 0000000..36a3672 --- /dev/null +++ b/tests/test_quality.py @@ -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) diff --git a/tests/test_review.py b/tests/test_review.py new file mode 100644 index 0000000..e4445fa --- /dev/null +++ b/tests/test_review.py @@ -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" diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..eb3cf8d --- /dev/null +++ b/tests/test_routes.py @@ -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 diff --git a/tests/test_server_info.py b/tests/test_server_info.py new file mode 100644 index 0000000..005cad2 --- /dev/null +++ b/tests/test_server_info.py @@ -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' diff --git a/tests/test_stage1.py b/tests/test_stage1.py new file mode 100644 index 0000000..dbe3bf0 --- /dev/null +++ b/tests/test_stage1.py @@ -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') diff --git a/tests/test_suite.py b/tests/test_suite.py new file mode 100644 index 0000000..ee59342 --- /dev/null +++ b/tests/test_suite.py @@ -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'