diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e84b4a2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{sh,yml,yaml,json}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7400b01 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +bin/ipcheck text eol=lf +*.sh text eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..f8efdd9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,66 @@ +name: Bug report +description: Report incorrect detection, output, or client behavior. +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Do not include API keys, tokens, proxy passwords, cookies, or private hostnames. + - type: input + id: version + attributes: + label: ipcheck version + placeholder: ipcheck 0.3.0 + validations: + required: true + - type: dropdown + id: client + attributes: + label: Client + options: + - Codex + - Claude Code + - Custom endpoint + - Multiple clients + validations: + required: true + - type: dropdown + id: platform + attributes: + label: Platform + options: + - macOS + - Linux + - WSL + - Git Bash + - Other + validations: + required: true + - type: textarea + id: description + attributes: + label: What happened? + description: Include expected and actual behavior. + validations: + required: true + - type: textarea + id: report + attributes: + label: Redacted quick report + description: Paste `ipcheck --quick --markdown` after removing private hostnames. + render: markdown + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + validations: + required: true + - type: checkboxes + id: safety + attributes: + label: Safety check + options: + - label: I removed credentials and private data from this report. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3f777d1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Private security report + url: https://github.com/jacklv-coder/ipcheck/security/advisories/new + about: Report credential exposure or other vulnerabilities privately. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..2afc7aa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest a client, gateway, metric, or workflow improvement. +title: "[Feature]: " +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What diagnostic problem cannot be solved today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + validations: + required: true + - type: input + id: documentation + attributes: + label: Official client or API documentation + description: Link to the documented endpoint, proxy, or configuration behavior. + - type: textarea + id: privacy + attributes: + label: Privacy and billing considerations + description: Explain how the probe can avoid credentials and billable inference. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d53c9da --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + labels: + - dependencies diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..77625d0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +## Summary + + + +## Verification + +- [ ] `bash -n bin/ipcheck` +- [ ] `bash test/smoke.sh` +- [ ] `shellcheck bin/ipcheck test/smoke.sh` +- [ ] User-facing changes are documented in English and Chinese +- [ ] No API key, token, cookie, or proxy credential is extracted or forwarded + +## Evidence + + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0f3dc7b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + github-release: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - name: Verify version and tests + run: | + test "v$(bin/ipcheck --version | awk '{print $2}')" = "$GITHUB_REF_NAME" + bash test/smoke.sh + - name: Build source archive + run: | + git archive --format=tar.gz --prefix="ipcheck-${GITHUB_REF_NAME#v}/" \ + --output="ipcheck-${GITHUB_REF_NAME#v}.tar.gz" "$GITHUB_REF_NAME" + sha256sum "ipcheck-${GITHUB_REF_NAME#v}.tar.gz" > "ipcheck-${GITHUB_REF_NAME#v}.tar.gz.sha256" + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + "ipcheck-${GITHUB_REF_NAME#v}.tar.gz" \ + "ipcheck-${GITHUB_REF_NAME#v}.tar.gz.sha256" \ + --generate-notes \ + --title "ipcheck $GITHUB_REF_NAME" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb1f896..23480d8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,28 +4,30 @@ on: push: pull_request: +concurrency: + group: test-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read jobs: + shellcheck: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Lint shell scripts + run: shellcheck bin/ipcheck test/smoke.sh + smoke: strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Run smoke tests run: bash test/smoke.sh - - npm-package: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - name: Validate npm package - run: | - npm test - npm pack --dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..52fd62f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes to ipcheck are documented here. The project follows +[Semantic Versioning](https://semver.org/). + +## [0.3.0] - 2026-07-22 + +### Added + +- Claude Code auto-detection through `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL`, + `CLAUDE_CONFIG_DIR`, and `~/.claude/settings.json`. +- Safe Anthropic-compatible `/v1/messages` protocol probes with no credentials, + prompt, or billable inference request. +- Codex custom-provider and `openai_base_url` route detection. +- Per-client results, route validation, privacy metadata, warnings, and a + versioned JSON schema. +- `codex`, `claude`, and `all` selectors plus `--service`, `--quick`, and + `--version`. +- Warnings for Claude Code SOCKS and macOS system-proxy differences. +- Release automation, ShellCheck, pinned GitHub Actions, Dependabot, and project + community files. + +### Changed + +- Generalized the report from Codex-only diagnostics to AI coding CLI + diagnostics. +- HTTP 404 on a configured protocol route is now reported as `POOR`; HTTP 429 + and 5xx responses are reported as `FAIR` service conditions. +- Expanded privacy and configuration-redaction tests. + +## [0.2.0] - 2026-07-22 + +### Added + +- Initial Codex/OpenAI path diagnostics. +- Human, Markdown, and JSON reports. +- Homebrew and direct-download packaging. +- Median/P95 TTFB, jitter, reference bandwidth, and macOS `networkQuality`. + +[0.3.0]: https://github.com/jacklv-coder/ipcheck/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/jacklv-coder/ipcheck/releases/tag/v0.2.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6d16448 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,30 @@ +# Code of Conduct + +## Our pledge + +We pledge to make participation in this project a harassment-free experience +for everyone, regardless of age, body size, disability, ethnicity, sex +characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +## Expected behavior + +- Be respectful, constructive, and empathetic. +- Focus criticism on ideas and code, not people. +- Respect privacy, especially when reports involve credentials or company + network configuration. +- Accept responsibility, apologize, and learn from mistakes. + +Harassment, discriminatory language, threats, doxxing, sexualized conduct, and +deliberate disclosure of private information are not acceptable. + +## Enforcement + +Report unacceptable behavior privately through the repository owner's GitHub +profile. Maintainers may edit, remove, or reject comments, commits, issues, and +other contributions that violate this policy, and may temporarily or +permanently ban contributors for repeated or severe violations. + +This policy is adapted from the +[Contributor Covenant 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0256d64 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,56 @@ +# Contributing to ipcheck + +Thanks for helping make AI coding network diagnostics more reliable. + +## Before opening an issue + +1. Run `ipcheck --version` and include the version. +2. Run `ipcheck --quick --markdown` and attach the redacted report. +3. Remove any organization-private hostnames you do not want to disclose. +4. Search existing issues for the same client, gateway, or proxy behavior. + +Do not post API keys, auth tokens, proxy passwords, cookies, or unredacted +credential files. Use GitHub's private vulnerability reporting for security +issues. + +## Development + +ipcheck supports Bash 3.2, including the Bash version bundled with older macOS +releases. Avoid associative arrays and newer Bash-only syntax. + +Run the local checks: + +```bash +bash -n bin/ipcheck +bash test/smoke.sh +shellcheck bin/ipcheck test/smoke.sh +``` + +The smoke suite uses stubbed network commands and must not require internet +access or real credentials. + +## Pull requests + +- Keep each pull request focused. +- Add tests for behavior changes and failure modes. +- Update both English and Chinese documentation for user-facing changes. +- Preserve backward-compatible flags and JSON fields when practical. +- Never add code that extracts, stores, prints, or forwards client + authentication credentials. +- Explain which real client behavior or official documentation the change + matches. + +All CI jobs must pass before merge. Maintainers may request a real, redacted +report for a new provider or gateway. + +## Adding a client or gateway + +A new built-in probe should: + +1. Derive its route only from documented, non-secret configuration. +2. Avoid credentials, query parameters, redirects, and billable inference. +3. Use the same proxy behavior as the client as closely as possible. +4. Distinguish network reachability from an invalid protocol route. +5. Add stub tests for success, timeout, proxy authentication, and redaction. + +By contributing, you agree that your contribution is licensed under Apache-2.0. diff --git a/README.md b/README.md index 6b6371c..30d7c61 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,53 @@ # ipcheck +[![CI](https://github.com/jacklv-coder/ipcheck/actions/workflows/test.yml/badge.svg)](https://github.com/jacklv-coder/ipcheck/actions/workflows/test.yml) +[![GitHub release](https://img.shields.io/github/v/release/jacklv-coder/ipcheck)](https://github.com/jacklv-coder/ipcheck/releases) +[![License](https://img.shields.io/github/license/jacklv-coder/ipcheck)](LICENSE) + [简体中文](README.zh-CN.md) -`ipcheck` is a zero-dependency Bash CLI that diagnoses the real proxy and -network path used to reach Codex and OpenAI endpoints. It reports reachability, -median and P95 time-to-first-byte (TTFB), jitter, reference bandwidth, and a -clear `GOOD`, `FAIR`, `POOR`, or `BLOCKED` result. +Know whether your AI coding CLI is slow, blocked, or using the wrong gateway. + +`ipcheck` is a zero-dependency Bash CLI for the real network paths used by +**Codex** and **Claude Code**. It auto-detects installed clients and safe, +non-secret routing configuration, then reports reachability, median/P95 +time-to-first-byte (TTFB), jitter, reference bandwidth, and a clear +`GOOD`, `FAIR`, `POOR`, or `BLOCKED` result. + +```text +$ ipcheck --quick +ipcheck v0.3.0 — AI coding network diagnostics + +Detected clients + Codex model=gpt-5.6-sol, route=https://chatgpt.com + https://api.openai.com + Claude Code model=deepseek-v4-flash, route=https://dashscope.aliyuncs.com/apps/anthropic + +Service results + Codex GOOD The service path is reachable with acceptable latency and jitter. + Claude Code GOOD The service path is reachable with acceptable latency and jitter. -For Codex, latency and jitter usually matter more than peak download bandwidth. -`ipcheck` therefore scores the service path separately from the optional -bandwidth test. +Result: GOOD +``` + +## Why ipcheck + +- Tests the routes the clients actually use, including Claude-compatible + `${ANTHROPIC_BASE_URL}/v1/messages` gateways. +- Detects Codex `config.toml`, custom model providers, Claude Code + `settings.json`, `ANTHROPIC_BASE_URL`, and `ANTHROPIC_MODEL`. +- Understands OpenAI, Anthropic, LiteLLM-style gateways, and Alibaba Cloud + Model Studio/DashScope Anthropic-compatible routes. +- Reports each client separately, so one healthy service cannot hide another + blocked service. +- Never extracts, stores, prints, or sends API keys; never sends a prompt or + creates a billable model request. +- Produces human, Markdown, and stable versioned JSON output for support tickets + and automation. +- Runs on the Bash and curl already available on macOS and Linux. + +For coding agents, TTFB, failures, and jitter usually matter more than peak +download bandwidth. `ipcheck` scores the service path separately from its +optional Cloudflare reference download. ## Install @@ -20,19 +58,11 @@ brew tap jacklv-coder/tap brew install ipcheck ``` -### npm - -```bash -npm install --global @jacklv-coder/ipcheck -``` - -The npm package installs the same Bash executable and supports macOS and Linux. - ### Direct download ```bash mkdir -p "$HOME/.local/bin" -curl -fsSL https://raw.githubusercontent.com/jacklv-coder/ipcheck/v0.2.0/bin/ipcheck \ +curl -fsSL https://raw.githubusercontent.com/jacklv-coder/ipcheck/v0.3.0/bin/ipcheck \ -o "$HOME/.local/bin/ipcheck" chmod +x "$HOME/.local/bin/ipcheck" ``` @@ -41,76 +71,145 @@ Make sure `$HOME/.local/bin` is on `PATH`. ## Usage +Auto-detect installed clients: + ```bash ipcheck -ipcheck --system -ipcheck --json > report.json -ipcheck --markdown > report.md -ipcheck --samples 10 -ipcheck --endpoint https://your-proxy.example.com/health +ipcheck --quick ``` -Options: +Select a client explicitly: -```text ---json Emit machine-readable JSON. ---markdown Emit a Markdown report. ---endpoint URL Test one endpoint instead of the defaults (repeatable). ---samples N Requests per endpoint (default: 5). ---timeout SECONDS Per-request timeout (default: 20). ---no-bandwidth Skip the 2 MB reference download test. ---system Also run macOS networkQuality when available. +```bash +ipcheck codex +ipcheck claude +ipcheck all +ipcheck --service claude ``` -Use `CODEX_NETWORK_ENDPOINTS` to replace the default endpoint list: +Create shareable reports: ```bash -CODEX_NETWORK_ENDPOINTS='https://chatgpt.com/|https://api.openai.com/v1/models' ipcheck +ipcheck --json > ipcheck-report.json +ipcheck --markdown > ipcheck-report.md ``` -`HTTPS_PROXY`, `ALL_PROXY`, and their lowercase variants are respected. On -macOS, `ipcheck` also detects and uses the configured system HTTPS proxy when -no proxy environment variable is set. Proxy credentials are redacted from -reports. +Tune or override a check: + +```bash +ipcheck --samples 10 +ipcheck --timeout 30 +ipcheck --system +ipcheck --endpoint https://your-gateway.example.com/health +``` + +Run `ipcheck --help` for the complete option and environment-variable list. + +## Supported clients and routes + +| Client | Configuration detected | Network route tested | +| --- | --- | --- | +| Codex | `$CODEX_HOME/config.toml`, `model`, `openai_base_url`, selected custom provider | ChatGPT/OpenAI defaults or the configured `/v1/responses` route | +| Claude Code | `$CLAUDE_CONFIG_DIR/settings.json`, `~/.claude/settings.json`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` | Configured `${ANTHROPIC_BASE_URL}/v1/messages` route | +| Custom | `--endpoint`, `IPCHECK_ENDPOINTS` | User-provided GET endpoint(s) | + +Legacy `CODEX_NETWORK_ENDPOINTS` remains supported. Claude-specific endpoint +overrides can use `CLAUDE_NETWORK_ENDPOINTS`. + +### Alibaba Cloud + Claude Code + +This common Claude Code configuration is auto-detected: + +```json +{ + "env": { + "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY", + "ANTHROPIC_BASE_URL": "https://dashscope.aliyuncs.com/apps/anthropic", + "ANTHROPIC_MODEL": "deepseek-v4-flash" + } +} +``` + +`ipcheck claude` probes +`https://dashscope.aliyuncs.com/apps/anthropic/v1/messages` with an empty, +unauthenticated protocol check. It does not read `ANTHROPIC_AUTH_TOKEN`. + +## Proxy behavior + +`HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`, and lowercase variants are reported +with credentials redacted. Claude probes intentionally ignore `ALL_PROXY` when +neither `HTTPS_PROXY` nor `HTTP_PROXY` is configured, matching Claude Code's +documented support boundary. On macOS, Codex and custom checks can fall back to +the configured system HTTPS proxy when an HTTPS proxy environment variable is +absent. + +Claude Code supports `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY`, but does not +support SOCKS proxies. `ipcheck` warns when its detected proxy configuration is +incompatible or likely to behave differently from curl or Codex. See Anthropic's +[corporate proxy documentation](https://docs.anthropic.com/en/docs/claude-code/corporate-proxy). ## Results | Result | Meaning | | --- | --- | -| `GOOD` | Every primary-endpoint sample succeeded with acceptable TTFB and jitter. | -| `FAIR` | Reachable, but latency is elevated or at least one sampled request failed. | -| `POOR` | The primary path is mostly unavailable, slow, or unstable. | -| `BLOCKED` | No tested endpoint returned an HTTP response. | - -HTTP 401 and 403 responses count as reachable because they prove that DNS, -proxying, TLS, and HTTP reached the remote service; authentication is outside -this network-only check. HTTP 407 does not count because it means the proxy -rejected the request before it reached the service. - -Credentials embedded in endpoint URLs are masked, and query strings are -replaced with `?` in every report format. - -`GOOD` requires every primary-endpoint sample to succeed, with median TTFB below -800 ms and jitter below 1,000 ms. Any failed primary sample limits the result to -`FAIR`; a success rate below 60% is `POOR`. With full reachability, median TTFB -below 3,000 ms is `FAIR`, and slower paths are `POOR`. -The reference bandwidth download uses Cloudflare and is not treated as OpenAI -model-generation speed. +| `GOOD` | Every primary sample succeeded with acceptable TTFB and jitter. | +| `FAIR` | Reachable, but latency is elevated, samples failed, or the API is rate-limited/unhealthy. | +| `POOR` | Mostly unavailable, very slow/unstable, or the configured API route returned HTTP 404. | +| `BLOCKED` | No primary endpoint returned an HTTP response, or the proxy returned HTTP 407. | + +HTTP 401 and 403 count as network-reachable because they prove DNS, proxying, +TLS, and HTTP reached the API route. HTTP 407 does not count because the proxy +rejected the request first. A 404 on a configured `/v1/messages` or +`/v1/responses` route is reported as `POOR` with a base-URL hint. + +The default thresholds are: + +- `GOOD`: 100% primary success, median TTFB below 800 ms, jitter below 1,000 ms. +- `FAIR`: all samples succeed and median TTFB is below 3,000 ms, or the path is + otherwise reachable with a recoverable warning. +- `POOR`: primary success below 60%, median TTFB at least 3,000 ms, or an invalid + configured API route. + +## Privacy and security + +`ipcheck` is intentionally a network-layer diagnostic: + +- The settings parser selects only named routing/model fields. Authentication + values are never extracted into shell variables or temporary files. +- API keys, bearer tokens, and cookies are never printed or passed to curl. +- Every curl invocation starts with `-q`, so user-level `.curlrc` files cannot + inject headers, cookies, credentials, or alternate routes. +- Claude/OpenAI protocol probes use an empty JSON object and cannot invoke a + model successfully without authentication. +- Proxy credentials are masked. Endpoint URLs are restricted to credential-free + HTTP/HTTPS paths; URL userinfo, query strings, and fragments are rejected. +- Temporary metrics are deleted on exit. + +Please report vulnerabilities privately as described in [SECURITY.md](SECURITY.md). + +## JSON and exit codes + +JSON output includes `schema_version`, per-service results, per-endpoint +measurements, privacy guarantees, warnings, bandwidth, and optional macOS +network quality. Additive fields may appear without changing the schema version; +breaking field changes increment it. + +- `0`: at least one selected primary service path was reachable. +- `1`: every selected primary service path was blocked/unavailable. +- `2`: invalid invocation or missing dependency. ## Requirements -- Bash +- Bash 3.2+ - curl -- awk -- sed -- sort +- awk, sed, sort - Optional on macOS: `networkQuality` -## Exit codes +## Contributing -- `0`: at least one endpoint was reachable -- `1`: all endpoints were blocked or unavailable -- `2`: invalid invocation or missing dependency +Issues and pull requests are welcome. Start with [CONTRIBUTING.md](CONTRIBUTING.md) +and the [Code of Conduct](CODE_OF_CONDUCT.md). See [CHANGELOG.md](CHANGELOG.md) +for release history. ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 23f7a37..6e5926d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,10 +1,47 @@ # ipcheck +[![CI](https://github.com/jacklv-coder/ipcheck/actions/workflows/test.yml/badge.svg)](https://github.com/jacklv-coder/ipcheck/actions/workflows/test.yml) +[![GitHub Release](https://img.shields.io/github/v/release/jacklv-coder/ipcheck)](https://github.com/jacklv-coder/ipcheck/releases) +[![License](https://img.shields.io/github/license/jacklv-coder/ipcheck)](LICENSE) + [English](README.md) -`ipcheck` 是一个零依赖 Bash 命令行工具,用来诊断访问 Codex/OpenAI 时 -实际经过的代理和网络路径。它会测量端点可达性、首字节延迟中位数、P95、 -抖动和参考带宽,并给出明确的 `GOOD`、`FAIR`、`POOR` 或 `BLOCKED` 结论。 +快速判断 AI 编程 CLI 到底是网络慢、代理被拦,还是网关地址配错。 + +`ipcheck` 是一个零依赖 Bash 命令行工具,面向 **Codex** 和 +**Claude Code** 的真实网络路径。它会自动识别本机客户端以及不含密钥的路由 +配置,测量可达性、首字节延迟(TTFB)中位数/P95、抖动和参考带宽,并给出 +明确的 `GOOD`、`FAIR`、`POOR` 或 `BLOCKED` 结论。 + +```text +$ ipcheck --quick +ipcheck v0.3.0 — AI coding network diagnostics + +Detected clients + Codex model=gpt-5.6-sol, route=https://chatgpt.com + https://api.openai.com + Claude Code model=deepseek-v4-flash, route=https://dashscope.aliyuncs.com/apps/anthropic + +Service results + Codex GOOD + Claude Code GOOD + +Result: GOOD +``` + +## 核心能力 + +- 检测客户端真实协议路径,包括 Claude 兼容网关的 + `${ANTHROPIC_BASE_URL}/v1/messages`。 +- 自动识别 Codex `config.toml`、自定义模型提供商、Claude Code + `settings.json`、`ANTHROPIC_BASE_URL` 和 `ANTHROPIC_MODEL`。 +- 兼容 OpenAI、Anthropic、LiteLLM 类网关,以及阿里云百炼/DashScope + Anthropic 兼容入口。 +- Codex 与 Claude Code 分别评分,避免一个正常服务掩盖另一个被拦服务。 +- 不提取、不保存、不显示也不发送 API Key;不发送 Prompt,不产生模型调用费用。 +- 支持终端、Markdown 和带版本号的 JSON 报告,方便提工单或接入自动化。 + +对 AI 编程工具而言,首字节延迟、失败率和抖动通常比峰值下载带宽更重要。 +因此服务路径与 Cloudflare 参考下载会分开评分。 ## 安装 @@ -15,53 +52,150 @@ brew tap jacklv-coder/tap brew install ipcheck ``` -npm(macOS/Linux): - -```bash -npm install --global @jacklv-coder/ipcheck -``` - 直接下载: ```bash mkdir -p "$HOME/.local/bin" -curl -fsSL https://raw.githubusercontent.com/jacklv-coder/ipcheck/v0.2.0/bin/ipcheck \ +curl -fsSL https://raw.githubusercontent.com/jacklv-coder/ipcheck/v0.3.0/bin/ipcheck \ -o "$HOME/.local/bin/ipcheck" chmod +x "$HOME/.local/bin/ipcheck" ``` ## 使用 +自动识别客户端: + ```bash ipcheck -ipcheck --system -ipcheck --json > report.json -ipcheck --markdown > report.md +ipcheck --quick +``` + +只检查指定客户端: + +```bash +ipcheck codex +ipcheck claude +ipcheck all +ipcheck --service claude +``` + +生成可分享报告: + +```bash +ipcheck --json > ipcheck-report.json +ipcheck --markdown > ipcheck-report.md +``` + +其他常用选项: + +```bash ipcheck --samples 10 -ipcheck --endpoint https://your-proxy.example.com/health +ipcheck --timeout 30 +ipcheck --system +ipcheck --endpoint https://your-gateway.example.com/health +``` + +运行 `ipcheck --help` 可以查看完整参数。 + +## 支持的客户端与配置 + +| 客户端 | 自动读取的非敏感配置 | 检测路径 | +| --- | --- | --- | +| Codex | `$CODEX_HOME/config.toml`、`model`、`openai_base_url`、当前自定义 provider | ChatGPT/OpenAI 默认路径或自定义 `/v1/responses` | +| Claude Code | `$CLAUDE_CONFIG_DIR/settings.json`、`~/.claude/settings.json`、`ANTHROPIC_BASE_URL`、`ANTHROPIC_MODEL` | `${ANTHROPIC_BASE_URL}/v1/messages` | +| 自定义 | `--endpoint`、`IPCHECK_ENDPOINTS` | 用户指定的 GET 地址 | + +原有 `CODEX_NETWORK_ENDPOINTS` 保持兼容;Claude 可使用 +`CLAUDE_NETWORK_ENDPOINTS` 覆盖端点。 + +### 阿里云百炼 + Claude Code + +下面这种配置会被自动识别: + +```json +{ + "env": { + "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY", + "ANTHROPIC_BASE_URL": "https://dashscope.aliyuncs.com/apps/anthropic", + "ANTHROPIC_MODEL": "deepseek-v4-flash" + } +} ``` -工具会尊重 `HTTPS_PROXY`、`ALL_PROXY` 等代理变量;macOS 未配置代理变量时, -还会读取并使用系统 HTTPS 代理。报告中的代理凭据会被脱敏。 +执行 `ipcheck claude` 时会检测: + +```text +https://dashscope.aliyuncs.com/apps/anthropic/v1/messages +``` + +检测请求不携带 `ANTHROPIC_AUTH_TOKEN`,也不会触发模型推理计费。 + +## 代理行为 + +工具会显示 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 及其小写形式,并对 +用户名、密码进行脱敏。若没有配置 `HTTPS_PROXY` 或 `HTTP_PROXY`,Claude +检测会忽略其官方未声明支持的 `ALL_PROXY`。在 macOS 上,Codex/自定义检测 +还可以在没有 HTTPS 代理环境变量时使用系统 HTTPS 代理。 + +Claude Code 支持 `HTTPS_PROXY`、`HTTP_PROXY` 和 `NO_PROXY`,但不支持 +SOCKS。检测到不兼容或与 Codex/curl 路径可能不同的配置时,`ipcheck` 会明确警告。 +详见 Anthropic 的 +[企业代理文档](https://docs.anthropic.com/en/docs/claude-code/corporate-proxy)。 ## 结论含义 | 结果 | 含义 | | --- | --- | -| `GOOD` | 主端点全部采样成功,且首字节延迟和抖动较低 | -| `FAIR` | 端点可达,但延迟偏高或至少一次采样失败 | -| `POOR` | 主路径多数采样不可用、延迟高或响应不稳定 | -| `BLOCKED` | 所有端点都无法获得 HTTP 响应 | - -HTTP 401/403 仍算网络可达,因为这表示 DNS、代理、TLS 和 HTTP 已经到达 -远端服务,只是请求没有携带有效认证。HTTP 407 不算可达,因为它表示请求 -在到达服务前就被代理拒绝。报告会隐藏端点 URL 中的凭据和查询参数。 - -默认规则:主端点全部采样成功、首字节延迟中位数低于 800 ms 且抖动低于 -1,000 ms 为 `GOOD`;只要有采样失败,最高为 `FAIR`,成功率低于 60% 为 -`POOR`。在全部采样成功时,低于 3,000 ms 为 `FAIR`,更慢则为 `POOR`。 -Cloudflare 下载测试只是参考 -带宽,不代表 OpenAI 模型生成速度。 +| `GOOD` | 主端点全部采样成功,TTFB 与抖动均在合理范围 | +| `FAIR` | 网络可达,但延迟偏高、存在失败,或 API 正在限流/异常 | +| `POOR` | 多数不可用、非常慢/不稳定,或配置的 API 路径返回 404 | +| `BLOCKED` | 主端点没有 HTTP 响应,或代理返回 HTTP 407 | + +HTTP 401/403 仍算网络可达,因为这表示 DNS、代理、TLS 和 HTTP 已到达 API +路径;HTTP 407 表示请求先被代理拒绝。配置的 `/v1/messages` 或 +`/v1/responses` 返回 404 时,会判定为 `POOR` 并提示检查 Base URL。 + +默认阈值: + +- `GOOD`:主路径 100% 成功,中位 TTFB 小于 800 ms,抖动小于 1,000 ms。 +- `FAIR`:全部成功且中位 TTFB 小于 3,000 ms,或出现可恢复的服务警告。 +- `POOR`:成功率低于 60%、中位 TTFB 至少 3,000 ms,或 API 路径无效。 + +## 隐私与安全 + +- 配置解析只提取指定的路由和模型字段,认证值不会进入 Shell 变量或临时文件。 +- 不显示 API Key、Bearer Token 或 Cookie,也不会把它们传给 curl。 +- 所有 curl 调用均以 `-q` 开始,用户级 `.curlrc` 无法注入请求头、Cookie、 + 凭据或替换检测地址。 +- Claude/OpenAI 协议探测只发送空 JSON,且不带认证,因此不会调用模型。 +- 代理凭据会被隐藏;端点只允许无凭据的 HTTP/HTTPS 路径,URL 用户信息、 + 查询参数和片段会被拒绝。 +- 临时指标文件会在程序退出时删除。 + +安全问题请按照 [SECURITY.md](SECURITY.md) 私下报告。 + +## JSON 与退出码 + +JSON 包含 `schema_version`、每个客户端与端点的独立结论、隐私声明、警告、 +带宽和可选的 macOS `networkQuality` 数据。增加兼容字段不会升级 schema; +破坏性字段调整才会升级版本。 + +- `0`:至少一个主服务路径可达。 +- `1`:全部主服务路径被阻断或不可用。 +- `2`:参数错误或缺少运行依赖。 + +## 运行要求 + +- Bash 3.2+ +- curl +- awk、sed、sort +- macOS 可选:`networkQuality` + +## 参与贡献 + +欢迎提交 Issue 和 Pull Request。请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md) +和 [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)。版本历史见 +[CHANGELOG.md](CHANGELOG.md)。 ## 许可证 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a5e1b1e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security policy + +## Supported versions + +Security fixes are provided for the latest released version of ipcheck. + +## Reporting a vulnerability + +Please do not open a public issue for a suspected vulnerability. + +Use GitHub's **Report a vulnerability** button in the Security tab of +[jacklv-coder/ipcheck](https://github.com/jacklv-coder/ipcheck/security) to send +a private report. Include the affected version, impact, reproduction steps, and +any suggested mitigation. Do not include live third-party credentials. + +You should receive an initial response within seven days. Confirmed issues will +be coordinated privately until a fix and release are available. + +## Security boundaries + +ipcheck is designed not to extract, store, print, or transmit Codex/Claude +authentication tokens. Its settings parser selects only routing and model-name +fields required to choose a safe probe. Reports redact URL credentials and +query strings. + +If you find a path that exposes a token, proxy password, cookie, sensitive query +parameter, or credential-file content, treat it as a security vulnerability. diff --git a/bin/ipcheck b/bin/ipcheck index a3a21fc..ed6fb58 100755 --- a/bin/ipcheck +++ b/bin/ipcheck @@ -1,13 +1,15 @@ #!/usr/bin/env bash -# Measure the IP/proxy network path used by Codex/OpenAI endpoints. +# Diagnose the proxy and network paths used by AI coding CLIs. # Dependencies: bash, curl, awk, sed, sort. macOS networkQuality is optional. set -u -VERSION="0.2.0" -SAMPLES=5 -TIMEOUT=20 -ENDPOINTS="https://chatgpt.com/|https://api.openai.com/v1/models" +VERSION="0.3.0" +SCHEMA_VERSION=1 +SAMPLES=3 +TIMEOUT=15 +SERVICE_MODE="auto" +CLI_ENDPOINTS="" CLI_ENDPOINTS_SET=0 BANDWIDTH_URL="https://speed.cloudflare.com/__down?bytes=2000000" MODE="human" @@ -16,24 +18,34 @@ RUN_SYSTEM=0 usage() { cat <<'EOF' -ipcheck - diagnose the IP/proxy path used by Codex/OpenAI +ipcheck - diagnose Codex and Claude Code network paths Usage: - ipcheck [options] + ipcheck [codex|claude|all] [options] Options: + --service NAME auto, codex, claude, or all (default: auto). --json Emit machine-readable JSON. --markdown Emit a Markdown report. - --endpoint URL Test one endpoint instead of the defaults (repeatable). - --samples N Requests per endpoint (default: 5). - --timeout SECONDS Per-request timeout (default: 20). + --endpoint URL Test custom GET endpoint(s) instead of client defaults. + --samples N Requests per endpoint (default: 3). + --timeout SECONDS Per-request timeout (default: 15). + --quick Use 1 sample, an 8s timeout, and skip bandwidth. --no-bandwidth Skip the 2 MB reference download test. --system Also run macOS networkQuality when available. + -v, --version Show the ipcheck version. -h, --help Show this help. Environment: - CODEX_NETWORK_ENDPOINTS Pipe-separated endpoint list, e.g. URL1|URL2. - HTTPS_PROXY/ALL_PROXY Proxy settings are respected by curl and reported redacted. + IPCHECK_SERVICES auto, codex, claude, or all. + IPCHECK_ENDPOINTS Pipe-separated custom endpoint list. + CODEX_NETWORK_ENDPOINTS Pipe-separated Codex endpoint override. + CLAUDE_NETWORK_ENDPOINTS Pipe-separated Claude endpoint override. + ANTHROPIC_BASE_URL Claude Code gateway base URL. + HTTPS_PROXY/HTTP_PROXY Standard proxy settings respected by the clients. + ALL_PROXY Also respected by curl; see Claude warnings in output. + +No API key or authentication token is used, transmitted, or printed. EOF } @@ -49,34 +61,65 @@ is_integer() { esac } +validate_service() { + case "$1" in + auto|codex|claude|all) ;; + *) die "service must be auto, codex, claude, or all" ;; + esac +} + +if [ -n "${IPCHECK_SERVICES-}" ]; then + validate_service "$IPCHECK_SERVICES" + SERVICE_MODE="$IPCHECK_SERVICES" +fi + while [ "$#" -gt 0 ]; do case "$1" in + codex|claude|all) + SERVICE_MODE="$1" + ;; + --service) + [ "$#" -ge 2 ] || die "--service requires a name" + validate_service "$2" + SERVICE_MODE="$2" + shift + ;; --json) MODE="json" ;; --markdown) MODE="markdown" ;; --endpoint) [ "$#" -ge 2 ] || die "--endpoint requires a URL" if [ "$CLI_ENDPOINTS_SET" -eq 0 ]; then - ENDPOINTS="$2" + CLI_ENDPOINTS="$2" CLI_ENDPOINTS_SET=1 else - ENDPOINTS="$ENDPOINTS|$2" + CLI_ENDPOINTS="$CLI_ENDPOINTS|$2" fi shift ;; --samples) [ "$#" -ge 2 ] || die "--samples requires a number" - is_integer "$2" && [ "$2" -gt 0 ] || die "--samples must be a positive integer" + if ! is_integer "$2" || ! [ "$2" -gt 0 ] 2>/dev/null; then + die "--samples must be a positive integer" + fi SAMPLES=$(printf '%s\n' "$2" | sed 's/^0*//') shift ;; --timeout) [ "$#" -ge 2 ] || die "--timeout requires a number" - is_integer "$2" && [ "$2" -gt 0 ] || die "--timeout must be a positive integer" + if ! is_integer "$2" || ! [ "$2" -gt 0 ] 2>/dev/null; then + die "--timeout must be a positive integer" + fi TIMEOUT=$(printf '%s\n' "$2" | sed 's/^0*//') shift ;; + --quick) + SAMPLES=1 + TIMEOUT=8 + RUN_BANDWIDTH=0 + ;; --no-bandwidth) RUN_BANDWIDTH=0 ;; --system) RUN_SYSTEM=1 ;; + -v|--version) printf 'ipcheck %s\n' "$VERSION"; exit 0 ;; -h|--help) usage; exit 0 ;; *) die "unknown option: $1 (use --help)" ;; esac @@ -85,10 +128,295 @@ done command -v curl >/dev/null 2>&1 || die "curl is required" command -v awk >/dev/null 2>&1 || die "awk is required" +command -v sed >/dev/null 2>&1 || die "sed is required" command -v sort >/dev/null 2>&1 || die "sort is required" -# curl does not automatically use macOS System Settings proxies. If no proxy -# environment variable is set, honor the configured HTTPS system proxy. +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/ipcheck.XXXXXX") || die "cannot create a temporary directory" +trap 'rm -rf "$WORK_DIR"' EXIT INT TERM + +SERVICES_FILE="$WORK_DIR/services.tsv" +PROBES_FILE="$WORK_DIR/probes.tsv" +INDEXED_PROBES_FILE="$WORK_DIR/indexed-probes.tsv" +ENDPOINT_SUMMARY_FILE="$WORK_DIR/endpoint-summary.tsv" +ENDPOINT_JSON_FILE="$WORK_DIR/endpoints.jsonl" +SERVICE_RESULT_FILE="$WORK_DIR/service-results.tsv" +WARNINGS_FILE="$WORK_DIR/warnings.txt" +: > "$SERVICES_FILE" +: > "$PROBES_FILE" +: > "$INDEXED_PROBES_FILE" +: > "$ENDPOINT_SUMMARY_FILE" +: > "$ENDPOINT_JSON_FILE" +: > "$SERVICE_RESULT_FILE" +: > "$WARNINGS_FILE" + +json_escape() { + # JSON requires every U+0000..U+001F control character to be escaped. awk's + # record handling supplies the escaped LF between input records; shell + # variables cannot contain NUL. + printf '%s' "$1" | LC_ALL=C awk ' + BEGIN { + ORS = "" + for (code = 1; code < 32; code++) controls[sprintf("%c", code)] = code + } + { + if (NR > 1) printf "\\n" + for (position = 1; position <= length($0); position++) { + character = substr($0, position, 1) + if (character == "\\") printf "\\\\" + else if (character == "\"") printf "\\\"" + else if (character in controls) printf "\\u%04x", controls[character] + else printf "%s", character + } + } + ' +} + +sanitize_field() { + printf '%s' "$1" | tr '\t\r\n' ' ' +} + +redact_url() { + printf '%s' "$1" | sed -E 's#://[^/]*@#://***@#; s#\?.*$#?#; s/#.*$//' +} + +toml_top_string() { + local file key + file="$1" + key="$2" + [ -f "$file" ] || return 0 + awk -v key="$key" ' + /^\[/ { exit } + $0 ~ "^[[:space:]]*" key "[[:space:]]*=" { + line = $0 + sub(/^[^=]*=[[:space:]]*/, "", line) + quote = sprintf("%c", 39) + first = substr(line, 1, 1) + if (first == "\"") { + sub(/^"/, "", line) + sub(/"[[:space:]]*(#.*)?$/, "", line) + } else if (first == quote) { + sub("^" quote, "", line) + sub(quote "[[:space:]]*(#.*)?$", "", line) + } else { + sub(/[[:space:]]*(#.*)?$/, "", line) + } + print line + exit + } + ' "$file" +} + +toml_section_string() { + local file section key + file="$1" + section="$2" + key="$3" + [ -f "$file" ] || return 0 + awk -v section="$section" -v key="$key" ' + $0 == "[" section "]" { inside = 1; next } + /^\[/ { inside = 0 } + inside && $0 ~ "^[[:space:]]*" key "[[:space:]]*=" { + line = $0 + sub(/^[^=]*=[[:space:]]*/, "", line) + quote = sprintf("%c", 39) + first = substr(line, 1, 1) + if (first == "\"") { + sub(/^"/, "", line) + sub(/"[[:space:]]*(#.*)?$/, "", line) + } else if (first == quote) { + sub("^" quote, "", line) + sub(quote "[[:space:]]*(#.*)?$", "", line) + } else { + sub(/[[:space:]]*(#.*)?$/, "", line) + } + print line + exit + } + ' "$file" +} + +json_setting() { + local file key + file="$1" + key="$2" + [ -f "$file" ] || return 0 + sed -n -E 's/.*"'"$key"'"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p' "$file" | head -n 1 +} + +append_path() { + local base path + base=$(printf '%s' "$1" | sed 's#/*$##') + path="$2" + case "$base" in + */v1) printf '%s/%s' "$base" "$path" ;; + *) printf '%s/v1/%s' "$base" "$path" ;; + esac +} + +validate_http_url() { + local url authority + url="$1" + case "$url" in + http://*|https://*) ;; + *) die "endpoint URLs must use http:// or https://" ;; + esac + case "$url" in + *[[:space:]]*) die "endpoint URLs must not contain whitespace" ;; + *\?*|*\#*) die "endpoint URLs must not contain query strings or fragments" ;; + esac + authority=${url#*://} + authority=${authority%%/*} + [ -n "$authority" ] || die "endpoint URL is missing a host" + case "$authority" in + *@*) die "endpoint URLs must not contain credentials" ;; + esac +} + +add_service() { + # id, display name, model, base URL/route, config source + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$(sanitize_field "$1")" "$(sanitize_field "$2")" "$(sanitize_field "$3")" \ + "$(sanitize_field "$(redact_url "$4")")" "$(sanitize_field "$5")" >> "$SERVICES_FILE" +} + +add_probe() { + # service, label, URL, probe type, primary|secondary + validate_http_url "$3" + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$(sanitize_field "$1")" "$(sanitize_field "$2")" "$3" \ + "$(sanitize_field "$4")" "$(sanitize_field "$5")" >> "$PROBES_FILE" +} + +add_endpoint_list() { + local service remaining label_prefix endpoint number final + service="$1" + remaining="$2" + label_prefix="$3" + number=1 + while :; do + final=0 + case "$remaining" in + *'|'*) endpoint=${remaining%%|*}; remaining=${remaining#*|} ;; + *) endpoint="$remaining"; remaining=""; final=1 ;; + esac + if [ -n "$endpoint" ]; then + add_probe "$service" "$label_prefix $number" "$endpoint" "get" "$([ "$number" -eq 1 ] && printf primary || printf secondary)" + number=$((number + 1)) + fi + [ "$final" -eq 0 ] || break + done +} + +CODEX_HOME_PATH="${CODEX_HOME:-$HOME/.codex}" +CODEX_CONFIG="$CODEX_HOME_PATH/config.toml" +CLAUDE_CONFIG_PATH="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +CLAUDE_SETTINGS="$CLAUDE_CONFIG_PATH/settings.json" +if [ -n "${CLAUDE_CONFIG_DIR-}" ]; then + CLAUDE_SETTINGS_SOURCE="\${CLAUDE_CONFIG_DIR}/settings.json" +else + CLAUDE_SETTINGS_SOURCE="\$HOME/.claude/settings.json" +fi + +if [ "$SERVICE_MODE" = "auto" ]; then + codex_detected=0 + claude_detected=0 + command -v codex >/dev/null 2>&1 && codex_detected=1 + [ -f "$CODEX_CONFIG" ] && codex_detected=1 + [ -n "${CODEX_NETWORK_ENDPOINTS-}" ] && codex_detected=1 + command -v claude >/dev/null 2>&1 && claude_detected=1 + [ -f "$CLAUDE_SETTINGS" ] && claude_detected=1 + [ -n "${ANTHROPIC_BASE_URL-}${ANTHROPIC_MODEL-}" ] && claude_detected=1 + [ -n "${CLAUDE_NETWORK_ENDPOINTS-}" ] && claude_detected=1 + if [ "$codex_detected" -eq 1 ] && [ "$claude_detected" -eq 1 ]; then + SERVICE_MODE="all" + elif [ "$claude_detected" -eq 1 ]; then + SERVICE_MODE="claude" + elif [ "$codex_detected" -eq 1 ]; then + SERVICE_MODE="codex" + else + SERVICE_MODE="all" + fi +fi + +if [ "$CLI_ENDPOINTS_SET" -eq 0 ] && [ -n "${IPCHECK_ENDPOINTS-}" ]; then + CLI_ENDPOINTS="$IPCHECK_ENDPOINTS" + CLI_ENDPOINTS_SET=1 +fi + +if [ "$CLI_ENDPOINTS_SET" -eq 1 ]; then + add_service "custom" "Custom" "n/a" "custom endpoints" "command line/environment" + add_endpoint_list "custom" "$CLI_ENDPOINTS" "Custom endpoint" + SERVICE_MODE="custom" +else + if [ "$SERVICE_MODE" = "codex" ] || [ "$SERVICE_MODE" = "all" ]; then + codex_model=$(toml_top_string "$CODEX_CONFIG" "model") + [ -n "$codex_model" ] || codex_model="default" + codex_provider=$(toml_top_string "$CODEX_CONFIG" "model_provider") + codex_base=$(toml_top_string "$CODEX_CONFIG" "openai_base_url") + codex_source="Codex defaults" + if [ -n "$codex_provider" ]; then + provider_base=$(toml_section_string "$CODEX_CONFIG" "model_providers.$codex_provider" "base_url") + if [ -n "$provider_base" ]; then + codex_base="$provider_base" + codex_source="config.toml provider $codex_provider" + elif [ -n "$codex_base" ]; then + codex_source="config.toml openai_base_url" + fi + elif [ -n "$codex_base" ]; then + codex_source="config.toml openai_base_url" + fi + + if [ -n "${CODEX_NETWORK_ENDPOINTS-}" ]; then + add_service "codex" "Codex" "$codex_model" "custom endpoints" "CODEX_NETWORK_ENDPOINTS" + add_endpoint_list "codex" "$CODEX_NETWORK_ENDPOINTS" "Codex endpoint" + elif [ -n "$codex_base" ]; then + add_service "codex" "Codex" "$codex_model" "$codex_base" "$codex_source" + add_probe "codex" "Codex Responses API" "$(append_path "$codex_base" "responses")" "openai" "primary" + else + add_service "codex" "Codex" "$codex_model" "https://chatgpt.com + https://api.openai.com" "$codex_source" + add_probe "codex" "ChatGPT" "https://chatgpt.com/" "head" "primary" + add_probe "codex" "OpenAI API" "https://api.openai.com/v1/models" "head" "secondary" + fi + fi + + if [ "$SERVICE_MODE" = "claude" ] || [ "$SERVICE_MODE" = "all" ]; then + claude_base="${ANTHROPIC_BASE_URL-}" + claude_model="${ANTHROPIC_MODEL-}" + claude_source="environment" + if [ -z "$claude_base" ]; then + claude_base=$(json_setting "$CLAUDE_SETTINGS" "ANTHROPIC_BASE_URL") + claude_source="$CLAUDE_SETTINGS_SOURCE" + fi + if [ -z "$claude_model" ]; then + claude_model=$(json_setting "$CLAUDE_SETTINGS" "ANTHROPIC_MODEL") + fi + if [ -z "$claude_base" ]; then + claude_base="https://api.anthropic.com" + claude_source="Claude Code default" + fi + [ -n "$claude_model" ] || claude_model="default" + + if [ -n "${CLAUDE_NETWORK_ENDPOINTS-}" ]; then + add_service "claude" "Claude Code" "$claude_model" "custom endpoints" "CLAUDE_NETWORK_ENDPOINTS" + add_endpoint_list "claude" "$CLAUDE_NETWORK_ENDPOINTS" "Claude endpoint" + else + add_service "claude" "Claude Code" "$claude_model" "$claude_base" "$claude_source" + add_probe "claude" "Claude Messages API" "$(append_path "$claude_base" "messages")" "anthropic" "primary" + if [ "$claude_base" = "https://api.anthropic.com" ]; then + add_probe "claude" "Claude account auth" "https://claude.ai/" "head" "secondary" + add_probe "claude" "Claude Console auth" "https://platform.claude.com/" "head" "secondary" + fi + fi + fi +fi + +[ -s "$SERVICES_FILE" ] || die "no services selected" +[ -s "$PROBES_FILE" ] || die "no endpoints configured" + +# curl does not automatically use macOS System Settings proxies. For Codex and +# custom probes, fall back to the configured system HTTPS proxy when no proxy +# environment variable is set. Claude probes intentionally do not: Claude Code +# documents support for proxy environment variables, not the macOS setting. system_proxy_enabled=0 system_proxy_host="" system_proxy_port="" @@ -97,46 +425,52 @@ if command -v scutil >/dev/null 2>&1; then system_proxy_host=$(scutil --proxy 2>/dev/null | sed -n 's/ *HTTPSProxy : \(.*\)/\1/p' | head -n 1) system_proxy_port=$(scutil --proxy 2>/dev/null | sed -n 's/ *HTTPSPort : \(.*\)/\1/p' | head -n 1) fi -proxy_env_found=0 -for proxy_name in HTTPS_PROXY https_proxy ALL_PROXY all_proxy; do + +https_proxy_env_found=0 +all_proxy_env_found=0 +any_proxy_env_found=0 +claude_http_proxy="" +proxy_scheme="" +for proxy_name in HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy; do eval "proxy_value=\${$proxy_name-}" - [ -n "$proxy_value" ] && proxy_env_found=1 + if [ -n "$proxy_value" ]; then + any_proxy_env_found=1 + case "$proxy_name" in + HTTPS_PROXY|https_proxy) https_proxy_env_found=1 ;; + HTTP_PROXY|http_proxy) [ -n "$claude_http_proxy" ] || claude_http_proxy="$proxy_value" ;; + ALL_PROXY|all_proxy) all_proxy_env_found=1 ;; + esac + [ -n "$proxy_scheme" ] || proxy_scheme=$(printf '%s' "$proxy_value" | sed -n -E 's#^([a-zA-Z0-9+.-]+)://.*#\1#p') + fi done + system_curl_proxy="" -if [ "$proxy_env_found" -eq 0 ] && [ "$system_proxy_enabled" = "1" ] && [ -n "$system_proxy_host" ] && [ -n "$system_proxy_port" ]; then +if [ "$https_proxy_env_found" -eq 0 ] && [ "$all_proxy_env_found" -eq 0 ] && [ "$system_proxy_enabled" = "1" ] && [ -n "$system_proxy_host" ] && [ -n "$system_proxy_port" ]; then system_curl_proxy="http://$system_proxy_host:$system_proxy_port" fi -curl_with_proxy() { - if [ -n "$system_curl_proxy" ]; then - command curl --proxy "$system_curl_proxy" "$@" - else - command curl "$@" +if awk -F '\t' '$1 == "claude" { found=1 } END { exit !found }' "$SERVICES_FILE"; then + case "$proxy_scheme" in + socks|socks4|socks4a|socks5|socks5h) + printf '%s\n' "Claude Code does not support SOCKS proxies; configure an HTTP/HTTPS proxy or gateway." >> "$WARNINGS_FILE" + ;; + esac + if [ "$all_proxy_env_found" -eq 1 ] && [ "$https_proxy_env_found" -eq 0 ] && [ -z "$claude_http_proxy" ]; then + printf '%s\n' "Claude Code does not document ALL_PROXY; Claude probes ignore it unless HTTPS_PROXY or HTTP_PROXY is also configured." >> "$WARNINGS_FILE" + fi + if [ -n "$system_curl_proxy" ] && [ "$any_proxy_env_found" -eq 0 ]; then + printf '%s\n' "A macOS system proxy is configured, but Claude Code expects HTTPS_PROXY/HTTP_PROXY; Claude probes use the direct path." >> "$WARNINGS_FILE" fi -} - -if [ "$CLI_ENDPOINTS_SET" -eq 0 ] && [ -n "${CODEX_NETWORK_ENDPOINTS-}" ]; then - ENDPOINTS="$CODEX_NETWORK_ENDPOINTS" fi -WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/ipcheck.XXXXXX") || die "cannot create a temporary directory" -trap 'rm -rf "$WORK_DIR"' EXIT INT TERM - -json_escape() { - printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' -} - redact_proxy() { - printf '%s' "$1" | sed -E 's#://[^/@]+@#://***@#' -} - -redact_endpoint() { - printf '%s' "$1" | sed -E 's#://[^/@]+@#://***@#; s#\?.*$#?#; s/#.*$//' + printf '%s' "$1" | sed -E 's#://[^/]*@#://***@#; s#\?.*$#?#; s/#.*$//' } proxy_summary() { - local name value found=0 - for name in HTTPS_PROXY https_proxy ALL_PROXY all_proxy; do + local name value found route_interface + found=0 + for name in HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy; do eval "value=\${$name-}" if [ -n "$value" ]; then [ "$found" -eq 0 ] || printf ', ' @@ -144,9 +478,10 @@ proxy_summary() { found=1 fi done - if [ "$found" -eq 0 ] && [ "${system_proxy_enabled-0}" = "1" ] && [ -n "${system_proxy_host-}" ]; then + if [ -n "$system_curl_proxy" ] && [ "$system_proxy_enabled" = "1" ] && [ -n "$system_proxy_host" ]; then + [ "$found" -eq 0 ] || printf ', ' printf 'macOS HTTPS proxy=%s' "$system_proxy_host" - [ -n "${system_proxy_port-}" ] && printf ':%s' "$system_proxy_port" + [ -n "$system_proxy_port" ] && printf ':%s' "$system_proxy_port" found=1 fi [ "$found" -eq 1 ] || printf 'none detected' @@ -158,6 +493,48 @@ proxy_summary() { fi } +curl_for_service() { + local service + service="$1" + shift + if [ "$service" = "claude" ]; then + if [ "$https_proxy_env_found" -eq 0 ] && [ -n "$claude_http_proxy" ]; then + HTTPS_PROXY="$claude_http_proxy" https_proxy="$claude_http_proxy" \ + HTTP_PROXY="$claude_http_proxy" http_proxy="$claude_http_proxy" \ + ALL_PROXY='' all_proxy='' command curl -q "$@" + else + ALL_PROXY='' all_proxy='' command curl -q "$@" + fi + elif [ -n "$system_curl_proxy" ] && [ "$service" != "claude" ]; then + command curl -q --proxy "$system_curl_proxy" "$@" + else + command curl -q "$@" + fi +} + +run_probe() { + local service probe_type endpoint + service="$1" + probe_type="$2" + endpoint="$3" + shift 3 + case "$probe_type" in + anthropic) + curl_for_service "$service" "$@" -X POST \ + -H 'content-type: application/json' \ + -H 'anthropic-version: 2023-06-01' \ + --data '{}' --url "$endpoint" + ;; + openai) + curl_for_service "$service" "$@" -X POST \ + -H 'content-type: application/json' \ + --data '{}' --url "$endpoint" + ;; + head) curl_for_service "$service" "$@" --head --url "$endpoint" ;; + *) curl_for_service "$service" "$@" --range 0-0 --url "$endpoint" ;; + esac +} + median_file() { awk 'NF { print $1 }' "$1" | sort -n | awk ' { values[NR] = $1 } @@ -192,69 +569,60 @@ format_bits_mbps() { } endpoint_count=0 -old_ifs="$IFS" -IFS='|' -for endpoint in $ENDPOINTS; do +TAB=$(printf '\t') +while IFS="$TAB" read -r service label endpoint probe_type priority; do [ -n "$endpoint" ] || continue endpoint_count=$((endpoint_count + 1)) + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$endpoint_count" "$service" "$label" "$endpoint" "$probe_type" "$priority" >> "$INDEXED_PROBES_FILE" sample_file="$WORK_DIR/endpoint_${endpoint_count}.tsv" : > "$sample_file" sample=1 while [ "$sample" -le "$SAMPLES" ]; do - metrics=$(curl_with_proxy -L --http1.1 -sS -o /dev/null \ + metrics=$(run_probe "$service" "$probe_type" "$endpoint" \ + --proto '=http,https' --http1.1 -sS -o /dev/null \ + -A "ipcheck/$VERSION" \ --connect-timeout "$TIMEOUT" --max-time "$TIMEOUT" \ -w '%{http_code}\t%{time_namelookup}\t%{time_connect}\t%{time_appconnect}\t%{time_starttransfer}\t%{time_total}\t%{size_download}\t%{speed_download}' \ - "$endpoint" 2>/dev/null) + 2>/dev/null) curl_exit=$? + http_code=000 + dns=0 + connect=0 + tls=0 + first_byte=0 + total=0 + bytes=0 + speed=0 if [ "$curl_exit" -eq 0 ]; then - http_code=000 - dns=0 - connect=0 - tls=0 - first_byte=0 - total=0 - bytes=0 - speed=0 - IFS=' ' read -r http_code dns connect tls first_byte total bytes speed <> "$sample_file" sample=$((sample + 1)) done -done -IFS="$old_ifs" +done < "$PROBES_FILE" [ "$endpoint_count" -gt 0 ] || die "no endpoints configured" bandwidth_speed=0 bandwidth_code=000 bandwidth_valid=0 +bandwidth_bytes=0 if [ "$RUN_BANDWIDTH" -eq 1 ]; then - bandwidth_metrics=$(curl_with_proxy -L --http1.1 -sS -o /dev/null \ + bandwidth_metrics=$(curl_for_service "custom" --proto '=https' --http1.1 -sS -o /dev/null \ + -A "ipcheck/$VERSION" \ --connect-timeout "$TIMEOUT" --max-time "$TIMEOUT" \ -w '%{http_code}\t%{size_download}\t%{speed_download}' \ - "$BANDWIDTH_URL" 2>/dev/null) + --url "$BANDWIDTH_URL" 2>/dev/null) bandwidth_exit=$? if [ "$bandwidth_exit" -eq 0 ]; then - bandwidth_code=000 - bandwidth_bytes=0 - bandwidth_speed=0 - IFS=' ' read -r bandwidth_code bandwidth_bytes bandwidth_speed < "$endpoint_json" - -index=1 -IFS='|' -for endpoint in $ENDPOINTS; do - [ -n "$endpoint" ] || continue +while IFS="$TAB" read -r index service label endpoint probe_type priority; do sample_file="$WORK_DIR/endpoint_${index}.tsv" ok_count=$(awk '$1 == 0 && $2 != "000" && $2 != "407" { count++ } END { print count + 0 }' "$sample_file") success_rate=$(awk -v ok="$ok_count" -v total="$SAMPLES" 'BEGIN { printf "%.0f", ok * 100 / total }') [ "$ok_count" -gt 0 ] && reachable_count=$((reachable_count + 1)) - ttfb_median=$(awk '{ if ($1 == 0 && $2 != "000" && $2 != "407") print $6 * 1000 }' "$sample_file" > "$WORK_DIR/ttfb_${index}"; median_file "$WORK_DIR/ttfb_${index}") - ttfb_p95=$(awk '{ if ($1 == 0 && $2 != "000" && $2 != "407") print $6 * 1000 }' "$sample_file" > "$WORK_DIR/ttfb_p95_${index}"; p95_file "$WORK_DIR/ttfb_p95_${index}") - jitter=$(awk -v median="$ttfb_median" 'BEGIN { total = 0; count = 0 } { if ($1 == 0 && $2 != "000" && $2 != "407") { total += ($6 * 1000 - median) * ($6 * 1000 - median); count++ } } END { if (count == 0) print 0; else print sqrt(total / count) }' "$sample_file") + awk '$1 == 0 && $2 != "000" && $2 != "407" { print $6 * 1000 }' "$sample_file" > "$WORK_DIR/ttfb_${index}" + ttfb_median=$(median_file "$WORK_DIR/ttfb_${index}") + ttfb_p95=$(p95_file "$WORK_DIR/ttfb_${index}") + jitter=$(awk -v median="$ttfb_median" ' + BEGIN { total = 0; count = 0 } + $1 == 0 && $2 != "000" && $2 != "407" { + total += ($6 * 1000 - median) * ($6 * 1000 - median) + count++ + } + END { if (count == 0) print 0; else print sqrt(total / count) } + ' "$sample_file") observed_http=$(awk '$2 != "000" { print $2; exit }' "$sample_file") [ -n "$observed_http" ] || observed_http="000" - if [ "$index" -eq 1 ]; then - primary_ttfb="$ttfb_median" - primary_jitter="$jitter" - primary_ok_count="$ok_count" - primary_success_rate="$success_rate" - primary_http="$observed_http" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$index" "$service" "$label" "$(redact_url "$endpoint")" "$priority" "$ok_count" "$success_rate" "$ttfb_median" "$ttfb_p95" "$jitter" >> "$ENDPOINT_SUMMARY_FILE" + escaped_endpoint=$(json_escape "$(redact_url "$endpoint")") + escaped_label=$(json_escape "$label") + printf '{"service":"%s","label":"%s","url":"%s","primary":%s,"reachable":%s,"http_code":"%s","samples":%s,"successful_samples":%s,"success_rate_pct":%s,"ttfb_median_ms":%s,"ttfb_p95_ms":%s,"jitter_ms":%s}\n' \ + "$service" "$escaped_label" "$escaped_endpoint" "$([ "$priority" = "primary" ] && printf true || printf false)" \ + "$([ "$ok_count" -gt 0 ] && printf true || printf false)" "$observed_http" "$SAMPLES" "$ok_count" "$success_rate" "$ttfb_median" "$ttfb_p95" "$jitter" >> "$ENDPOINT_JSON_FILE" +done < "$INDEXED_PROBES_FILE" + +service_count=0 +good_services=0 +fair_services=0 +poor_services=0 +blocked_services=0 +overall_rank=0 +while IFS="$TAB" read -r service display model base source; do + service_count=$((service_count + 1)) + primary_index=$(awk -F '\t' -v service="$service" '$2 == service && $5 == "primary" { print $1; exit }' "$ENDPOINT_SUMMARY_FILE") + [ -n "$primary_index" ] || continue + primary_sample_file="$WORK_DIR/endpoint_${primary_index}.tsv" + service_ok=$(awk '$1 == 0 && $2 != "000" && $2 != "407" { count++ } END { print count + 0 }' "$primary_sample_file") + service_rate=$(awk -v ok="$service_ok" -v total="$SAMPLES" 'BEGIN { printf "%.0f", ok * 100 / total }') + service_has_407=$(awk '$2 == "407" { found=1 } END { print found + 0 }' "$primary_sample_file") + service_has_404=$(awk '$2 == "404" { found=1 } END { print found + 0 }' "$primary_sample_file") + service_has_429=$(awk '$2 == "429" { found=1 } END { print found + 0 }' "$primary_sample_file") + service_has_5xx=$(awk '$2 ~ /^5[0-9][0-9]$/ { found=1 } END { print found + 0 }' "$primary_sample_file") + service_ttfb=$(awk -F '\t' -v idx="$primary_index" '$1 == idx { print $8; exit }' "$ENDPOINT_SUMMARY_FILE") + service_jitter=$(awk -F '\t' -v idx="$primary_index" '$1 == idx { print $10; exit }' "$ENDPOINT_SUMMARY_FILE") + service_result="blocked" + service_reason="No HTTP response reached the primary service endpoint." + service_rank=3 + if [ "$service_ok" -eq 0 ]; then + if [ "$service_has_407" -eq 1 ]; then + service_reason="The proxy requires authentication (HTTP 407), so the service endpoint was not reached." + fi + else + if [ "$service_has_404" -eq 1 ]; then + service_result="poor" + service_reason="The host is reachable, but the configured API route returned HTTP 404 in at least one sample. Check the base URL." + service_rank=2 + elif [ $((service_ok * 100)) -lt $((SAMPLES * 60)) ]; then + service_result="poor" + service_reason="The primary service path is intermittent or unavailable for most samples." + service_rank=2 + elif [ "$service_has_429" -eq 1 ]; then + service_result="fair" + service_reason="The API route is reachable, but at least one sample was rate limited (HTTP 429)." + service_rank=1 + elif [ "$service_has_5xx" -eq 1 ]; then + service_result="fair" + service_reason="The API route is reachable, but at least one sample returned a server error." + service_rank=1 + elif [ "$service_has_407" -eq 1 ] || [ "$service_ok" -lt "$SAMPLES" ]; then + service_result="fair" + service_reason="The service path is reachable, but one or more sampled requests failed." + service_rank=1 + elif awk -v ttfb="$service_ttfb" -v jitter="$service_jitter" 'BEGIN { exit !(ttfb > 0 && ttfb < 800 && jitter < 1000) }'; then + service_result="good" + service_reason="The service path is reachable with acceptable first-byte latency and jitter." + service_rank=0 + elif awk -v ttfb="$service_ttfb" 'BEGIN { exit !(ttfb > 0 && ttfb < 3000) }'; then + service_result="fair" + service_reason="The service path is usable, but interactive responses may feel delayed." + service_rank=1 + else + service_result="poor" + service_reason="The service path is reachable but has high latency or unstable responses." + service_rank=2 + fi fi - escaped_endpoint=$(json_escape "$(redact_endpoint "$endpoint")") - printf '{"url":"%s","reachable":%s,"http_code":"%s","samples":%s,"successful_samples":%s,"success_rate_pct":%s,"ttfb_median_ms":%s,"ttfb_p95_ms":%s,"jitter_ms":%s}\n' \ - "$escaped_endpoint" "$([ "$ok_count" -gt 0 ] && printf true || printf false)" "$observed_http" "$SAMPLES" "$ok_count" "$success_rate" "$ttfb_median" "$ttfb_p95" "$jitter" >> "$endpoint_json" - index=$((index + 1)) -done -IFS="$old_ifs" + case "$service_result" in + good) good_services=$((good_services + 1)) ;; + fair) fair_services=$((fair_services + 1)) ;; + poor) poor_services=$((poor_services + 1)) ;; + blocked) blocked_services=$((blocked_services + 1)) ;; + esac + [ "$service_rank" -gt "$overall_rank" ] && overall_rank="$service_rank" + if [ "$service_count" -eq 1 ]; then + primary_ttfb="$service_ttfb" + primary_jitter="$service_jitter" + primary_success_rate="$service_rate" + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$service" "$service_result" "$service_reason" "$service_rate" "$service_ttfb" "$service_jitter" >> "$SERVICE_RESULT_FILE" +done < "$SERVICES_FILE" -overall="blocked" -overall_reason="No tested endpoint returned an HTTP response." -if [ "$primary_http" = "407" ]; then - overall_reason="The proxy requires authentication (HTTP 407), so the service endpoint was not reached." +if [ "$blocked_services" -eq "$service_count" ]; then + overall="blocked" +elif [ "$blocked_services" -gt 0 ]; then + overall="poor" +else + case "$overall_rank" in + 0) overall="good" ;; + 1) overall="fair" ;; + *) overall="poor" ;; + esac fi -if [ "$reachable_count" -gt 0 ]; then - if [ "$primary_ok_count" -eq 0 ] || [ $((primary_ok_count * 100)) -lt $((SAMPLES * 60)) ]; then - overall="poor" - overall_reason="The primary service path is intermittent or unavailable for most samples." - elif [ "$primary_ok_count" -lt "$SAMPLES" ]; then - overall="fair" - overall_reason="The proxy path is reachable, but one or more sampled requests failed." - elif awk -v ttfb="$primary_ttfb" -v jitter="$primary_jitter" 'BEGIN { exit !(ttfb > 0 && ttfb < 800 && jitter < 1000) }'; then - overall="good" - overall_reason="The proxy path is reachable with acceptable first-byte latency and jitter." - elif awk -v ttfb="$primary_ttfb" 'BEGIN { exit !(ttfb > 0 && ttfb < 3000) }'; then - overall="fair" - overall_reason="The proxy path is usable, but interactive Codex responses may feel delayed." - else - overall="poor" - overall_reason="The proxy path is reachable but has high latency or unstable responses." - fi +if [ "$service_count" -eq 1 ]; then + overall_reason=$(awk -F '\t' 'NR == 1 { print $3 }' "$SERVICE_RESULT_FILE") +else + overall_reason="$good_services good, $fair_services fair, $poor_services poor, $blocked_services blocked service path(s)." fi proxy_text=$(proxy_summary) timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ') +service_display() { + awk -F '\t' -v service="$1" '$1 == service { print $2; exit }' "$SERVICES_FILE" +} + render_human() { cat < "$WORK_DIR/h_ttfb_${index}"; median_file "$WORK_DIR/h_ttfb_${index}") - ttfb_p95=$(awk '{ if ($1 == 0 && $2 != "000" && $2 != "407") print $6 * 1000 }' "$sample_file" > "$WORK_DIR/h_ttfb_p95_${index}"; p95_file "$WORK_DIR/h_ttfb_p95_${index}") - jitter=$(awk -v median="$ttfb_median" 'BEGIN { total = 0; count = 0 } { if ($1 == 0 && $2 != "000" && $2 != "407") { total += ($6 * 1000 - median) * ($6 * 1000 - median); count++ } } END { if (count == 0) print 0; else print sqrt(total / count) }' "$sample_file") - ok_count=$(awk '$1 == 0 && $2 != "000" && $2 != "407" { count++ } END { print count + 0 }' "$sample_file") + summary=$(awk -F '\t' -v idx="$index" '$1 == idx { print $6 "\t" $8 "\t" $9 "\t" $10; exit }' "$ENDPOINT_SUMMARY_FILE") + IFS="$TAB" read -r ok_count ttfb_median ttfb_p95 jitter < ipcheck does not read or transmit API keys and does not make billable model requests.\n' } render_json() { - endpoints_json=$(awk 'NR > 1 { printf "," } { printf "%s", $0 }' "$endpoint_json") - printf '{"version":"%s","timestamp":"%s","proxy":"%s","result":"%s","reason":"%s","reachable_endpoints":%s,"primary_success_rate_pct":%s,"primary_ttfb_median_ms":%s,"primary_jitter_ms":%s,"bandwidth":{"enabled":%s,"available":%s,"http_code":"%s","bytes":%s,"bytes_per_second":%s},"system":{"enabled":%s,"interface":"%s","base_rtt_ms":%s,"download_bits_per_second":%s,"proxy_state":"%s"},"endpoints":[%s]}\n' \ - "$VERSION" "$timestamp" "$(json_escape "$proxy_text")" "$overall" "$(json_escape "$overall_reason")" \ - "$reachable_count" "$primary_success_rate" "$primary_ttfb" "$primary_jitter" \ + endpoints_json=$(awk 'NR > 1 { printf "," } { printf "%s", $0 }' "$ENDPOINT_JSON_FILE") + services_json="" + separator="" + while IFS="$TAB" read -r service display model base source; do + result=$(awk -F '\t' -v service="$service" '$1 == service { print $2; exit }' "$SERVICE_RESULT_FILE") + reason=$(awk -F '\t' -v service="$service" '$1 == service { print $3; exit }' "$SERVICE_RESULT_FILE") + success_rate=$(awk -F '\t' -v service="$service" '$1 == service { print $4; exit }' "$SERVICE_RESULT_FILE") + ttfb=$(awk -F '\t' -v service="$service" '$1 == service { print $5; exit }' "$SERVICE_RESULT_FILE") + jitter=$(awk -F '\t' -v service="$service" '$1 == service { print $6; exit }' "$SERVICE_RESULT_FILE") + service_json=$(printf '{"id":"%s","name":"%s","model":"%s","route":"%s","config_source":"%s","result":"%s","reason":"%s","primary_success_rate_pct":%s,"primary_ttfb_median_ms":%s,"primary_jitter_ms":%s}' \ + "$(json_escape "$service")" "$(json_escape "$display")" "$(json_escape "$model")" "$(json_escape "$base")" "$(json_escape "$source")" \ + "$(json_escape "$result")" "$(json_escape "$reason")" "$success_rate" "$ttfb" "$jitter") + services_json="$services_json$separator$service_json" + separator="," + done < "$SERVICES_FILE" + warnings_json="" + separator="" + while IFS= read -r warning; do + [ -n "$warning" ] || continue + warnings_json="$warnings_json$separator\"$(json_escape "$warning")\"" + separator="," + done < "$WARNINGS_FILE" + printf '{"schema_version":%s,"version":"%s","timestamp":"%s","proxy":"%s","result":"%s","reason":"%s","reachable_endpoints":%s,"primary_success_rate_pct":%s,"primary_ttfb_median_ms":%s,"primary_jitter_ms":%s,"privacy":{"credentials_used":false,"billable_requests":false},"warnings":[%s],"bandwidth":{"enabled":%s,"available":%s,"http_code":"%s","bytes":%s,"bytes_per_second":%s},"system":{"enabled":%s,"interface":"%s","base_rtt_ms":%s,"download_bits_per_second":%s,"proxy_state":"%s"},"services":[%s],"endpoints":[%s]}\n' \ + "$SCHEMA_VERSION" "$VERSION" "$timestamp" "$(json_escape "$proxy_text")" "$overall" "$(json_escape "$overall_reason")" \ + "$reachable_count" "$primary_success_rate" "$primary_ttfb" "$primary_jitter" "$warnings_json" \ "$([ "$RUN_BANDWIDTH" -eq 1 ] && printf true || printf false)" "$([ "$bandwidth_valid" -eq 1 ] && printf true || printf false)" "$bandwidth_code" "$bandwidth_bytes" "$bandwidth_speed" \ "$([ -n "$system_throughput" ] && printf true || printf false)" "$(json_escape "$system_interface")" "${system_rtt:-0}" "${system_throughput:-0}" "$(json_escape "$system_proxy_state")" \ - "$endpoints_json" + "$services_json" "$endpoints_json" } case "$MODE" in diff --git a/package.json b/package.json index 42e3b73..58a3e24 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "@jacklv-coder/ipcheck", - "version": "0.2.0", - "description": "Diagnose the proxy and network path used by Codex and OpenAI endpoints.", + "private": true, + "version": "0.3.0", + "description": "Diagnose proxy, latency, and API routes used by Codex and Claude Code.", "license": "Apache-2.0", "author": "Jintao", "homepage": "https://github.com/jacklv-coder/ipcheck#readme", @@ -18,6 +19,7 @@ "files": [ "bin/", "LICENSE", + "CHANGELOG.md", "README.md", "README.zh-CN.md" ], @@ -27,7 +29,11 @@ }, "keywords": [ "codex", + "claude-code", + "anthropic", "openai", + "dashscope", + "deepseek", "network", "proxy", "latency", @@ -40,8 +46,5 @@ ], "engines": { "node": ">=18" - }, - "publishConfig": { - "access": "public" } } diff --git a/test/smoke.sh b/test/smoke.sh index 9513e01..f22d4b3 100755 --- a/test/smoke.sh +++ b/test/smoke.sh @@ -2,27 +2,51 @@ set -eu -PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +PROJECT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) STUB_DIR=$(mktemp -d "${TMPDIR:-/tmp}/ipcheck-test.XXXXXX") +FIXTURE_HOME="$STUB_DIR/home" +CODEX_FIXTURE="$FIXTURE_HOME/.codex" +CLAUDE_FIXTURE="$FIXTURE_HOME/.claude" +CURL_LOG="$STUB_DIR/curl.log" trap 'rm -rf "$STUB_DIR"' EXIT INT TERM +mkdir -p "$CODEX_FIXTURE" "$CLAUDE_FIXTURE" + cat > "$STUB_DIR/curl" <<'EOF' #!/usr/bin/env bash is_bandwidth=0 is_blocked=0 is_flaky=0 is_proxy_auth=0 +is_not_found=0 +is_rate_limited=0 +is_server_error=0 +is_mixed_status=0 +is_anthropic=0 +[ "${1-}" = "-q" ] || { + printf 'curl was not invoked with -q first\n' >&2 + exit 64 +} +printf 'env:HTTPS_PROXY=%s\n' "${HTTPS_PROXY-}" >> "${IPCHECK_TEST_CURL_LOG:?}" +printf 'env:http_proxy=%s\n' "${http_proxy-}" >> "${IPCHECK_TEST_CURL_LOG:?}" +printf 'env:ALL_PROXY=%s\n' "${ALL_PROXY-}" >> "${IPCHECK_TEST_CURL_LOG:?}" for argument in "$@"; do + printf '%s\n' "$argument" >> "${IPCHECK_TEST_CURL_LOG:?}" case "$argument" in *speed.cloudflare.com*) is_bandwidth=1 ;; *blocked.invalid*) is_blocked=1 ;; *flaky.invalid*) is_flaky=1 ;; *proxy-auth.invalid*) is_proxy_auth=1 ;; + *not-found.invalid*) is_not_found=1 ;; + *rate-limit.invalid*) is_rate_limited=1 ;; + *server-error.invalid*) is_server_error=1 ;; + *mixed-status.invalid*) is_mixed_status=1 ;; + *anthropic*|*dashscope*|*/v1/messages*) is_anthropic=1 ;; esac done if [ "$is_flaky" -eq 1 ]; then attempt_file="${IPCHECK_TEST_ATTEMPT_FILE:?}" - attempt=$(($(cat "$attempt_file" 2>/dev/null || printf 0) + 1)) + attempt=$(($(sed -n '1p' "$attempt_file" 2>/dev/null || printf 0) + 1)) printf '%s' "$attempt" > "$attempt_file" if [ "$attempt" -gt 1 ]; then exit 28 @@ -35,6 +59,24 @@ elif [ "$is_blocked" -eq 1 ]; then elif [ "$is_bandwidth" -eq 1 ]; then bandwidth_code=${IPCHECK_TEST_BANDWIDTH_CODE:-200} printf '%s\t2000000\t10000000' "$bandwidth_code" +elif [ "$is_not_found" -eq 1 ]; then + printf '404\t0.001\t0.002\t0.003\t0.050\t0.050\t100\t1000' +elif [ "$is_rate_limited" -eq 1 ]; then + printf '429\t0.001\t0.002\t0.003\t0.050\t0.050\t100\t1000' +elif [ "$is_server_error" -eq 1 ]; then + printf '503\t0.001\t0.002\t0.003\t0.050\t0.050\t100\t1000' +elif [ "$is_mixed_status" -eq 1 ]; then + attempt_file="${IPCHECK_TEST_MIXED_FILE:?}" + attempt=$(($(sed -n '1p' "$attempt_file" 2>/dev/null || printf 0) + 1)) + printf '%s' "$attempt" > "$attempt_file" + if [ "$attempt" -eq 1 ]; then + printf '401\t0.001\t0.002\t0.003\t0.050\t0.050\t100\t1000' + else + printf '503\t0.001\t0.002\t0.003\t0.050\t0.050\t100\t1000' + fi +elif [ "$is_anthropic" -eq 1 ]; then + first_byte=${IPCHECK_TEST_TTFB:-0.100} + printf '403\t0.001\t0.002\t0.003\t%s\t%s\t151\t1000' "$first_byte" "$first_byte" else first_byte=${IPCHECK_TEST_TTFB:-0.100} printf '401\t0.001\t0.002\t0.003\t%s\t%s\t151\t1000' "$first_byte" "$first_byte" @@ -42,6 +84,13 @@ fi EOF chmod +x "$STUB_DIR/curl" +MINIMAL_BIN="$STUB_DIR/minimal-bin" +mkdir -p "$MINIMAL_BIN" +for utility in bash awk sed sort mktemp rm tr date head; do + ln -s "$(command -v "$utility")" "$MINIMAL_BIN/$utility" +done +ln -s "$STUB_DIR/curl" "$MINIMAL_BIN/curl" + cat > "$STUB_DIR/networkQuality" <<'EOF' #!/usr/bin/env bash cat <<'JSON' @@ -67,36 +116,160 @@ PROXY EOF chmod +x "$STUB_DIR/scutil" +cat > "$CODEX_FIXTURE/config.toml" <<'EOF' +model = "gpt-test" +EOF + +cat > "$CLAUDE_FIXTURE/settings.json" <<'EOF' +{ + "env": { + "ANTHROPIC_AUTH_TOKEN": "fixture-secret-must-never-appear", + "ANTHROPIC_BASE_URL": "https://dashscope.aliyuncs.com/apps/anthropic", + "ANTHROPIC_MODEL": "deepseek-v4-flash" + } +} +EOF + +run_ipcheck() { + env \ + -u ANTHROPIC_BASE_URL -u ANTHROPIC_MODEL \ + -u IPCHECK_SERVICES -u IPCHECK_ENDPOINTS \ + -u CODEX_NETWORK_ENDPOINTS -u CLAUDE_NETWORK_ENDPOINTS \ + -u HTTP_PROXY -u http_proxy -u ALL_PROXY -u all_proxy \ + PATH="$STUB_DIR:$PATH" HOME="$FIXTURE_HOME" CODEX_HOME="$CODEX_FIXTURE" CLAUDE_CONFIG_DIR="$CLAUDE_FIXTURE" \ + HTTPS_PROXY="http://127.0.0.1:1080" IPCHECK_TEST_CURL_LOG="$CURL_LOG" \ + "$PROJECT_DIR/bin/ipcheck" "$@" +} + +run_ipcheck_direct() { + env \ + -u ANTHROPIC_BASE_URL -u ANTHROPIC_MODEL \ + -u IPCHECK_SERVICES -u IPCHECK_ENDPOINTS \ + -u CODEX_NETWORK_ENDPOINTS -u CLAUDE_NETWORK_ENDPOINTS \ + -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy -u ALL_PROXY -u all_proxy \ + PATH="$STUB_DIR:$PATH" HOME="$FIXTURE_HOME" CODEX_HOME="$CODEX_FIXTURE" CLAUDE_CONFIG_DIR="$CLAUDE_FIXTURE" \ + IPCHECK_TEST_CURL_LOG="$CURL_LOG" \ + "$PROJECT_DIR/bin/ipcheck" "$@" +} + bash -n "$PROJECT_DIR/bin/ipcheck" -"$PROJECT_DIR/bin/ipcheck" --help | grep -q '^ipcheck - diagnose' +"$PROJECT_DIR/bin/ipcheck" --help | grep -q '^ipcheck - diagnose Codex and Claude Code' +[ "$("$PROJECT_DIR/bin/ipcheck" --version)" = "ipcheck 0.3.0" ] -report=$(PATH="$STUB_DIR:$PATH" HTTPS_PROXY="http://127.0.0.1:1080" \ - "$PROJECT_DIR/bin/ipcheck" --samples 3 --no-bandwidth --json) +: > "$CURL_LOG" +report=$(ANTHROPIC_AUTH_TOKEN="runtime-secret-must-never-appear" run_ipcheck --samples 3 --no-bandwidth --json) +printf '%s\n' "$report" | grep -q '"schema_version":1' printf '%s\n' "$report" | grep -q '"result":"good"' -printf '%s\n' "$report" | grep -q '"reachable_endpoints":2' -printf '%s\n' "$report" | grep -q '"http_code":"401"' +printf '%s\n' "$report" | grep -q '"reachable_endpoints":3' +printf '%s\n' "$report" | grep -q '"id":"codex"' +printf '%s\n' "$report" | grep -q '"model":"gpt-test"' +printf '%s\n' "$report" | grep -q '"id":"claude"' +printf '%s\n' "$report" | grep -q '"model":"deepseek-v4-flash"' +printf '%s\n' "$report" | grep -q 'https://dashscope.aliyuncs.com/apps/anthropic' +printf '%s\n' "$report" | grep -q 'https://dashscope.aliyuncs.com/apps/anthropic/v1/messages' +printf '%s\n' "$report" | grep -q '"credentials_used":false' +printf '%s\n' "$report" | grep -q '"billable_requests":false' +if printf '%s\n' "$report" | grep -Eq 'fixture-secret|runtime-secret'; then + printf 'Claude credential leaked into JSON report\n' >&2 + exit 1 +fi +grep -q '^anthropic-version: 2023-06-01$' "$CURL_LOG" +grep -q '^{}$' "$CURL_LOG" +if grep -Eqi 'authorization|x-api-key|fixture-secret|runtime-secret' "$CURL_LOG"; then + printf 'Claude credential was passed to curl\n' >&2 + exit 1 +fi + +if command -v python3 >/dev/null 2>&1; then + REPORT_JSON="$report" python3 -c 'import json, os; json.loads(os.environ["REPORT_JSON"])' +fi + +AUTO_HOME="$STUB_DIR/auto-home" +mkdir -p "$AUTO_HOME/codex" "$AUTO_HOME/claude" +: > "$CURL_LOG" +auto_codex_override=$(env \ + -u ANTHROPIC_BASE_URL -u ANTHROPIC_MODEL -u IPCHECK_SERVICES -u IPCHECK_ENDPOINTS \ + -u CLAUDE_NETWORK_ENDPOINTS -u HTTP_PROXY -u http_proxy -u ALL_PROXY -u all_proxy \ + PATH="$MINIMAL_BIN" HOME="$AUTO_HOME" CODEX_HOME="$AUTO_HOME/codex" CLAUDE_CONFIG_DIR="$AUTO_HOME/claude" \ + CODEX_NETWORK_ENDPOINTS="https://codex-override.invalid" HTTPS_PROXY="http://127.0.0.1:1080" \ + IPCHECK_TEST_CURL_LOG="$CURL_LOG" "$PROJECT_DIR/bin/ipcheck" --samples 1 --no-bandwidth --json) +printf '%s\n' "$auto_codex_override" | grep -q '"id":"codex"' +if printf '%s\n' "$auto_codex_override" | grep -q '"id":"claude"'; then + printf 'Codex endpoint override did not participate in auto-detection\n' >&2 + exit 1 +fi + +: > "$CURL_LOG" +auto_claude_override=$(env \ + -u ANTHROPIC_BASE_URL -u ANTHROPIC_MODEL -u IPCHECK_SERVICES -u IPCHECK_ENDPOINTS \ + -u CODEX_NETWORK_ENDPOINTS -u HTTP_PROXY -u http_proxy -u ALL_PROXY -u all_proxy \ + PATH="$MINIMAL_BIN" HOME="$AUTO_HOME" CODEX_HOME="$AUTO_HOME/codex" CLAUDE_CONFIG_DIR="$AUTO_HOME/claude" \ + CLAUDE_NETWORK_ENDPOINTS="https://claude-override.invalid/v1/messages" HTTPS_PROXY="http://127.0.0.1:1080" \ + IPCHECK_TEST_CURL_LOG="$CURL_LOG" "$PROJECT_DIR/bin/ipcheck" --samples 1 --no-bandwidth --json) +printf '%s\n' "$auto_claude_override" | grep -q '"id":"claude"' +if printf '%s\n' "$auto_claude_override" | grep -q '"id":"codex"'; then + printf 'Claude endpoint override did not participate in auto-detection\n' >&2 + exit 1 +fi + +claude_report=$(run_ipcheck claude --samples 1 --no-bandwidth --json) +printf '%s\n' "$claude_report" | grep -q '"id":"claude"' +if printf '%s\n' "$claude_report" | grep -q '"id":"codex"'; then + printf 'Claude-only mode included Codex\n' >&2 + exit 1 +fi + +env_precedence_report=$(ANTHROPIC_BASE_URL="https://gateway.example.com/anthropic" ANTHROPIC_MODEL="gateway-model" \ + env -u HTTP_PROXY -u http_proxy -u ALL_PROXY -u all_proxy \ + PATH="$STUB_DIR:$PATH" HOME="$FIXTURE_HOME" CODEX_HOME="$CODEX_FIXTURE" CLAUDE_CONFIG_DIR="$CLAUDE_FIXTURE" \ + HTTPS_PROXY="http://127.0.0.1:1080" IPCHECK_TEST_CURL_LOG="$CURL_LOG" \ + "$PROJECT_DIR/bin/ipcheck" claude --samples 1 --no-bandwidth --json) +printf '%s\n' "$env_precedence_report" | grep -q '"model":"gateway-model"' +printf '%s\n' "$env_precedence_report" | grep -q 'https://gateway.example.com/anthropic/v1/messages' + +cat > "$CODEX_FIXTURE/config.toml" <<'EOF' +model = 'proxy-model' +model_provider = "company" + +[model_providers.company] +base_url = 'https://codex-gateway.example.com/v1' +env_key = "SECRET_THAT_IPCHECK_MUST_NOT_READ" +EOF +codex_provider_report=$(run_ipcheck codex --samples 1 --no-bandwidth --json) +printf '%s\n' "$codex_provider_report" | grep -q '"model":"proxy-model"' +printf '%s\n' "$codex_provider_report" | grep -q 'https://codex-gateway.example.com/v1/responses' +printf '%s\n' "$codex_provider_report" | grep -q 'config.toml provider company' -decimal_report=$(PATH="$STUB_DIR:$PATH" HTTPS_PROXY="http://127.0.0.1:1080" \ - "$PROJECT_DIR/bin/ipcheck" --samples 08 --timeout 08 --no-bandwidth --endpoint https://decimal.invalid --json) +decimal_report=$(run_ipcheck --samples 08 --timeout 08 --no-bandwidth --endpoint https://decimal.invalid --json) printf '%s\n' "$decimal_report" | grep -q '"samples":8' printf '%s\n' "$decimal_report" | grep -q '"successful_samples":8' -fair_report=$(PATH="$STUB_DIR:$PATH" HTTPS_PROXY="http://127.0.0.1:1080" \ - IPCHECK_TEST_TTFB=1.000 "$PROJECT_DIR/bin/ipcheck" --samples 1 --no-bandwidth --json) +fair_report=$(IPCHECK_TEST_TTFB=1.000 run_ipcheck --samples 1 --no-bandwidth --endpoint https://fair.invalid --json) printf '%s\n' "$fair_report" | grep -q '"result":"fair"' attempt_file="$STUB_DIR/attempt" printf 0 > "$attempt_file" -flaky_report=$(PATH="$STUB_DIR:$PATH" HTTPS_PROXY="http://127.0.0.1:1080" \ - IPCHECK_TEST_ATTEMPT_FILE="$attempt_file" "$PROJECT_DIR/bin/ipcheck" --samples 5 --no-bandwidth \ - --endpoint https://flaky.invalid --json) +flaky_report=$(IPCHECK_TEST_ATTEMPT_FILE="$attempt_file" run_ipcheck --samples 5 --no-bandwidth --endpoint https://flaky.invalid --json) printf '%s\n' "$flaky_report" | grep -q '"result":"poor"' printf '%s\n' "$flaky_report" | grep -q '"primary_success_rate_pct":20' printf '%s\n' "$flaky_report" | grep -q '"successful_samples":1' -precedence_report=$(PATH="$STUB_DIR:$PATH" HTTPS_PROXY="http://127.0.0.1:1080" \ - CODEX_NETWORK_ENDPOINTS="https://blocked.invalid" "$PROJECT_DIR/bin/ipcheck" --samples 1 --no-bandwidth \ - --endpoint https://cli.invalid --json) +not_found_report=$(run_ipcheck --samples 1 --no-bandwidth --endpoint https://not-found.invalid/v1/messages --json) +printf '%s\n' "$not_found_report" | grep -q '"result":"poor"' +printf '%s\n' "$not_found_report" | grep -q 'configured API route returned HTTP 404' + +rate_limit_report=$(run_ipcheck --samples 1 --no-bandwidth --endpoint https://rate-limit.invalid --json) +printf '%s\n' "$rate_limit_report" | grep -q '"result":"fair"' +server_error_report=$(run_ipcheck --samples 1 --no-bandwidth --endpoint https://server-error.invalid --json) +printf '%s\n' "$server_error_report" | grep -q '"result":"fair"' + +mixed_status_file="$STUB_DIR/mixed-status-attempt" +printf 0 > "$mixed_status_file" +mixed_status_report=$(IPCHECK_TEST_MIXED_FILE="$mixed_status_file" run_ipcheck --samples 2 --no-bandwidth --endpoint https://mixed-status.invalid --json) +printf '%s\n' "$mixed_status_report" | grep -q '"result":"fair"' +printf '%s\n' "$mixed_status_report" | grep -q 'at least one sample returned a server error' + +precedence_report=$(CODEX_NETWORK_ENDPOINTS="https://blocked.invalid" run_ipcheck --samples 1 --no-bandwidth --endpoint https://cli.invalid --json) printf '%s\n' "$precedence_report" | grep -q '"url":"https://cli.invalid"' if printf '%s\n' "$precedence_report" | grep -q 'blocked.invalid'; then printf 'CLI endpoint did not override CODEX_NETWORK_ENDPOINTS\n' >&2 @@ -104,71 +277,151 @@ if printf '%s\n' "$precedence_report" | grep -q 'blocked.invalid'; then fi set +e -proxy_auth_report=$(PATH="$STUB_DIR:$PATH" HTTPS_PROXY="http://127.0.0.1:1080" \ - "$PROJECT_DIR/bin/ipcheck" --samples 2 --no-bandwidth --endpoint https://proxy-auth.invalid --json) +proxy_auth_report=$(run_ipcheck --samples 2 --no-bandwidth --endpoint https://proxy-auth.invalid --json) proxy_auth_exit=$? set -e [ "$proxy_auth_exit" -eq 1 ] printf '%s\n' "$proxy_auth_report" | grep -q '"result":"blocked"' printf '%s\n' "$proxy_auth_report" | grep -q '"http_code":"407"' -printf '%s\n' "$proxy_auth_report" | grep -q '"successful_samples":0' printf '%s\n' "$proxy_auth_report" | grep -q 'proxy requires authentication' -sensitive_endpoint='https://alice:topsecret@secure.invalid/path?token=verysecret#private' -sensitive_report=$(PATH="$STUB_DIR:$PATH" HTTPS_PROXY="http://127.0.0.1:1080" \ - "$PROJECT_DIR/bin/ipcheck" --samples 1 --no-bandwidth --endpoint "$sensitive_endpoint" --json) -printf '%s\n' "$sensitive_report" | grep -Fq 'https://***@secure.invalid/path?' -if printf '%s\n' "$sensitive_report" | grep -Eq 'alice|topsecret|verysecret|private'; then - printf 'endpoint credentials leaked into JSON report\n' >&2 +mixed_report=$(env \ + -u ANTHROPIC_BASE_URL -u ANTHROPIC_MODEL -u HTTP_PROXY -u http_proxy -u ALL_PROXY -u all_proxy \ + CODEX_NETWORK_ENDPOINTS="https://blocked.invalid" \ + CLAUDE_NETWORK_ENDPOINTS="https://dashscope.aliyuncs.com/apps/anthropic/v1/messages" \ + PATH="$STUB_DIR:$PATH" HOME="$FIXTURE_HOME" CODEX_HOME="$CODEX_FIXTURE" CLAUDE_CONFIG_DIR="$CLAUDE_FIXTURE" \ + HTTPS_PROXY="http://127.0.0.1:1080" IPCHECK_TEST_CURL_LOG="$CURL_LOG" \ + "$PROJECT_DIR/bin/ipcheck" all --samples 1 --no-bandwidth --json) +printf '%s\n' "$mixed_report" | grep -q '"result":"poor"' +printf '%s\n' "$mixed_report" | grep -q '1 good, 0 fair, 0 poor, 1 blocked' + +for unsafe_endpoint in \ + 'file:///etc/passwd' \ + '--config=/tmp/curlrc' \ + 'https://alice:topsecret@secure.invalid/path' \ + 'https://secure.invalid/path?token=verysecret' \ + 'https://secure.invalid/path#private' +do + set +e + run_ipcheck --samples 1 --no-bandwidth --endpoint "$unsafe_endpoint" --json >/dev/null 2>&1 + unsafe_exit=$? + set -e + [ "$unsafe_exit" -eq 2 ] +done + +proxy_redaction_report=$(env \ + -u ANTHROPIC_BASE_URL -u ANTHROPIC_MODEL -u HTTP_PROXY -u http_proxy -u ALL_PROXY -u all_proxy \ + PATH="$STUB_DIR:$PATH" HOME="$FIXTURE_HOME" CODEX_HOME="$CODEX_FIXTURE" CLAUDE_CONFIG_DIR="$CLAUDE_FIXTURE" \ + HTTPS_PROXY="http://proxy-user:p@ss@127.0.0.1:1080?token=proxy-secret" \ + IPCHECK_TEST_CURL_LOG="$CURL_LOG" "$PROJECT_DIR/bin/ipcheck" --samples 1 --no-bandwidth --endpoint https://proxy-redaction.invalid --json) +printf '%s\n' "$proxy_redaction_report" | grep -Fq 'HTTPS_PROXY=http://***@127.0.0.1:1080?' +if printf '%s\n' "$proxy_redaction_report" | grep -Eq 'proxy-user|p@ss|proxy-secret'; then + printf 'proxy credentials leaked into report\n' >&2 exit 1 fi -sensitive_human=$(PATH="$STUB_DIR:$PATH" HTTPS_PROXY="http://127.0.0.1:1080" \ - "$PROJECT_DIR/bin/ipcheck" --samples 1 --no-bandwidth --endpoint "$sensitive_endpoint") -if printf '%s\n' "$sensitive_human" | grep -Eq 'alice|topsecret|verysecret|private'; then - printf 'endpoint credentials leaked into human report\n' >&2 + +: > "$CURL_LOG" +proxy_fallback_report=$(env \ + -u ANTHROPIC_BASE_URL -u ANTHROPIC_MODEL -u HTTPS_PROXY -u https_proxy -u ALL_PROXY -u all_proxy \ + HTTP_PROXY="http://ignored.invalid:8080" PATH="$STUB_DIR:$PATH" HOME="$FIXTURE_HOME" \ + CODEX_HOME="$CODEX_FIXTURE" CLAUDE_CONFIG_DIR="$CLAUDE_FIXTURE" IPCHECK_TEST_CURL_LOG="$CURL_LOG" \ + "$PROJECT_DIR/bin/ipcheck" --samples 1 --no-bandwidth --endpoint https://system-proxy.invalid --json) +printf '%s\n' "$proxy_fallback_report" | grep -q 'macOS HTTPS proxy=127.0.0.1:1082' +grep -q '^--proxy$' "$CURL_LOG" +grep -q '^http://127.0.0.1:1082$' "$CURL_LOG" + +: > "$CURL_LOG" +claude_system_proxy_report=$(run_ipcheck_direct claude --samples 1 --no-bandwidth --json) +printf '%s\n' "$claude_system_proxy_report" | grep -q 'Claude Code expects HTTPS_PROXY/HTTP_PROXY' +if grep -q '^--proxy$' "$CURL_LOG"; then + printf 'Claude probe incorrectly used the macOS system proxy\n' >&2 exit 1 fi -proxy_fallback_report=$(env -u HTTPS_PROXY -u https_proxy -u ALL_PROXY -u all_proxy \ - PATH="$STUB_DIR:$PATH" HTTP_PROXY="http://ignored.invalid:8080" \ - "$PROJECT_DIR/bin/ipcheck" --samples 1 --no-bandwidth --json) -printf '%s\n' "$proxy_fallback_report" | grep -q 'macOS HTTPS proxy=127.0.0.1:1082' -if printf '%s\n' "$proxy_fallback_report" | grep -q 'HTTP_PROXY'; then - printf 'HTTP_PROXY incorrectly suppressed HTTPS system proxy fallback\n' >&2 +: > "$CURL_LOG" +claude_http_proxy_report=$(env \ + -u ANTHROPIC_MODEL -u HTTPS_PROXY -u https_proxy -u ALL_PROXY -u all_proxy \ + ANTHROPIC_BASE_URL="http://claude-http-gateway.invalid/anthropic" HTTP_PROXY="http://claude-proxy.invalid:8080" \ + PATH="$STUB_DIR:$PATH" HOME="$FIXTURE_HOME" \ + CODEX_HOME="$CODEX_FIXTURE" CLAUDE_CONFIG_DIR="$CLAUDE_FIXTURE" IPCHECK_TEST_CURL_LOG="$CURL_LOG" \ + "$PROJECT_DIR/bin/ipcheck" claude --samples 1 --no-bandwidth --json) +printf '%s\n' "$claude_http_proxy_report" | grep -q '"result":"good"' +grep -q '^env:HTTPS_PROXY=http://claude-proxy.invalid:8080$' "$CURL_LOG" +grep -q '^env:http_proxy=http://claude-proxy.invalid:8080$' "$CURL_LOG" +if grep -q '^--proxy$' "$CURL_LOG"; then + printf 'Claude HTTP_PROXY credentials could be exposed in process arguments\n' >&2 exit 1 fi +if printf '%s\n' "$claude_http_proxy_report" | grep -q 'Claude Code expects HTTPS_PROXY/HTTP_PROXY'; then + printf 'Claude HTTP_PROXY fallback incorrectly emitted system-proxy warning\n' >&2 + exit 1 +fi + +: > "$CURL_LOG" +claude_https_precedence_report=$(env \ + -u ANTHROPIC_MODEL -u HTTP_PROXY -u http_proxy -u https_proxy \ + ANTHROPIC_BASE_URL="http://claude-http-gateway.invalid/anthropic" \ + HTTPS_PROXY="http://supported-proxy.invalid:8080" ALL_PROXY="http://unsupported-proxy.invalid:8081" \ + PATH="$STUB_DIR:$PATH" HOME="$FIXTURE_HOME" CODEX_HOME="$CODEX_FIXTURE" CLAUDE_CONFIG_DIR="$CLAUDE_FIXTURE" \ + IPCHECK_TEST_CURL_LOG="$CURL_LOG" "$PROJECT_DIR/bin/ipcheck" claude --samples 1 --no-bandwidth --json) +printf '%s\n' "$claude_https_precedence_report" | grep -q '"result":"good"' +grep -q '^env:HTTPS_PROXY=http://supported-proxy.invalid:8080$' "$CURL_LOG" +grep -q '^env:ALL_PROXY=$' "$CURL_LOG" -bandwidth_report=$(PATH="$STUB_DIR:$PATH" HTTPS_PROXY="http://127.0.0.1:1080" \ - "$PROJECT_DIR/bin/ipcheck" --samples 1 --json) +: > "$CURL_LOG" +socks_report=$(env \ + -u ANTHROPIC_BASE_URL -u ANTHROPIC_MODEL -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy \ + ALL_PROXY="socks5h://127.0.0.1:1080" PATH="$STUB_DIR:$PATH" HOME="$FIXTURE_HOME" \ + CODEX_HOME="$CODEX_FIXTURE" CLAUDE_CONFIG_DIR="$CLAUDE_FIXTURE" IPCHECK_TEST_CURL_LOG="$CURL_LOG" \ + "$PROJECT_DIR/bin/ipcheck" claude --samples 1 --no-bandwidth --json) +printf '%s\n' "$socks_report" | grep -q 'Claude Code does not support SOCKS proxies' +printf '%s\n' "$socks_report" | grep -q 'Claude Code does not document ALL_PROXY' +grep -q '^env:ALL_PROXY=$' "$CURL_LOG" + +bandwidth_report=$(run_ipcheck --samples 1 --json) printf '%s\n' "$bandwidth_report" | grep -q '"bandwidth":{"enabled":true,"available":true,"http_code":"200"' -invalid_bandwidth_report=$(PATH="$STUB_DIR:$PATH" HTTPS_PROXY="http://127.0.0.1:1080" \ - IPCHECK_TEST_BANDWIDTH_CODE=407 "$PROJECT_DIR/bin/ipcheck" --samples 1 --json) +invalid_bandwidth_report=$(IPCHECK_TEST_BANDWIDTH_CODE=407 run_ipcheck --samples 1 --json) printf '%s\n' "$invalid_bandwidth_report" | grep -q '"bandwidth":{"enabled":true,"available":false,"http_code":"407","bytes":0,"bytes_per_second":0}' -[ "$(grep -c -- '--max-time "$TIMEOUT"' "$PROJECT_DIR/bin/ipcheck")" -eq 2 ] -grep -q 'networkQuality -c -u -M "$TIMEOUT"' "$PROJECT_DIR/bin/ipcheck" +grep -Fq -- "--max-time \"\$TIMEOUT\"" "$PROJECT_DIR/bin/ipcheck" +grep -Fq "networkQuality -c -u -M \"\$TIMEOUT\"" "$PROJECT_DIR/bin/ipcheck" grep -q 'download_bits_per_second' "$PROJECT_DIR/bin/ipcheck" grep -q 'value / 1000000' "$PROJECT_DIR/bin/ipcheck" -system_report=$(PATH="$STUB_DIR:$PATH" HTTPS_PROXY="http://127.0.0.1:1080" \ - "$PROJECT_DIR/bin/ipcheck" --samples 1 --no-bandwidth --system --json) +system_report=$(run_ipcheck --samples 1 --no-bandwidth --system --json) printf '%s\n' "$system_report" | grep -q '"download_bits_per_second":10000000' -system_human=$(PATH="$STUB_DIR:$PATH" HTTPS_PROXY="http://127.0.0.1:1080" \ - "$PROJECT_DIR/bin/ipcheck" --samples 1 --no-bandwidth --system) +system_human=$(run_ipcheck --samples 1 --no-bandwidth --system) printf '%s\n' "$system_human" | grep -q 'macOS networkQuality: 10.0 Mbps' set +e -blocked_report=$(PATH="$STUB_DIR:$PATH" HTTPS_PROXY="http://127.0.0.1:1080" \ - "$PROJECT_DIR/bin/ipcheck" --samples 1 --no-bandwidth --endpoint https://blocked.invalid --json) +blocked_report=$(run_ipcheck --samples 1 --no-bandwidth --endpoint https://blocked.invalid --json) blocked_exit=$? set -e [ "$blocked_exit" -eq 1 ] printf '%s\n' "$blocked_report" | grep -q '"http_code":"000"' printf '%s\n' "$blocked_report" | grep -q '"bandwidth":{"enabled":false,"available":false,"http_code":"000"' -markdown=$(PATH="$STUB_DIR:$PATH" HTTPS_PROXY="http://127.0.0.1:1080" \ - "$PROJECT_DIR/bin/ipcheck" --samples 1 --no-bandwidth --markdown) -printf '%s\n' "$markdown" | grep -q '^# ipcheck: Codex Network Check' +markdown=$(run_ipcheck --samples 1 --no-bandwidth --markdown) +printf '%s\n' "$markdown" | grep -q '^# ipcheck: AI Coding Network Report' +printf '%s\n' "$markdown" | grep -q '| Claude Code |' printf '%s\n' "$markdown" | grep -q '| Jitter |' +quick_report=$(run_ipcheck --quick --json) +printf '%s\n' "$quick_report" | grep -q '"samples":1' +printf '%s\n' "$quick_report" | grep -q '"bandwidth":{"enabled":false' + +set +e +run_ipcheck --service invalid >/dev/null 2>&1 +invalid_exit=$? +set -e +[ "$invalid_exit" -eq 2 ] + +for numeric_option in --samples --timeout; do + set +e + run_ipcheck "$numeric_option" 9223372036854775808 >/dev/null 2>&1 + invalid_exit=$? + set -e + [ "$invalid_exit" -eq 2 ] +done + printf 'ipcheck smoke tests: ok\n'