commit 05fcd08057c828bf6a912c6a87d594531e048ff1 Author: wehub-resource-sync Date: Mon Jul 13 12:21:01 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b921cd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +# VCS +.git +**/.git + +# Editor +.vscode +**/.vscode + +# Dependencies and build output +node_modules +.next +out +build +dist +coverage + +# Runtime data and logs +data +logs + +# Local env files (inject at runtime via --env-file or -e) +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Debug logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6ed8f81 --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +# 9Router environment contract +# This file reflects actual runtime usage in the current codebase. + +# Required +JWT_SECRET=change-me-to-a-long-random-secret +INITIAL_PASSWORD=change-me +DATA_DIR=/var/lib/9router + +# Recommended runtime variables +PORT=20128 +NODE_ENV=production + +# Recommended security and ops variables +API_KEY_SECRET=endpoint-proxy-api-key-secret +MACHINE_ID_SALT=endpoint-proxy-salt +ENABLE_REQUEST_LOGS=false +OBSERVABILITY_ENABLED=true +AUTH_COOKIE_SECURE=false +REQUIRE_API_KEY=false + +# Cloud sync variables +# Must point to this running instance so internal sync jobs can call /api/sync/cloud. +# Server-side preferred variables: +BASE_URL=http://localhost:20128 +CLOUD_URL=https://9router.com +# Backward-compatible/public variables: +NEXT_PUBLIC_BASE_URL=http://localhost:20128 +NEXT_PUBLIC_CLOUD_URL=https://9router.com + +# Optional outbound proxy variables for upstream provider calls +# Lowercase variants are also supported: http_proxy, https_proxy, all_proxy, no_proxy +# HTTP_PROXY=http://127.0.0.1:7890 +# HTTPS_PROXY=http://127.0.0.1:7890 +# ALL_PROXY=socks5://127.0.0.1:7890 +# NO_PROXY=localhost,127.0.0.1 + +# Optional SearXNG endpoint for the built-in unauthenticated web-search provider. +# SEARXNG_URL=http://searxng:8080/search + +# Currently unused by application runtime (kept as reference) +# INSTANCE_NAME=9router diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e180032 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,2 @@ +version: 2 +updates: [] diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..e8ef957 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,60 @@ +name: Build and Push Docker Image + +on: + push: + tags: + - "v*" + workflow_dispatch: + +env: + GHCR_IMAGE: ghcr.io/${{ github.repository }} + DOCKERHUB_IMAGE: decolua/9router + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.GHCR_IMAGE }} + ${{ env.DOCKERHUB_IMAGE }} + tags: | + type=semver,pattern={{version}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache + cache-to: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache,mode=max + platforms: linux/amd64,linux/arm64 + provenance: false + sbom: false diff --git a/.github/workflows/gitbook-pages.yml b/.github/workflows/gitbook-pages.yml new file mode 100644 index 0000000..797e1dc --- /dev/null +++ b/.github/workflows/gitbook-pages.yml @@ -0,0 +1,50 @@ +name: Deploy GitBook to 9router.github.io + +on: + push: + branches: [main, master] + paths: + - "gitbook/**" + - ".github/workflows/gitbook-pages.yml" + workflow_dispatch: + +concurrency: + group: gitbook-pages + cancel-in-progress: true + +jobs: + build-deploy: + runs-on: ubuntu-latest + defaults: + run: + working-directory: gitbook + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install deps + run: npm install --no-audit --no-fund + + - name: Build static export + run: npm run build + env: + NODE_ENV: production + NEXT_PUBLIC_BASE_PATH: "" + + - name: Add .nojekyll + run: touch out/.nojekyll + + - name: Deploy to 9router.github.io + uses: peaceiris/actions-gh-pages@v4 + with: + deploy_key: ${{ secrets.GH_PAGES_DEPLOY_KEY }} + external_repository: 9router/9router.github.io + publish_branch: main + publish_dir: gitbook/out + force_orphan: true + user_name: github-actions[bot] + user_email: github-actions[bot]@users.noreply.github.com + commit_message: "deploy: ${{ github.sha }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd7c67f --- /dev/null +++ b/.gitignore @@ -0,0 +1,80 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/.next-cli-build/ +/out/ +cli/.build-home/ +product +# production +/build +.idea/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +.bin/* +data/ +logs/* +source/* +.cursor/* +docs/* +!docs/ARCHITECTURE.md +test/* +open-sse/test/* +RM.vn.md +RM.md +cursor/* +PUBLIC.md +Thanks.md +PUBLIC.en.md +PR/* +package-lock.json + + +#Ignore vscode AI rules +.github/instructions/codacy.instructions.md +README1.md +deploy*.sh +ecosystem.config.* + +scripts/agSniffer/* +gitbooks/* +gitbook/README.md + +# Refactor backup reference (do not bundle/lint) +open-sse.old/ +.graphifyignore +graphify-out/* +.next-analyze/* diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..24e8f32 --- /dev/null +++ b/.npmignore @@ -0,0 +1,31 @@ +# Database files - NEVER publish +data/ +**/data/ +**/db.json + +# Development +src/ +docs/ +test/ +agents/ +scripts/ +worker/ +shared-sse/ +copilot-api/ +CLIProxyAPI/ + +# Config files +*.md +!README.md +.gitignore +.env* +jsconfig.json +eslint.config.mjs +postcss.config.mjs +next.config.mjs +tsconfig.json + +# Build artifacts that shouldn't be published +.next/cache/ +.next/standalone/data/ + diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8e3d0f1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,20 @@ +{ + "css.lint.unknownAtRules": "ignore", + "sonarlint.rules": { + "css:S4662": { + "level": "off" + }, + "javascript:S6747": { + "level": "off" + }, + "javascript:S7764": { + "level": "off" + }, + "javascript:S6772": { + "level": "off" + }, + "javascript:S3776": { + "level": "off" + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c6ba213 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,380 @@ +# v0.5.30 (2026-07-10) + +## Features +- **Perplexity**: add Agent API provider (#2492) +- **Grok CLI**: add Grok CLI / Grok Build provider with OAuth device-code flow (#2502) +- **Featherless**: add OpenAI-compatible provider presets +- **SearXNG**: configure endpoint via SEARXNG_URL env (#2499) +- **Providers**: add max thinking level for gpt-5.6-sol (#2500) +- **Headroom**: add extras detection and install UI (#2403) +- **Headroom**: activate/uninstall extras + fix interpreter detection +- **PXPipe**: PXPIPE token saver — multimodal prompt compression (#2465) +- **Proxy-Pools**: auto-rotate strategy for no-auth providers (#2409) + +## Fixes +- **Cloudflare-AI**: support accountId in bulk key import (#2449) +- **DB**: backup on schema change, MCP child cleanup, codex models, usage providers OOM +- **Codex**: avoid bare-email OAuth dedup (#2477) +- **CLI**: allow staged app bundle builds (#2479) +- **Headroom**: compress Kiro conversation state (#2488) +- **Gemini-CLI**: raise output floor for thinking and add validated toolConfig (#2486) +- **GitHub**: label Copilot profiles by account identity (#2498) +- **OpenAI-to-Claude**: unwrap bare {function:{…}} tools without parent type (#2473) +- **Translator**: clamp thinking effort max->xhigh for OpenAI format (#2466) +- **RTK/find**: detect and group Windows backslash-style find output (#2448) +- **Codex**: handle fast tier and capacity SSE (#2452) +- **Volcengine-ark**: clamp Kimi max_tokens to 32768 endpoint cap +- **Antigravity**: align provider fingerprint with IDE Desktop 2.1.1 (#2389) +- **Pricing**: update Claude/Codex model rates and add new models + +## Improvements +- **i18n(zh-CN)**: complete Chinese translations for all UI strings (#2436) +- **API**: caching for tunnel and version status endpoints +- **Perf**: faster dev startup and lighter bundle + +# v0.5.20 (2026-07-07) + +## Features +- **Thinking**: per-model thinking level picker on provider page — appends `(level)` suffix to copied model names for forced reasoning effort across all formats (openai, claude, gemini, deepseek, kimi, qwen, zai, minimax, hunyuan, step) +- **RTK**: add JS-native git-log filter (#2423) +- **Caveman**: add targeted upstream-aligned style rules (#2424) +- **i18n**: add Farsi (fa) language support (#2385) + +## Fixes +- **Thinking**: strip `(level)` suffix from upstream `body.model` so providers no longer reject requests +- **Translator**: preserve developer instructions in openai-responses conversion (#2434) +- **count_tokens**: count structured Anthropic blocks (#2419) +- **Volcengine-ark**: clamp GLM-5 max_tokens to model output ceiling (#2428) +- **Kimi**: normalize reasoning_effort to backend enum (#2427) +- **Claude**: reconcile max_tokens vs thinking budget and lift per-model ceiling (#2381) +- **Kiro**: deliver system prompt natively, add Opus 4.5/4.7/4.8, tolerate dash version ids (#2366) +- **Headroom**: proxy dashboard through app (#2372) +- **MITM**: recover from stale lock file on server start + +# v0.5.18 (2026-07-03) + +## Features +- **Usage**: track cached tokens + correct input/output/cache cost (#2209) — hodtien +- **Codex**: show reset credit expiry details (#2290) — Rafli Ahmad Zulfikar +- **NVIDIA**: add new models and capabilities — decolua +- **ClinePass**: add provider support — sternelee + +## Fixes +- **Usage**: dedupe streaming request-details log entries — Qin Li +- **Claude**: drop foreign thinking signatures in passthrough — decolua +- Prevent non-SSE stream pipe crash and cross-IdP account overwrites (#2244) — KunN-21 +- **Kiro**: route IdC auth to regional CodeWhisperer surface (#2297) — Volodymyr Saakian +- **Kiro**: add Claude Sonnet 5 model support (#2264) — Edison42 +- **Xiaomi-tokenplan**: region selector, key validation, multi-connection (#2251) — MiQieR +- **Translator**: strict Anthropic content block compliance (#2225) — Sahrul Ramadhan Hardiansyah +- **Kimchi**: strip reasoning_content echo to bound multi-turn input tokens — KunN-21 +- **Kimchi**: bump User-Agent to kimchi/0.1.40 (#2256) — Ansh7473 +- **Codebuddy-cn**: strip empty tool_calls arrays to preserve reasoning — zmf +- **Antigravity**: preserve Claude tool delta index (#2223) — Sutarto Jordan Chrisfivo +- **MITM**: generate root CA on server startup (#2228) — Sutarto Jordan Chrisfivo + +# v0.5.15 (2026-06-29) + +## Features +- Add Kimchi OAuth provider — Nant361 +- Refine Qwen vision/video + thinking model patterns — decolua +- Opt-in Codex auto-ping quota keep-alive — Emirhan + +## Fixes +- **Responses**: handle response.done terminal events (#2142) — rifuki +- **Headroom**: skip unsafe responses tool history (#2132) — Sutarto Jordan Chrisfivo +- **Translator**: map mid-conversation system message to user (claude→openai) — decolua +- **Gemini**: normalize contents to prevent 400 invalid_argument (#2192) — warelik +- **Gemini**: backfill thoughtSignature + suppress stream done sentinel — WARELIK +- **Alicode**: preserve cache_control for DashScope providers (#2069) — Rex +- **Antigravity**: strip deprecated/readOnly/writeOnly from tool schemas — iletai, Yudhistira-Official +- **CodeBuddy CN**: show bonus packs as one-time, not monthly-replenishing — whale9820 +- **Kiro**: strip leaked tags from content stream (#2158) — hamsa0x7 +- **Tray**: make Windows context menu DPI-aware — Emirhan +- **Kilocode**: expose full gateway catalog in combo model picker — jellylarper +- **OpenCode**: fix Go GLM — decolua + +# v0.5.12 (2026-06-26) + +## Features +- Add token-saver dashboard page — decolua +- Add bulk delete for provider connections — teddytkz +- Resolve GitHub Copilot model catalog from upstream — caiqinzhou +- Add Venice AI provider — Brokenc0de +- Add Kiro external_idp import for Microsoft SSO (CLIProxyAPI) — Stevanus Pangau +- Overhaul Blackbox provider catalog + WebUI test support — suryacagur + +## Fixes +- Provider thinking compatibility (DeepSeek/Gemini) — Mink Nguyen +- Stop double-counting streaming usage at source — decolua +- Usage logging dedupe to reduce stats churn — Mink Nguyen +- Prevent non-JSON SSE lines / duplicate [DONE] from breaking clients (PR #2046) — qianze +- Resolve Gemini TTS models from catalog — nguyenha935 +- Support Kiro IDC (organization) token import — quanturbo +- Preserve forced streaming for JSON clients (#2031) — Joseph Yaksich +- Preserve Responses text format (Codex) — tenglong +- Support Gemini native TTS generateContent endpoint — nguyenha935 +- Add missing zh-CN endpoint key label (i18n) — weimaozhen +- CodeBuddy: only send reasoning params when client requests reasoning (#2071) — Rex +- CodeBuddy CN: show one-shot bonus packs as expiring, not monthly-replenishing +- Show custom provider models in combo picker — Sapto +- Docker: add docker-compose.yml with headroom enabled by default — nitsuahlabs +- Clarify token diagnostics vs provider billing (headroom, #1998) — Sutarto Jordan Chrisfivo +- Translate openai-responses input through OpenAI for compression (#1998) — Ankit +- Kiro: report 1M context window for claude-opus-4.8 — EdisonPVE +- Avoid stale redirects after auth changes (#2100) — Emirhan +- Mark Claude Opus 4.7 (dashed id) as 1M context — Brokenc0de +- Preserve reasoning effort through Codex translations — ntdung6868 +- Token-saver: full width card layout — decolua +- Antigravity: retry transient upstream failures — Sutarto Jordan Chrisfivo +- Param-support: handle strip rules without match/drop (#1960) — Joseph Yaksich +- Translator: resolve custom provider prefix in debug endpoint (#1083) — hamsa0x7 + +# v0.5.8 (2026-06-21) + +## Features +- **Antigravity**: native image generation support (image models tagged kind:image, hiển thị trong media-providers UI) +- **CodeBuddy CN**: API key auth + credit quota tracker +- **CodeBuddy CN**: short model prefix alias "cbcn" + +## Fixes +- **MiniMax-M3**: enable vision capability +- **Headroom**: support Docker sidecar proxy +- **Antigravity**: image executor fixes +- **mimo-free**: Chrome User-Agent rotation to bypass anti-abuse gate +- **cloudflare-ai**: flatten content-part arrays to string to avoid oneOf 400 (#1926) +- **Translator**: normalize tools to Anthropic-native shape for non-Anthropic providers +- **CLI**: handle Next.js 16 nested standalone output path (#1940) +- **Codex**: preserve custom tools during request normalization +- **next.config**: add new route for responses endpoint to API + +# v0.5.6 (2026-06-20) + +## Features +- **Ponytail**: minimalist code generation feature +- **Headroom**: proxy lifecycle management + dashboard UI (one-click start/stop, install detection, status probing, token saver, claude↔openai shape conversion) +- **CodeBuddy CN**: new OAuth provider (copilot.tencent.com) — 15-model catalog, /v2 inference, forced streaming, OpenAI-style reasoning +- **OpenCode-Go**: align models with official endpoints; route Qwen 3.7 MiniMax via /v1/messages, GLM/Kimi/DeepSeek/MiMo via /chat/completions + +## Fixes +- **Anthropic-compatible validation**: use POST /v1/messages (GET /models not spec, false "invalid" for valid keys) +- **CLI tools**: tolerate JSONC configs in all 8 settings routes (opencode, openclaw, kilo, droid, cowork, copilot, claude, cline) +- **Gemini/Antigravity**: preserve 'pattern' in tool schema translation (glob/grep) +- **Combo/Fusion**: flatten Anthropic-style tool messages in panel calls (prevent 503) +- **Models**: store provider custom models by provider scope +- **Perplexity**: use /v1/models endpoint for key validation + +# v0.5.4 (2026-06-18) + +## Fixes +- **Kiro**: honor thinking effort budgets +- **AG/Kiro/Xiaomi**: provider fixes +- **Combo/Fusion**: flatten tool history in panel calls to prevent 503 +- **LLM selector**: show custom vision models in selector and model list +- **Image**: prevent compatible nodes from shadowing provider aliases + +# v0.5.2 (2026-06-17) + +## Features +- **Combo Fusion strategy** — fans the prompt out to all member models in parallel, then a configurable judge model synthesizes one final answer (quorum-grace, anonymized sources, graceful degradation) +- **Per-combo strategy selector** — pick `fallback` / `round-robin` / `fusion` / `capacity` per combo (replaces the old round-robin toggle), with a judge picker for fusion +- **Capacity auto-switch** — reorders models per request so images/PDFs route to capable models first +- **Kiro headless API-key auth** (`ksk_`) + direct `claude↔kiro` route that avoids the lossy OpenAI two-hop pivot +- **Claude auto-ping** — warms the 5h quota window right after reset so a fresh window starts immediately (per-connection toggle) + +## Fixes +- **Claude 429**: stop hammering the OAuth usage endpoint — cache resetAt, throttle quota refresh to 3 min, cool down after a 429 (chat unaffected) +- **Usage logs always empty**: missing `await` on `getAdapter()` in `getRecentLogs` made `/api/usage/logs` & `/api/usage/request-logs` return nothing +- **Executors**: strip params unsupported by the provider/model (drops deprecated `temperature` for claude-opus-4 → Anthropic 400) +- **Translator**: derive deterministic tool_call ids for gemini/antigravity → OpenAI so function call/response pair correctly (fixes tool-pairing 400s) +- **Antigravity**: strip `optional` from tool schemas before sending to Gemini +- **Claude-to-OpenAI**: handle OpenAI-format responses in the non-streaming path (e.g. xiaomi-tokenplan) +- **Usage views**: show edited connection names consistently across Providers & Quota Tracker +- **Security**: hardened reverse-proxy local-access trust +- **Security**: SSRF hardening on web fetch + +## Internal +- Large **open-sse / translator refactor** (~40 commits): unified provider/model registry (LiteLLM-style `models[]` + `kind` field, 100 co-located registry files), single-sourced media/OAuth/refresh/token URLs, registry-based dispatch for usage & token-refresh, DRY translator concerns (buildUsage, encodeDataUri, finishReasonMap, chunkBuilder, reasoningDelta…), ESM-safe registry init, large-file splits, dead-code removal, and golden/no-regression test gates + +# v0.4.80 (2026-06-13) + +## Features +- Vercel AI Gateway: support embeddings, images and credit usage (#1183) +- Add MiMo Free no-auth provider (#1789) +- Vertex: support ADC `authorized_user` credential +- Cowork: re-enable Claude Cowork with preset-only stdio MCP +- Codex: bulk add accounts via JSON (#1719) +- Kiro: enable multi-endpoint failover for GenerateAssistantResponse (#1722) + +## Fixes +- Security: re-auth on DB export/import + SSRF guard on web fetch +- Auth: real client IP rate-limiting + remote default-password guard +- Cerebras/Mistral: strip unsupported `client_metadata` from downstream requests (#1742) +- SiliconFlow: update baseUrl `.cn` -> `.com` + curate verified model list (#1760) +- Gemini-to-OpenAI: route unsigned thought parts to `reasoning_content` (#1752) +- Claude-to-OpenAI: strip Anthropic billing header from system prompt (#1765) +- Anthropic-compatible: send Bearer auth for third-party gateways (#1795) +- Usage-stats: avoid partial stats on initial SSE race (#1767) +- Proxy: use `export default` in proxy.js for Next.js 16 middleware detection +- Claude passthrough: add body normalization +- GitHub Copilot: refresh missing/expired token on models discovery (#1727) + add mappable gpt-5-mini/gpt-5.4-nano slots for Copilot MITM (#1653) +- Kiro: auto-resolve profileArn to prevent 403 on IDC login, enhance profile ARN resolution, update endpoint to `runtime.us-east-1.kiro.dev` (#1713) +- Tunnel: detect system-installed Tailscale via dual-socket probe (#1723) + non-blocking probes to prevent UI freeze +- CommandCode: force `stream=true` in transformRequest (#1706) +- Qoder: increase timeouts for reasoning models and improve stream handling +- Dashboard: show provider node name instead of connection name in topology (#1770) + show explicit `kind="llm"` combos on combos page (#1684) + +## Docs +- README: add Indonesian 9Router tutorial video (#1709) + +# v0.4.71 (2026-06-06) + +## Features +- Caveman: add wenyan classical Chinese levels and sync upstream prompts; locale-based visibility on endpoint page +- i18n: endpoint exposure notice across multiple languages + Russian README +- Antigravity: add gemini-3.5-flash-extra-low (Low) model +- xiaomi-tokenplan: add Claude-native MiMo V2.5 Pro alias via dedicated executor +- Qoder: fetch latest model + dashboard import-model button (#1642) +- MiniMax: add MiniMax-M3 + update Quota Tracker coding/CN (#1631) + +## Fixes +- Codex: harden streaming timeouts (stall/connect raised to 60s, configurable per-provider), accept `response.done` event, and always emit a terminal `response.failed` + `[DONE]` for Responses passthrough when a stream closes, stalls, or aborts before a terminal event — prevents codex clients from hanging (#1648, #1680, #1688, #1618) +- Codex: durable OAuth refresh lifecycle (#1664) +- Tunnel: skip virtual interfaces to prevent false netchange watchdog +- Claude: fix forced tool_choice 400 on cc/ OAuth route (#1592) +- Proxy: raise Next client body limit to 128MB via `NINEROUTER_PROXY_CLIENT_MAX_BODY_SIZE` (#1529, #1572) +- MiniMax: echo `reasoning_content` on follow-up turns to avoid 400 (#1543) +- Kiro: handle 400 on tool-bearing history without client tools; add mappable "auto" model slot; fix binary EventStream crash + add models & TTS tool filtering +- Antigravity: passthrough tab-autocomplete + mark default agent slot mandatory +- Qoder: allow `qmodel_latest` model key (#1638) +- Providers: restore one-connection guard for compatible/embedding nodes +- Model-test: route image/STT probes to their real endpoints, harden STT ping; add opencode-go + xiaomi-tokenplan to connection test (#1576, #1628) + +## Improvements +- Dashboard: reorganize menu actions across sidebar/header/profile +- Translator: add data-driven coverage, bug-exposing cases, and real provider smoke tests + +# v0.4.66 (2026-05-29) + +## Features +- Add Qoder provider: device-flow OAuth, COSY signing, WAF-bypass body encoding, live model catalog, dashboard quota tracker, 11 models (#1372) +- Add new models: Claude Opus 4.8 (Claude Code), GPT 5.4 Mini (Codex) + +## Fixes +- DeepSeek thinking mode: echo `reasoning_content` back on follow-up/tool-call turns so OpenCode-free and custom providers no longer 400 with "reasoning_content must be passed back" (#1543) +- Reasoning injector: match deepseek/kimi model ids case-insensitively (covers custom providers using capitalized model names) +- OpenCode suggested-models: include free models without the `-free` suffix, e.g. `big-pickle` (#1535) + +## Improvements +- Codex: trim sunset models, keep gpt-5.5 / gpt-5.4 / gpt-5.3-codex family, add gpt-5.4-mini +- volcengine-ark: refresh model list (add DeepSeek-V4-Flash/Pro, drop EOL entries) +- Lower stream stall timeout 35s → 30s for faster hang detection + +# v0.4.63 (2026-05-26) + +## Fixes +- GitHub Copilot: never route Gemini/Claude models to the `/responses` endpoint; prevents misleading "does not support Responses API" 400s (#1062) +- proxyFetch: restore missing `Readable` import causing runtime `ReferenceError` in DNS-bypass fetch path + +## Improvements +- Lower stream stall timeout from 60s → 35s for faster hang detection + +# v0.4.62 (2026-05-26) + +## Fixes +- Codex: auto-retry when upstream drops mid-stream (no more hangs) +- Codex: fix random 400/404 errors, tool-calling failures, and unstable prompt cache +- MITM: support Antigravity 2.x +- Sanitize Read tool args to prevent retry loops from non-Anthropic models (#1144) +- Implement json_schema fallback for OpenAI-compatible providers without native Structured Output (#1343) +- Strip empty Read pages argument in OpenAI-to-Claude translator (#1354) +- Forward Gemini output dimensions for embeddings (#1366) +- Resolve setState-in-effect errors in dashboard components (#1362) +- Gemini CLI: reuse stored OAuth project IDs for quota checks and show clearer setup guidance when the project is missing (#1271, #1428) + +## Features +- Add Cloudflare Workers proxy deployer and pool integration (#1360) +- Add Deno Deploy relays support and improved proxy pools dashboard layout (#1437) + +## Improvements +- Refactor Tunnel into dedicated Cloudflare and Tailscale manager modules +- Refactor tokenRefresh service with in-flight dedup to prevent refresh_token_reused errors + +# v0.4.59 (2026-05-21) + +## Fixes +- OAuth: fix login flow on Windows + +# v0.4.58 (2026-05-21) + +## Features +- xAI Grok provider (OAuth, API key, image) +- Provider limits: paginated accounts with page size controls + +## Fixes +- Tailscale: fix connection status on Windows (#1300) +- Tunnel: fix false "checking" when tunnel URL is reachable +- Stream: fix pipe errors on client disconnect/abort + +# v0.4.55 (2026-05-18) + +## Features +- Xiaomi MiMo Token Plan: region selector (Singapore / China / Europe) — keys are cluster-specific +- Antigravity: risk confirmation dialog before first connection +- Gemini CLI: surface upstream retry delay on 429 errors + +## Fixes +- MITM: cannot kill process on macOS under sudo (lsof not found in PATH) +- Stream: false-positive stall timeout on Claude reasoning / Kiro responses +- Tunnel: cannot re-enable after disable (stuck state) +- Tunnel: cloudflared error messages now include log tail for easier debugging +- Language switcher: applies selected locale immediately on close (#1234) +- Antigravity OAuth: metadata now matches the official client + +## Improvements +- Gemini CLI: bump engine to 0.34.0 +- Re-hide `qwen` (OAuth EOL) and `iflow` (not ready) providers + +# v0.4.52 (2026-05-17) + +## Features +- Add Vercel AI Gateway provider support (#1183) +- rtk: Kiro format tool result compression — handle conversationState.history & currentMessage, preserve error results, ~13.6% savings (#1194) + +## Fixes +- openclaw: normalize agent.model object form `{primary, fallbacks}` before .startsWith → fix TypeError & 'not configured' status (#1216) +- Usage Details pagination: stay inside mobile viewport <640px (#1218) +- Fix test model error +- Fix MIMO provider in Codex +- Disable log file creation when using MITM AG + +# v0.4.50 (2026-05-16) + +## Fixes +- Fix duplicate tray icon on macOS when hiding to tray +- Fix tray not showing in background mode on macOS +- Fix hide to tray broken on Windows/Linux +- Fix Shutdown button in web UI not working + +# v0.4.49 (2026-05-16) + +## Features +- Add Kiro provider support: full request/response translation, live model listing, reasoning content support +- Add `buildOutput` RTK filter with autodetect for npm/yarn/cargo build logs +- Add MITM warning notification in tray and dashboard + +## Improvements +- Add modalities (input/output) to model configuration for OpenCode +- Fix tray hide-to-tray: keep current process alive instead of spawning detached child (fixes macOS NSStatusItem ghost icon) +- Fix tray kill: graceful shutdown with SIGTERM/SIGKILL escalation +- Fix SIGHUP handling so macOS terminal close doesn't kill tray process +- Hide deprecated providers (qwen, iflow, antigravity) +- Update i18n across 32 languages + +## Fixes +- Fix model check (test-models) blocked by dashboardGuard: pass machineId-based CLI token in internal self-calls + +# v0.4.46 (2026-05-15) + +## Breaking Changes +- Tunnel public URL changed — old tunnel links no longer work, please reconnect to get the new URL \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d7c2134 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +9Router (`9router-app`) — a local AI routing gateway + Next.js dashboard. It exposes one OpenAI-compatible endpoint (`/v1/*`) and routes traffic across 40+ upstream providers with format translation, model-combo fallback, multi-account fallback, OAuth/API-key credential management, token refresh, quota/usage tracking, and optional cloud sync. + +Two published artifacts live in this one repo: +- The **dashboard + gateway** (root `package.json`, `9router-app`) — the Next.js server that does the actual routing. +- The **CLI launcher** (`cli/`, published to npm as `9router`) — a separate package that installs/starts the server and manages the tray. It has its own `package.json`, version, and build. + +The code lives in `src/` (Next.js app + dashboard/compat APIs), `open-sse/` (the provider-agnostic routing/translation engine), `cli/` (the launcher package), and `tests/`. + +## Commands + +Dashboard/gateway (run from repo root): +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev # dev (webpack, port 20127 by default via next dev) +npm run build && PORT=20128 HOSTNAME=0.0.0.0 npm run start # production +``` +- Bun variants: `npm run dev:bun` / `build:bun` / `start:bun`. +- Default runtime port is **20128** (dashboard at `/dashboard`, API at `/v1`). +- Lint: `npx eslint .` (config `eslint.config.mjs`, extends `eslint-config-next`). + +CLI package (`cli/`): +```bash +npm run cli:pack # build + npm pack from root +cd cli && npm run dev # nodemon watch +``` + +Tests (vitest, in `tests/`, an **independent** ESM package — not wired into root `npm test`): +```bash +npm install # ROOT deps first — tests import from src/ which needs `open`, `undici`, etc. +cd tests && npm install # then tests' own deps (vitest) → tests/node_modules (allowed by tests/.gitignore) +npx vitest run # all tests; auto-discovers tests/vitest.config.js +npx vitest run unit/capabilities.test.js # single file (path relative to tests/) +``` +> The committed `tests/package.json` `test` script hardcodes Unix paths (`NODE_PATH=/tmp/node_modules …`) — a shared-install workaround from upstream. On Windows (or anywhere), ignore it and use the `npx vitest` form above; `vitest.config.js` resolves the `open-sse`/`@/` aliases from the repo root regardless of where vitest lives. +> +> **The suite is NOT expected to be all-green on a plain checkout.** ~938 pass, ~64 fail. Judge regressions with `tests/__baseline__/verify-no-regression.mjs`, not a raw run. Expected red: +> - 26 catalogued in `tests/__baseline__/known-fails.txt` (rtk, oauth-cursor-auto-import, translator-request-normalization, …). +> - `unit/embeddings.cloud.test.js` imports `cloud/src/handlers/embeddings.js` — the `cloud/` worker dir is **not in this repo**, so it always fails here. +> - `unit/xai-oauth-service.test.js` times out (5s) when the xAI endpoint-discovery fetch isn't reachable/mocked. +> - `real/*.real.test.js` make live provider calls — need credentials, skip otherwise. +- `*.real.test.js` under `tests/translator/real/` make live provider calls — skip unless credentials are set. +- Regression baselines: `tests/__baseline__/verify-*.mjs` compare against committed snapshots (providers, aliases, OAuth URLs). Run these after touching provider registry / alias logic. + +## Architecture + +Two authoritative docs already exist — read them before working in these areas rather than re-deriving: +- `docs/ARCHITECTURE.md` — full system: request lifecycle, combo/account fallback, OAuth + token refresh, cloud sync, data model. +- `open-sse/AGENTS.md` — the routing/translation engine's own conventions and "how to add a provider/executor/translator". **Read this before editing anything under `open-sse/`.** + +### Request flow (the thing to understand first) +`src/app/api/v1/*` route (Next rewrite maps `/v1/*` → `/api/v1/*` in `next.config.mjs`) +→ `src/sse/handlers/chat.js` (parse, combo expansion, account-selection loop) +→ `open-sse/handlers/chatCore.js` (detect source format, translate request, dispatch to executor, retry/refresh, stream setup) +→ `open-sse/executors/*` (per-provider upstream call; `default.js` handles any OpenAI-compatible provider) +→ `open-sse/translator/*` (client format ↔ provider format) +→ SSE back to client. + +`src/sse/` is the app-side entry glue; `open-sse/` is the provider-agnostic engine (also usable standalone). Cross that boundary consciously. + +### Translator engine (`open-sse/translator/`) +- Pivots through **OpenAI as the intermediate format**. A translator registered on an exact `source:target` pair (e.g. `claude:kiro`) runs as a **direct route**, skipping the lossy double-hop. Prefer a direct route for fragile pairs (thinking blocks, tool ids, non-base64 images, `is_error`). +- Translators **self-register** via `register(from, to, reqFn, resFn)` as an import side effect — a new translator file MUST be imported in `open-sse/translator/index.js` or it never runs. +- Never hardcode role/block/model strings — use `open-sse/translator/schema/` and `open-sse/config/` constants. Config-driven and DRY is enforced by convention here. + +### Provider registry (`open-sse/providers/registry/*`) +- One file per provider. `providers/registry/index.js` is an **auto-generated** static import list — regenerate it with `scripts/migrate-registry.mjs` / `injectDisplayToRegistry.mjs`, don't hand-edit. +- Add a provider: copy `providers/REGISTRY_TEMPLATE.js`, add models to `config/providerModels.js`. Only add an executor for non-OpenAI-compatible upstreams. + +### Persistence — IMPORTANT (ARCHITECTURE.md is stale here) +State is **no longer `db.json`**. It's a SQLite layer under `src/lib/db/` with an adapter fallback chain (`driver.js`): `bun:sqlite` → `better-sqlite3` (optional native dep) → `node:sqlite` (Node ≥22.5) → `sql.js` (pure-JS fallback, always works). `better-sqlite3` is deliberately in `optionalDependencies` so install never fails without build tools. +- `src/lib/localDb.js` is a **backward-compat shim** re-exporting `src/lib/db/index.js`. New code should import from `@/lib/db/index.js`; per-entity logic lives in `src/lib/db/repos/*`. Schema/migrations in `src/lib/db/migrations/`. +- DB file location resolves via `src/lib/db/paths.js` (`DATA_DIR`, else `~/.9router/`). +- Usage/logs (`src/lib/usageDb.js`, `usage.json` + `log.txt`) still live under `~/.9router` and do **not** follow `DATA_DIR`. + +### RTK token saver (`open-sse/rtk/`) +Pre-translate hooks that compress `tool_result` content in-place to cut tokens. **Fail-open**: any error returns null and leaves the body untouched — never throw out of them. Skips `is_error`/`status:"error"` results to preserve traces. + +## Conventions & gotchas + +- Plain JavaScript (ESM), no TypeScript. `@/*` path alias → `src/*` (`jsconfig.json`). +- `custom-server.js` wraps the Next standalone server to derive client IP from the TCP socket and strip attacker-controlled `X-Forwarded-For` — trusting forwarding headers only from a loopback reverse proxy. Preserve this when touching request/IP/rate-limit code. +- Security-sensitive env: `JWT_SECRET` (session cookie), `INITIAL_PASSWORD` (default `123456` — must override), `API_KEY_SECRET`, `MACHINE_ID_SALT`. Full env contract in `.env.example` and ARCHITECTURE.md's env matrix. +- Binary/protobuf upstreams (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — they're handled inside their own executor, not the translator. +- Versioning: root and `cli/` are versioned independently; changes are logged in `CHANGELOG.md`. Commit style is Conventional Commits (`fix(translator): …`, `feat(...)`). diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..1f280d9 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,132 @@ +# Docker + +Run 9Router in a container. Published image: [`decolua/9router`](https://hub.docker.com/r/decolua/9router) — multi-platform `linux/amd64` + `linux/arm64`. + +--- + +# 👤 For Users + +## Quick start + +```bash +docker run -d \ + -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + --name 9router \ + decolua/9router:latest +``` + +App listens on port `20128`. Open: http://localhost:20128 + +## Manage container + +```bash +docker logs -f 9router # view logs +docker stop 9router # stop +docker start 9router # start again +docker rm -f 9router # remove +``` + +## Data persistence + +```bash +-v "$HOME/.9router:/app/data" \ +-e DATA_DIR=/app/data +``` + +Without `DATA_DIR`, the app falls back to `~/.9router/` (macOS/Linux) or `%APPDATA%\9router\` (Windows). In the container, `DATA_DIR=/app/data` makes the bind mount work. + +Data layout under `$DATA_DIR/`: + +```text +$DATA_DIR/ +├── db/ +│ ├── data.sqlite # main SQLite database +│ └── backups/ # auto backups +└── ... # certs, logs, runtime configs +``` + +Host path: `$HOME/.9router/db/data.sqlite` +Container path: `/app/data/db/data.sqlite` + +## Optional env vars + +```bash +docker run -d \ + -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + -e PORT=20128 \ + -e HOSTNAME=0.0.0.0 \ + -e DEBUG=true \ + --name 9router \ + decolua/9router:latest +``` + +## Optional Headroom sidecar + +The 9Router image does not bundle Python or Headroom. To use Headroom in Docker, run it as a separate service and point 9Router at that proxy: + +```yaml +services: + 9router: + image: decolua/9router:latest + ports: + - "20128:20128" + volumes: + - "$HOME/.9router:/app/data" + environment: + DATA_DIR: /app/data + HEADROOM_URL: http://headroom:8787 + depends_on: + - headroom + + headroom: + image: ghcr.io/chopratejas/headroom:latest + ports: + - "8787:8787" +``` + +In the dashboard, open `Endpoint` → `Token Saver` → `Headroom`, confirm the URL is `http://headroom:8787`, recheck status, then enable Headroom. + +If Headroom runs on the Docker host instead of as a sidecar, use `http://host.docker.internal:8787` on macOS/Windows. On Linux, add `--add-host=host.docker.internal:host-gateway` or the equivalent compose `extra_hosts` entry. + +## Update to latest + +```bash +docker pull decolua/9router:latest +docker rm -f 9router +# re-run the quick start command +``` + +--- + +# 🛠 For Developers + +## Build image locally (test) + +```bash +cd app && docker build -t 9router . + +docker run --rm -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + 9router +``` + +## Publish (automatic via CI) + +Push a git tag `v*` → GitHub Actions builds multi-platform (amd64+arm64) and pushes to: +- `ghcr.io/decolua/9router:v{version}` + `:latest` +- `decolua/9router:v{version}` + `:latest` + +```bash +# Use scripts/release.js (recommended) +node scripts/release.js "Release title" "Notes" + +# Or manually +git tag v0.4.x && git push origin v0.4.x +``` + +Workflow: `app/.github/workflows/docker-publish.yml` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5abe1f2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# syntax=docker/dockerfile:1.7 +ARG NODE_IMAGE=node:22-alpine +FROM ${NODE_IMAGE} AS base +WORKDIR /app + +FROM base AS builder + +RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers + +COPY package.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm install + +COPY . ./ +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM ${NODE_IMAGE} AS runner +WORKDIR /app + +LABEL org.opencontainers.image.title="9router" + +ENV NODE_ENV=production +ENV PORT=20128 +ENV HOSTNAME=0.0.0.0 +ENV NEXT_TELEMETRY_DISABLED=1 +ENV DATA_DIR=/app/data + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/custom-server.js ./custom-server.js +COPY --from=builder /app/open-sse ./open-sse +# Next file tracing can omit sibling files; MITM runs server.js as a separate process. +COPY --from=builder /app/src/mitm ./src/mitm +# Standalone node_modules may omit deps only required by the MITM child process. +COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge +# Ensure `next` is available at runtime in case tracing did not include it. +COPY --from=builder /app/node_modules/next ./node_modules/next + +RUN mkdir -p /app/data && chown -R node:node /app && \ + mkdir -p /app/data-home && chown node:node /app/data-home && \ + ln -sf /app/data-home /root/.9router 2>/dev/null || true + +# Fix permissions at runtime (handles mounted volumes) +RUN apk --no-cache upgrade && apk --no-cache add su-exec && \ + printf '#!/bin/sh\nchown -R node:node /app/data /app/data-home 2>/dev/null\nexec su-exec node "$@"\n' > /entrypoint.sh && \ + chmod +x /entrypoint.sh + +EXPOSE 20128 + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["node", "custom-server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9219235 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2026 decolua and 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.md b/README.md new file mode 100644 index 0000000..b6cd76b --- /dev/null +++ b/README.md @@ -0,0 +1,1444 @@ +
+ 9Router Dashboard + + # 9Router - FREE AI Router & Token Saver + + **Never stop coding. Save 20-40% tokens with RTK + auto-fallback to FREE & cheap AI models.** + + **Connect All AI Code Tools (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) to 40+ AI Providers & 100+ Models.** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![Docker Pulls](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/decolua/9router) + [![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + +decolua%2F9router | Trendshift + +[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com) + +[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) + +
+ +--- + +## 🤔 Why 9Router? + +**Stop wasting money, tokens and hitting limits:** + +- ❌ Subscription quota expires unused every month +- ❌ Rate limits stop you mid-coding +- ❌ Tool outputs (git diff, grep, ls...) burn tokens fast +- ❌ Expensive APIs ($20-50/month per provider) +- ❌ Manual switching between providers + +**9Router solves this:** + +- ✅ **RTK Token Saver** - Auto-compress tool_result content, save 20-40% tokens per request +- ✅ **Maximize subscriptions** - Track quota, use every bit before reset +- ✅ **Auto fallback** - Subscription → Cheap → Free, zero downtime +- ✅ **Multi-account** - Round-robin between accounts per provider +- ✅ **Universal** - Works with Claude Code, Codex, Cursor, Cline, any CLI tool + +--- + +## 🔄 How It Works + +``` +┌─────────────┐ +│ Your CLI │ (Claude Code, Codex, OpenClaw, Cursor, Cline...) +│ Tool │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────────┐ +│ 9Router (Smart Router) │ +│ • RTK Token Saver (cut tool_result tokens) │ +│ • Format translation (OpenAI ↔ Claude) │ +│ • Quota tracking │ +│ • Auto token refresh │ +└──────┬──────────────────────────────────────┘ + │ + ├─→ [Tier 1: SUBSCRIPTION] Claude Code, Codex, GitHub Copilot + │ ↓ quota exhausted + ├─→ [Tier 2: CHEAP] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ budget limit + └─→ [Tier 3: FREE] Kiro, OpenCode Free, Vertex ($300 credits) + +Result: Never stop coding, minimal cost + 20-40% token savings via RTK +``` + +--- + +## ⚡ Quick Start + +**1. Install globally:** + +```bash +npm install -g 9router +9router +``` + +🎉 Dashboard opens at `http://localhost:20128` + +**2. Connect a FREE provider (no signup needed):** + +Dashboard → Providers → Connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → Done! + +**3. Use in your CLI tool:** + +``` +Claude Code/Codex/OpenClaw/Cursor/Cline Settings: + Endpoint: http://localhost:20128/v1 + API Key: [copy from dashboard] + Model: kr/claude-sonnet-4.5 +``` + +**That's it!** Start coding with FREE AI models. + +**Alternative: run from source (this repository):** + +This repository package is private (`9router-app`), so source/Docker execution is the expected local development path. + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +Production mode: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +Default URLs: + +- Dashboard: `http://localhost:20128/dashboard` +- OpenAI-compatible API: `http://localhost:20128/v1` + +--- + +## Video Guides + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Tiết kiệm chi phí LLM với 9Router +
+ 🇻🇳 Tiếng Việt
+ Tiết kiệm chi phí LLM cho OpenClaw với 9Router
by Mì AI
+
+ + 9Router + Claude Code FREE Unlimited Setup +
+ 🇵🇰 اردو / हिन्दी
+ 9Router + Claude Code FREE Unlimited Setup
by Build AI With Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 English
+ 9Router + Claude Code FREE Setup
by Build AI With Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 English
+ 9Router + Claude Code FREE Setup
by Build AI With Hamid
+
+ + Claude Code FREE Forever +
+ 🇺🇸 English
+ Claude Code FREE Forever — Unlimited Models
by Build AI With Hamid
+
+ + Claude CLI Free Setup +
+ 🇺🇸 English
+ Claude CLI Free Setup with 9Router 🚀
by CodeVerse Soban
+
+ + Cài đặt OpenClaw Free A-Z +
+ 🇻🇳 Tiếng Việt
+ Cài Đặt OpenClaw Free Từ A-Z + 9Router
by Mai Gia
+
+ + FREE OpenClaw with Claude Opus +
+ 🇺🇸 English
+ FREE OpenClaw + Claude Opus 4.6
by Build AI With Hamid
+
+ + Claude CLI Free Setup +
+ 🇮🇩 Indonesia
+ Koding 24 Jam Anti Rate Limit! Hemat Token AI 65% | Tutorial Quick Setup 9Router 🚀
by Krisswuh
+
+ + Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB +
+ 🇮🇩 Indonesia
+ Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB
by Krisswuh
+
+ +
+ +> 🎬 **Made a video about 9Router?** Submit a [Pull Request](https://github.com/decolua/9router/pulls) adding your video to this section — we'll merge it! + +--- + +## 🛠️ Supported CLI Tools + +9Router works seamlessly with all major AI coding tools: + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## 🌐 Supported Providers + +### 🔐 OAuth Providers + +
+ + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+ Kimchi
+ Kimchi +
+
+ +### 🆓 Free Providers + +
+ + + + + + +
+ Kiro
+ Kiro AI
+ Claude 4.5 + GLM-5 + MiniMax
Unlimited FREE
+
+ OpenCode Free
+ OpenCode Free
+ No auth • Auto-fetch models
Unlimited FREE
+
+ Vertex AI
+ Vertex AI
+ Gemini 3 Pro + GLM-5 + DeepSeek
$300 credits free
+
+
+ +> **Note:** iFlow, Qwen and Gemini CLI free tiers were discontinued in 2026. Use Kiro / OpenCode Free / Vertex instead. + +### 🔑 API Key Providers (40+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

...and 20+ more providers including Nebius, Chutes, Hyperbolic, and custom OpenAI/Anthropic compatible endpoints

+
+ +--- + +## 💡 Key Features + +| Feature | What It Does | Why It Matters | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------- | +| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compress tool outputs (`git diff`, `grep`, `ls`, `tree`...) before sending to LLM | Save **20-40% input tokens** per request | +| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | Optional external `/v1/compress` proxy before provider routing | Save more context tokens without changing clients | +| 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Inject caveman-speak prompt → LLM replies terse, technical substance preserved | Save **up to 65% output tokens** | +| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Inject "lazy senior dev" prompt → LLM writes minimal, YAGNI-first code (Lite/Full/Ultra) | **Fewer output tokens, less refactoring** | +| 🎯 **Smart 3-Tier Fallback** | Auto-route: Subscription → Cheap → Free | Never stop coding, zero downtime | +| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown | Maximize subscription value | +| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Works with any CLI tool | +| 👥 **Multi-Account Support** | Multiple accounts per provider | Load balancing + redundancy | +| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically | No manual re-login needed | +| 🎨 **Custom Combos** | Create unlimited model combinations | Tailor fallback to your needs | +| 📝 **Request Logging** | Debug mode with full request/response logs | Troubleshoot issues easily | +| 💾 **Cloud Sync** | Sync config across devices | Same setup everywhere | +| 📊 **Usage Analytics** | Track tokens, cost, trends over time | Optimize spending | +| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloudflare Workers | Flexible deployment options | + +
+📖 Feature Details + +### 🚀 RTK Token Saver + +Tool outputs (`git diff`, `grep`, `find`, `ls`, `tree`, log dumps...) often eat 30-50% of your prompt budget. RTK detects them and applies smart, lossless compression **before** the request hits the LLM: + +- **Filters:** `git-diff`, `git-status`, `grep`, `find`, `ls`, `tree`, `dedup-log`, `smart-truncate`, `read-numbered`, `search-list` +- **Auto-detect:** No config needed — RTK peeks the first 1KB of each `tool_result` and picks the right filter. +- **Safe by design:** If a filter fails, throws, or makes output bigger, RTK silently keeps the original text. Errors never break your request. +- **Universal:** Works across all formats (OpenAI, Claude, Gemini, Cursor, Kiro, OpenAI Responses) because it runs **before** any format translation. +- **Default ON:** Toggle anytime in Dashboard → Endpoint settings. + +``` +Without RTK: 47K tokens sent to LLM +With RTK: 28K tokens sent to LLM (40% saved · same context · same answer) +``` + +### 🧠 Headroom Token Saver + +Headroom is optional and runs separately. 9Router calls Headroom's local `/v1/compress` endpoint, then keeps normal routing, fallback, auth, and usage tracking: + +``` +Client → 9Router → Headroom /v1/compress → 9Router → provider +``` + +Local setup: + +```bash +pip install "headroom-ai[proxy]" +headroom proxy --port 8787 +``` + +Enable in Dashboard → Endpoint → Token Saver → Headroom. Default URL: `http://localhost:8787`. + +Docker examples: + +```bash +# Headroom service in same Docker network +http://headroom:8787 + +# Headroom running on host machine +http://host.docker.internal:8787 +``` + +If Headroom is down or returns an error, 9Router fails open and sends the original request. + +### 🐴 Ponytail (Lazy Senior Dev) + +Ponytail injects a _"lazy senior dev"_ system prompt into every request, biasing the LLM toward minimal, YAGNI-first code — deletion over addition, stdlib over new deps, one-liners over abstractions. Adapted from [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail). + +- **Lite** — Build what's asked, name the lazier alternative. +- **Full** — YAGNI ladder enforced: stdlib → native → existing deps → one-liner → minimal code. +- **Ultra** — YAGNI extremist: deletion first, ship the one-liner, challenge the rest of the requirement in the same response. + +``` +Without Ponytail: verbose code, extra abstractions, "just in case" scaffolding +With Ponytail: shortest working diff, no unrequested abstractions, fewer tokens +``` + +Never trades away: input validation, error handling that prevents data loss, security, accessibility, or anything explicitly requested. Enable in Dashboard → Endpoint → Ponytail. Stacks with Caveman (output terseness) and RTK (input compression). + +### 🎯 Smart 3-Tier Fallback + +Create combos with automatic fallback: + +``` +Combo: "my-coding-stack" + 1. cc/claude-opus-4-6 (your subscription) + 2. glm/glm-4.7 (cheap backup, $0.6/1M) + 3. if/kimi-k2-thinking (free fallback) + +→ Auto switches when quota runs out or errors occur +``` + +### 📊 Real-Time Quota Tracking + +- Token consumption per provider +- Reset countdown (5-hour, daily, weekly) +- Cost estimation for paid tiers +- Monthly spending reports + +### 🔄 Format Translation + +Seamless translation between formats: + +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** +- Your CLI tool sends OpenAI format → 9Router translates → Provider receives native format +- Works with any tool that supports custom OpenAI endpoints + +### 👥 Multi-Account Support + +- Add multiple accounts per provider +- Auto round-robin or priority-based routing +- Fallback to next account when one hits quota + +### 🔄 Auto Token Refresh + +- OAuth tokens automatically refresh before expiration +- No manual re-authentication needed +- Seamless experience across all providers + +### 🎨 Custom Combos + +- Create unlimited model combinations +- Mix subscription, cheap, and free tiers +- Name your combos for easy access +- Share combos across devices with Cloud Sync + +### 📝 Request Logging + +- Enable debug mode for full request/response logs +- Track API calls, headers, and payloads +- Troubleshoot integration issues +- Export logs for analysis + +### 💾 Cloud Sync + +- Sync providers, combos, and settings across devices +- Automatic background sync +- Secure encrypted storage +- Access your setup from anywhere + +#### Cloud Runtime Notes + +- Prefer server-side cloud variables in production: + - `BASE_URL` (internal callback URL used by sync scheduler) + - `CLOUD_URL` (cloud sync endpoint base) +- `NEXT_PUBLIC_BASE_URL` and `NEXT_PUBLIC_CLOUD_URL` are still supported for compatibility/UI, but server runtime now prioritizes `BASE_URL`/`CLOUD_URL`. +- Cloud sync requests now use timeout + fail-fast behavior to avoid UI hanging when cloud DNS/network is unavailable. + +### 📊 Usage Analytics + +- Track token usage per provider and model +- Cost estimation and spending trends +- Monthly reports and insights +- Optimize your AI spending + +> **💡 IMPORTANT - Understanding Dashboard Costs:** +> +> The "cost" displayed in Usage Analytics is **for tracking and comparison purposes only**. +> 9Router itself **never charges** you anything. You only pay providers directly (if using paid services). +> +> **Example:** If your dashboard shows "$290 total cost" while using iFlow models, this represents +> what you would have paid using paid APIs directly. Your actual cost = **$0** (iFlow is free unlimited). +> +> Think of it as a "savings tracker" showing how much you're saving by using free models or +> routing through 9Router! + +### 🌐 Deploy Anywhere + +- 💻 **Localhost** - Default, works offline +- ☁️ **VPS/Cloud** - Share across devices +- 🐳 **Docker** - One-command deployment +- 🚀 **Cloudflare Workers** - Global edge network + +
+ +--- + +## 💰 Pricing at a Glance + +| Tier | Provider | Cost | Quota Reset | Best For | +| ------------------- | --------------------- | ------------ | ---------------- | --------------------------------------- | +| **🚀 TOKEN SAVER** | **RTK (built-in)** | **FREE** | Always on | **Save 20-40% tokens on EVERY request** | +| **💳 SUBSCRIPTION** | Claude Code (Pro/Max) | $20-200/mo | 5h + weekly | Already subscribed | +| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users | +| | GitHub Copilot | $10-19/mo | Monthly | GitHub users | +| | Cursor IDE | $20/mo | Monthly | Cursor users | +| **💰 CHEAP** | GLM-5.1 / GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup | +| | MiniMax M2.7 | $0.2/1M | 5-hour rolling | Cheapest option | +| | Kimi K2.5 | $9/mo flat | 10M tokens/mo | Predictable cost | +| **🆓 FREE** | Kiro AI | $0 | Unlimited | Claude 4.5 + GLM-5 + MiniMax free | +| | OpenCode Free | $0 | Unlimited | No auth, auto-fetch models | +| | Vertex AI | $300 credits | New GCP accounts | Gemini 3 Pro + DeepSeek + GLM-5 | + +**💡 Pro Tip:** RTK + Kiro AI + OpenCode Free combo = **$0 cost + 20-40% token savings**! + +--- + +### 📊 Understanding 9Router Costs & Billing + +**9Router Billing Reality:** + +✅ **9Router software = FREE forever** (open source, never charges) +✅ **Dashboard "costs" = Display/tracking only** (not actual bills) +✅ **You pay providers directly** (subscriptions or API fees) +✅ **FREE providers stay FREE** (iFlow, Kiro, Qwen = $0 unlimited) +❌ **9Router never sends invoices** or charges your card + +**How Cost Display Works:** + +The dashboard shows **estimated costs** as if you were using paid APIs directly. This is **not billing** - it's a comparison tool to show your savings. + +**Example Scenario:** + +``` +Dashboard Display: +• Total Requests: 1,662 +• Total Tokens: 47M +• Display Cost: $290 + +Reality Check: +• Provider: iFlow (FREE unlimited) +• Actual Payment: $0.00 +• What $290 Means: Amount you SAVED by using free models! +``` + +**Payment Rules:** + +- **Subscription providers** (Claude Code, Codex): Pay them directly via their websites +- **Cheap providers** (GLM, MiniMax): Pay them directly, 9Router just routes +- **FREE providers** (iFlow, Kiro, Qwen): Genuinely free forever, no hidden charges +- **9Router**: Never charges anything, ever + +--- + +## 🎯 Use Cases + +### Case 1: "I have Claude Pro subscription" + +**Problem:** Quota expires unused, rate limits during heavy coding + +**Solution:** + +``` +Combo: "maximize-claude" + 1. cc/claude-opus-4-7 (use subscription fully) + 2. glm/glm-5.1 (cheap backup when quota out) + 3. kr/claude-sonnet-4.5 (free emergency fallback) + +Monthly cost: $20 (subscription) + ~$5 (backup) = $25 total +vs. $20 + hitting limits = frustration +``` + +### Case 2: "I want zero cost" + +**Problem:** Can't afford subscriptions, need reliable AI coding + +**Solution:** + +``` +Combo: "free-forever" + 1. kr/claude-sonnet-4.5 (Claude 4.5 free unlimited) + 2. kr/glm-5 (GLM-5 free via Kiro) + 3. oc/ (OpenCode Free, no auth) + +Monthly cost: $0 +Quality: Production-ready models + RTK saves 20-40% tokens +``` + +### Case 3: "I need 24/7 coding, no interruptions" + +**Problem:** Deadlines, can't afford downtime + +**Solution:** + +``` +Combo: "always-on" + 1. cc/claude-opus-4-7 (best quality) + 2. cx/gpt-5.5 (second subscription) + 3. glm/glm-5.1 (cheap, resets daily) + 4. minimax/MiniMax-M2.7 (cheapest, 5h reset) + 5. kr/claude-sonnet-4.5 (free unlimited) + +Result: 5 layers of fallback = zero downtime +Monthly cost: $20-200 (subscriptions) + $10-20 (backup) +``` + +### Case 4: "I want FREE AI in OpenClaw" + +**Problem:** Need AI assistant in messaging apps (WhatsApp, Telegram, Slack...), completely free + +**Solution:** + +``` +Combo: "openclaw-free" + 1. kr/claude-sonnet-4.5 (Claude 4.5 free) + 2. kr/glm-5 (GLM-5 free) + 3. kr/MiniMax-M2.5 (MiniMax free) + +Monthly cost: $0 +Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +``` + +--- + +## ❓ Frequently Asked Questions + +
+📊 Why does my dashboard show high costs? + +The dashboard tracks your token usage and displays **estimated costs** as if you were using paid APIs directly. This is **not actual billing** - it's a reference to show how much you're saving by using free models or existing subscriptions through 9Router. + +**Example:** + +- **Dashboard shows:** "$290 total cost" +- **Reality:** You're using iFlow (FREE unlimited) +- **Your actual cost:** **$0.00** +- **What $290 means:** Amount you **saved** by using free models instead of paid APIs! + +The cost display is a "savings tracker" to help you understand your usage patterns and optimization opportunities. + +
+ +
+💳 Will I be charged by 9Router? + +**No.** 9Router is free, open-source software that runs on your own computer. It never charges you anything. + +**You only pay:** + +- ✅ **Subscription providers** (Claude Code $20/mo, Codex $20-200/mo) → Pay them directly on their websites +- ✅ **Cheap providers** (GLM, MiniMax) → Pay them directly, 9Router just routes your requests +- ❌ **9Router itself** → **Never charges anything, ever** + +9Router is a local proxy/router. It doesn't have your credit card, can't send invoices, and has no billing system. It's completely free software. + +
+ +
+🆓 Are FREE providers really unlimited? + +**Yes!** The current FREE providers (Kiro, OpenCode Free, Vertex) are genuinely free with **no hidden charges**. + +These are free services offered by those respective companies: + +- **Kiro AI**: Free unlimited Claude 4.5 + GLM-5 + MiniMax via AWS Builder ID / Google / GitHub OAuth +- **OpenCode Free**: No-auth passthrough proxy, models auto-fetched from `opencode.ai/zen/v1/models` +- **Vertex AI**: $300 free credits for new Google Cloud accounts (90 days) + +9Router just routes your requests to them - there's no "catch" or future billing. They're truly free services, and 9Router makes them easy to use with fallback support. + +**Discontinued free tiers (no longer recommended):** + +- ❌ **iFlow**: Was free unlimited, now changed to paid (2026) +- ❌ **Qwen Code**: Free OAuth tier discontinued by Alibaba on 2026-04-15 +- ❌ **Gemini CLI**: Still works, but using it with non-CLI tools (Claude, Codex, Cursor...) may result in account bans — only use if you stick to Gemini CLI itself + +
+ +
+💰 How do I minimize my actual AI costs? + +**Free-First Strategy:** + +1. **Start with 100% free combo:** + + ``` + 1. gc/gemini-3-flash (180K/month free from Google) + 2. if/kimi-k2-thinking (unlimited free from iFlow) + 3. qw/qwen3-coder-plus (unlimited free from Qwen) + ``` + + **Cost: $0/month** + +2. **Add cheap backup** only if you need it: + + ``` + 4. glm/glm-4.7 ($0.6/1M tokens) + ``` + + **Additional cost: Only pay for what you actually use** + +3. **Use subscription providers last:** + - Only if you already have them + - 9Router helps maximize their value through quota tracking + +**Result:** Most users can operate at $0/month using only free tiers! + +
+ +
+📈 What if my usage suddenly spikes? + +9Router's smart fallback prevents surprise charges: + +**Scenario:** You're on a coding sprint and blow through your quotas + +**Without 9Router:** + +- ❌ Hit rate limit → Work stops → Frustration +- ❌ Or: Accidentally rack up huge API bills + +**With 9Router:** + +- ✅ Subscription hits limit → Auto-fallback to cheap tier +- ✅ Cheap tier gets expensive → Auto-fallback to free tier +- ✅ Never stop coding → Predictable costs + +**You're in control:** Set spending limits per provider in dashboard, and 9Router respects them. + +
+ +--- + +## 📖 Setup Guide + +
+🔐 Subscription Providers (Maximize Value) + +### Claude Code (Pro/Max) + +```bash +Dashboard → Providers → Connect Claude Code +→ OAuth login → Auto token refresh +→ 5-hour + weekly quota tracking + +Models: + cc/claude-opus-4-7 + cc/claude-opus-4-6 + cc/claude-sonnet-4-6 + cc/claude-haiku-4-5-20251001 +``` + +**Pro Tip:** Use Opus for complex tasks, Sonnet for speed. 9Router tracks quota per model! + +### OpenAI Codex (Plus/Pro) + +```bash +Dashboard → Providers → Connect Codex +→ OAuth login (port 1455) +→ 5-hour + weekly reset + +Models: + cx/gpt-5.5 + cx/gpt-5.4 + cx/gpt-5.3-codex + cx/gpt-5.2-codex +``` + +### GitHub Copilot + +```bash +Dashboard → Providers → Connect GitHub +→ OAuth via GitHub +→ Monthly reset (1st of month) + +Models: + gh/gpt-5.4 + gh/claude-opus-4.7 + gh/claude-sonnet-4.6 + gh/gemini-3.1-pro-preview + gh/grok-code-fast-1 +``` + +### Cursor IDE + +```bash +Dashboard → Providers → Connect Cursor +→ OAuth login +→ Monthly subscription + +Models: + cu/claude-4.6-opus-max + cu/claude-4.5-sonnet-thinking + cu/gpt-5.3-codex +``` + +
+ +
+💰 Cheap Providers (Backup) + +### GLM-5.1 / GLM-4.7 (Daily reset, $0.6/1M) + +1. Sign up: [Zhipu AI](https://open.bigmodel.cn/) +2. Get API key from Coding Plan +3. Dashboard → Add API Key: + - Provider: `glm` + - API Key: `your-key` + +**Use:** `glm/glm-5.1`, `glm/glm-5`, `glm/glm-4.7` + +**Pro Tip:** Coding Plan offers 3× quota at 1/7 cost! Reset daily 10:00 AM. + +### MiniMax M2.7 (5h reset, $0.20/1M) + +1. Sign up: [MiniMax](https://www.minimax.io/) +2. Get API key +3. Dashboard → Add API Key + +**Use:** `minimax/MiniMax-M2.7`, `minimax/MiniMax-M2.5` + +**Pro Tip:** Cheapest option for long context (1M tokens)! + +### Kimi K2.5 ($9/month flat) + +1. Subscribe: [Moonshot AI](https://platform.moonshot.ai/) +2. Get API key +3. Dashboard → Add API Key + +**Use:** `kimi/kimi-k2.5`, `kimi/kimi-k2.5-thinking` + +**Pro Tip:** Fixed $9/month for 10M tokens = $0.90/1M effective cost! + +
+ +
+🆓 FREE Providers (Recommended) + +### Kiro AI (Claude 4.5 + GLM-5 + MiniMax FREE) + +```bash +Dashboard → Connect Kiro +→ AWS Builder ID, AWS IAM Identity Center, Google, or GitHub +→ Unlimited usage + +Models: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 + kr/glm-5 + kr/MiniMax-M2.5 + kr/qwen3-coder-next + kr/deepseek-3.2 +``` + +**Pro Tip:** Best free option for Claude. No API key, no payment, fully unlimited. + +### OpenCode Free (No auth, auto-fetch models) + +```bash +Dashboard → Connect OpenCode Free +→ No login required (passthrough proxy) +→ Models auto-fetched from opencode.ai/zen/v1/models +``` + +**Pro Tip:** Fastest setup. Just connect and start coding. + +### Vertex AI ($300 free credits for new GCP accounts) + +```bash +Dashboard → Connect Vertex AI +→ Upload Google Cloud Service Account JSON +→ Enable Vertex AI API in your GCP project + +Models: + vertex/gemini-3.1-pro-preview + vertex/gemini-3-flash-preview + vertex/gemini-2.5-flash + +Vertex Partner (Anthropic / DeepSeek / GLM / Qwen via Vertex): + vertex-partner/glm-5-maas + vertex-partner/deepseek-v3.2-maas + vertex-partner/qwen3-next-80b-a3b-thinking-maas +``` + +**Pro Tip:** New Google Cloud accounts get $300 credits free for 90 days. Plenty for daily coding. + +
+ +
+🎨 Create Combos + +### Example 1: Maximize Subscription → Cheap Backup + +``` +Dashboard → Combos → Create New + +Name: premium-coding +Models: + 1. cc/claude-opus-4-7 (Subscription primary) + 2. glm/glm-5.1 (Cheap backup, $0.6/1M) + 3. minimax/MiniMax-M2.7 (Cheapest fallback, $0.20/1M) + +Use in CLI: premium-coding + +Monthly cost example (100M tokens): + 80M via Claude (subscription): $0 extra + 15M via GLM: $9 + 5M via MiniMax: $1 + Total: $10 + your subscription +``` + +### Example 2: Free-Only (Zero Cost) + +``` +Name: free-combo +Models: + 1. kr/claude-sonnet-4.5 (Claude 4.5 free unlimited) + 2. kr/glm-5 (GLM-5 free via Kiro) + 3. vertex/gemini-3.1-pro-preview ($300 free credits) + +Cost: $0 forever (+ 20-40% token savings via RTK)! +``` + +
+ +
+🔧 CLI Integration + +### Cursor IDE + +``` +Settings → Models → Advanced: + OpenAI API Base URL: http://localhost:20128/v1 + OpenAI API Key: [from 9router dashboard] + Model: cc/claude-opus-4-7 +``` + +Or use combo: `premium-coding` + +### Claude Code + +Edit `~/.claude/config.json`: + +```json +{ + "anthropic_api_base": "http://localhost:20128/v1", + "anthropic_api_key": "your-9router-api-key" +} +``` + +### Codex CLI + +```bash +export OPENAI_BASE_URL="http://localhost:20128" +export OPENAI_API_KEY="your-9router-api-key" + +codex "your prompt" +``` + +### OpenClaw + +**Option 1 — Dashboard (recommended):** + +``` +Dashboard → CLI Tools → OpenClaw → Select Model → Apply +``` + +**Option 2 — Manual:** Edit `~/.openclaw/openclaw.json`: + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "9router/kr/claude-sonnet-4.5" + } + } + }, + "models": { + "providers": { + "9router": { + "baseUrl": "http://127.0.0.1:20128/v1", + "apiKey": "sk_9router", + "api": "openai-completions", + "models": [ + { + "id": "kr/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (Kiro Free)" + } + ] + } + } + } +} +``` + +> **Note:** OpenClaw only works with local 9Router. Use `127.0.0.1` instead of `localhost` to avoid IPv6 resolution issues. + +### Cline / Continue / RooCode + +``` +Provider: OpenAI Compatible +Base URL: http://localhost:20128/v1 +API Key: [from dashboard] +Model: cc/claude-opus-4-7 +``` + +
+ +
+🚀 Deployment + +### VPS Deployment + +```bash +# Clone and install +git clone https://github.com/decolua/9router.git +cd 9router +npm install +npm run build + +# Configure +export JWT_SECRET="your-secure-secret-change-this" +export INITIAL_PASSWORD="your-password" +export DATA_DIR="/var/lib/9router" +export PORT="20128" +export HOSTNAME="0.0.0.0" +export NODE_ENV="production" +export NEXT_PUBLIC_BASE_URL="http://localhost:20128" +export NEXT_PUBLIC_CLOUD_URL="https://9router.com" +export API_KEY_SECRET="endpoint-proxy-api-key-secret" +export MACHINE_ID_SALT="endpoint-proxy-salt" + +# Start +npm run start + +# Or use PM2 +npm install -g pm2 +pm2 start npm --name 9router -- start +pm2 save +pm2 startup +``` + +### Docker + +Published images (multi-platform `linux/amd64` + `linux/arm64`): + +- Docker Hub: [`decolua/9router`](https://hub.docker.com/r/decolua/9router) +- GHCR: [`ghcr.io/decolua/9router`](https://github.com/decolua/9router/pkgs/container/9router) + +**Quick start (use published image):** + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + decolua/9router:latest +``` + +→ Open http://localhost:20128 + +**Build from source (dev):** + +```bash +git clone https://github.com/decolua/9router.git +cd 9router/app +docker build -t 9router . +docker run -d --name 9router -p 20128:20128 \ + -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data 9router +``` + +**Container defaults:** + +- `PORT=20128` +- `HOSTNAME=0.0.0.0` + +**Useful commands:** + +```bash +docker logs -f 9router +docker restart 9router +docker stop 9router && docker rm 9router +docker pull decolua/9router:latest # update to latest +``` + +**Data persistence:** `$HOME/.9router/db/data.sqlite` on host ↔ `/app/data/db/data.sqlite` in container. + +### Environment Variables + +| Variable | Default | Description | +| ---------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | +| `JWT_SECRET` | Auto-generated (`~/.9router/jwt-secret`) | JWT signing secret for dashboard auth cookie (override to share across instances) | +| `INITIAL_PASSWORD` | `123456` | First login password when no saved hash exists | +| `DATA_DIR` | `~/.9router` | Main app data location (SQLite at `$DATA_DIR/db/data.sqlite`) | +| `PORT` | framework default | Service port (`20128` in examples) | +| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) | +| `NODE_ENV` | runtime default | Set `production` for deploy | +| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL used by cloud sync jobs | +| `CLOUD_URL` | `https://9router.com` | Server-side cloud sync endpoint base URL | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Backward-compatible/public base URL (prefer `BASE_URL` for server runtime) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | Backward-compatible/public cloud URL (prefer `CLOUD_URL` for server runtime) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Salt for stable machine ID hashing | +| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs under `logs/` | +| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (set `true` behind HTTPS reverse proxy) | +| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` routes (recommended for internet-exposed deploys) | +| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | empty | Optional outbound proxy for upstream provider calls | +| `SEARXNG_URL` | `http://localhost:8888/search` | Endpoint for the built-in unauthenticated SearXNG web-search provider | + +Notes: + +- Lowercase proxy variables are also supported: `http_proxy`, `https_proxy`, `all_proxy`, `no_proxy`. +- `.env` is not baked into Docker image (`.dockerignore`); inject runtime config with `--env-file` or `-e`. +- On Windows, `APPDATA` can be used for local storage path resolution. +- `INSTANCE_NAME` appears in older docs/env templates, but is currently not used at runtime. + +### Runtime Files and Storage + +- Main app state: `${DATA_DIR}/db/data.sqlite` (SQLite — providers, combos, aliases, keys, settings, usage history) +- Auto backups: `${DATA_DIR}/db/backups/` +- Optional request/translator logs: `/logs/...` when `ENABLE_REQUEST_LOGS=true` +- Both `${DATA_DIR}` and `~/.9router` resolve to the same location in a Docker container — the symlink `/root/.9router -> /app/data` is created at build time. + +
+ +--- + +## 📊 Available Models + +
+View all available models + +**Claude Code (`cc/`)** - Pro/Max: + +- `cc/claude-opus-4-7` +- `cc/claude-opus-4-6` +- `cc/claude-sonnet-4-6` +- `cc/claude-sonnet-4-5-20250929` +- `cc/claude-haiku-4-5-20251001` + +**Codex (`cx/`)** - Plus/Pro: + +- `cx/gpt-5.5` +- `cx/gpt-5.4` +- `cx/gpt-5.3-codex` +- `cx/gpt-5.2-codex` +- `cx/gpt-5.1-codex-max` + +**GitHub Copilot (`gh/`)**: + +- `gh/gpt-5.4` +- `gh/claude-opus-4.7` +- `gh/claude-sonnet-4.6` +- `gh/gemini-3.1-pro-preview` +- `gh/grok-code-fast-1` + +**Cursor (`cu/`)** - Subscription: + +- `cu/claude-4.6-opus-max` +- `cu/claude-4.5-sonnet-thinking` +- `cu/gpt-5.3-codex` +- `cu/kimi-k2.5` + +**GLM (`glm/`)** - $0.6/1M: + +- `glm/glm-5.1` +- `glm/glm-5` +- `glm/glm-4.7` + +**MiniMax (`minimax/`)** - $0.2/1M: + +- `minimax/MiniMax-M2.7` +- `minimax/MiniMax-M2.5` + +**Kimi (`kimi/`)** - $9/mo flat: + +- `kimi/kimi-k2.5` +- `kimi/kimi-k2.5-thinking` + +**Kiro (`kr/`)** - FREE unlimited: + +- `kr/claude-sonnet-4.5` +- `kr/claude-haiku-4.5` +- `kr/glm-5` +- `kr/MiniMax-M2.5` +- `kr/qwen3-coder-next` +- `kr/deepseek-3.2` + +**OpenCode Free (`oc/`)** - FREE no-auth: + +- Auto-fetched from `opencode.ai/zen/v1/models` + +**Vertex AI (`vertex/`)** - $300 free credits: + +- `vertex/gemini-3.1-pro-preview` +- `vertex/gemini-3-flash-preview` +- `vertex/gemini-2.5-flash` +- `vertex-partner/glm-5-maas` +- `vertex-partner/deepseek-v3.2-maas` + +
+ +--- + +## 🐛 Troubleshooting + +**"Language model did not provide messages"** + +- Provider quota exhausted → Check dashboard quota tracker +- Solution: Use combo fallback or switch to cheaper tier + +**Rate limiting** + +- Subscription quota out → Fallback to GLM/MiniMax +- Add combo: `cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5` + +**OAuth token expired** + +- Auto-refreshed by 9Router +- If issues persist: Dashboard → Provider → Reconnect + +**High costs** + +- Enable RTK in Dashboard → Endpoint settings (default ON, saves 20-40% tokens) +- Check usage stats in Dashboard +- Switch primary model to GLM/MiniMax +- Use free tier (Kiro, OpenCode Free, Vertex) for non-critical tasks + +**Dashboard opens on wrong port** + +- Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128` + +**First login not working** + +- Check `INITIAL_PASSWORD` in `.env` +- If unset, fallback password is `123456` + +**No request logs under `logs/`** + +- Set `ENABLE_REQUEST_LOGS=true` + +--- + +## 🛠️ Tech Stack + +- **Runtime**: Node.js 20+ +- **Framework**: Next.js 16 +- **UI**: React 19 + Tailwind CSS 4 +- **Database**: SQLite (better-sqlite3 / node:sqlite / sql.js fallback) +- **Streaming**: Server-Sent Events (SSE) +- **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys + +--- + +## 📝 API Reference + +### Chat Completions + +```bash +POST http://localhost:20128/v1/chat/completions +Authorization: Bearer your-api-key +Content-Type: application/json + +{ + "model": "cc/claude-opus-4-6", + "messages": [ + {"role": "user", "content": "Write a function to..."} + ], + "stream": true +} +``` + +### List Models + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer your-api-key + +→ Returns all models + combos in OpenAI format +``` + +## 📧 Support + +- **Website**: [9router.com](https://9router.com) +- **GitHub**: [github.com/decolua/9router](https://github.com/decolua/9router) +- **Issues**: [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues) + +--- + +## 👥 Contributors + +Thanks to all contributors who helped make 9Router better! + +[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1&v=20260309)](https://github.com/decolua/9router/graphs/contributors) + +--- + +## 📊 Star Chart + +[![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) + +## 🔀 Forks + +**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — A full-featured TypeScript fork of 9Router. Adds 36+ providers, 4-tier auto-fallback, multi-modal APIs (images, embeddings, audio, TTS), circuit breaker, semantic cache, LLM evaluations, and a polished dashboard. 368+ unit tests. Available via npm and Docker. + +--- + +## 🙏 Acknowledgments + +Built on the shoulders of giants: + +- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — original Go implementation that inspired this JavaScript port. +- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — Rust token-saver. 9Router ports its compression pipeline to JS → **−20-40% input tokens** on every request. +- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral _"why use many token when few token do trick"_. 9Router adapts its prompt → **−65% output tokens**. +- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) by **[@DietrichGebert](https://github.com/DietrichGebert)** — _"lazy senior dev"_ skill. 9Router injects its YAGNI-first ladder → **fewer tokens, less code, shorter diffs**. + +Huge thanks to these authors — without their work, 9Router's token-saving features wouldn't exist. ⭐ them on GitHub! + +--- + +## 📄 License + +MIT License - see [LICENSE](LICENSE) for details. + +--- + +
+ Built with ❤️ for developers who code 24/7 +
diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..0241f22 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`decolua/9router` +- 原始仓库:https://github.com/decolua/9router +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..8ba77ff --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,1311 @@ + +
+ 9Router Dashboard + + # 9Router - 免费 AI 路由器与 Token 节省器 + + **编程永不停歇。使用 RTK + 自动切换到免费/低价 AI 模型,节省 20-40% 的 tokens。** + + **将所有 AI 编程工具(Claude Code、Cursor、Antigravity、Copilot、Codex、Gemini、OpenCode、Cline、OpenClaw...)连接到 40+ AI 提供商和 100+ 模型。** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + + decolua%2F9router | Trendshift + + [🚀 快速开始](#-快速开始) • [💡 功能特点](#-主要功能) • [📖 设置指南](#-设置指南) • [🌐 网站](https://9router.com) + + [🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) +
+ +--- + +## 🤔 为什么选择 9Router? + +**告别浪费金钱、tokens 和触碰限制的困扰:** + +- ❌ 订阅配额每月到期却未使用 +- ❌ 速率限制在编程中途打断你 +- ❌ 工具输出(git diff、grep、ls...)快速消耗 tokens +- ❌ 昂贵的 API(每个提供商 $20-50/月) +- ❌ 需要手动在提供商之间切换 + +**9Router 解决这一切:** + +- ✅ **RTK Token 节省器** - 自动压缩 tool_result 内容,每次请求节省 20-40% tokens +- ✅ **充分利用订阅** - 追踪配额,在重置前用尽每一分额度 +- ✅ **自动切换** - 订阅 → 低价 → 免费,零停机时间 +- ✅ **多账户支持** - 按提供商在账户之间轮询 +- ✅ **通用兼容** - 支持 Claude Code、Codex、Cursor、Cline 以及任何 CLI 工具 + +--- + +## 🔄 工作原理 + +``` +┌─────────────┐ +│ 你的 CLI │ (Claude Code、Codex、OpenClaw、Cursor、Cline...) +│ 工具 │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────────┐ +│ 9Router(智能路由器) │ +│ • RTK Token 节省器(减少 tool_result tokens)│ +│ • 格式转换(OpenAI ↔ Claude) │ +│ • 配额追踪 │ +│ • 自动刷新 token │ +└──────┬──────────────────────────────────────┘ + │ + ├─→ [第一层:订阅] Claude Code、Codex、GitHub Copilot + │ ↓ 配额耗尽 + ├─→ [第二层:低价] GLM ($0.6/1M)、MiniMax ($0.2/1M) + │ ↓ 预算超限 + └─→ [第三层:免费] Kiro、OpenCode Free、Vertex ($300 额度) + +结果:编程永不停歇,最小成本 + 通过 RTK 节省 20-40% tokens +``` + +--- + +## ⚡ 快速开始 + +**1. 全局安装:** + +```bash +npm install -g 9router +9router +``` + +🎉 控制面板在 `http://localhost:20128` 打开 + +**2. 连接免费提供商(无需注册):** + +控制面板 → 提供商 → 连接 **Kiro AI**(免费 Claude 无限量)或 **OpenCode Free**(无需认证)→ 完成! + +**3. 在 CLI 工具中使用:** + +``` +Claude Code/Codex/OpenClaw/Cursor/Cline 设置: + Endpoint: http://localhost:20128/v1 + API Key: [从控制面板复制] + Model: kr/claude-sonnet-4.5 +``` + +**就这么简单!** 开始使用免费 AI 模型编程。 + +**替代方案:从源码运行(本仓库):** + +本仓库的包是私有的(`9router-app`),所以源码/Docker 执行是预期的本地开发方式。 + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +生产模式: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +默认 URL: +- 控制面板:`http://localhost:20128/dashboard` +- OpenAI 兼容 API:`http://localhost:20128/v1` + +--- + +## 视频教程 + +
+ + + + + + + + + + + + +
+ + 9Router Setup Tutorial +
+ 🇺🇸 English
+ 9Router + Claude Code 免费设置
by Build AI With Hamid
+
+ + Tiết kiệm chi phí LLM với 9Router +
+ 🇻🇳 Tiếng Việt
+ 使用 9Router 节省 OpenClaw 的 LLM 成本
by Mì AI
+
+ + Claude Code FREE Forever +
+ 🇺🇸 English
+ Claude Code 免费永久使用 — 无限模型
by Build AI With Hamid
+
+ + Claude CLI Free Setup +
+ 🇺🇸 English
+ 使用 9Router 免费设置 Claude CLI 🚀
by CodeVerse Soban
+
+ + Cài đặt OpenClaw Free A-Z +
+ 🇻🇳 Tiếng Việt
+ 从零开始安装 OpenClaw 免费版 + 9Router
by Mai Gia
+
+ + FREE OpenClaw with Claude Opus +
+ 🇺🇸 English
+ 免费 OpenClaw + Claude Opus 4.6
by Build AI With Hamid
+
+ +
+ +> 🎬 **制作了关于 9Router 的视频?** 提交 [Pull Request](https://github.com/decolua/9router/pulls),将你的视频添加到此部分 — 我们会合并它! + +--- + +## 🛠️ 支持的 CLI 工具 + +9Router 与所有主流 AI 编程工具无缝协作: + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## 🌐 支持的提供商 + +### 🔐 OAuth 提供商 + +
+ + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+
+ +### 🆓 免费提供商 + +
+ + + + + + +
+ Kiro
+ Kiro AI
+ Claude 4.5 + GLM-5 + MiniMax
无限免费
+
+ OpenCode Free
+ OpenCode Free
+ 无需认证 • 自动获取模型
无限免费
+
+ Vertex AI
+ Vertex AI
+ Gemini 3 Pro + GLM-5 + DeepSeek
$300 免费额度
+
+
+ +> **注意:** iFlow、Qwen 和 Gemini CLI 的免费等级已于 2026 年停止。请改用 Kiro / OpenCode Free / Vertex。 + +### 🔑 API Key 提供商(40+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

...以及 20+ 更多提供商,包括 Nebius、Chutes、Hyperbolic 和自定义 OpenAI/Anthropic 兼容端点

+
+ +--- + +## 💡 主要功能 + +| 功能 | 作用 | 为什么重要 | +|---------|--------------|----------------| +| 🚀 **RTK Token 节省器**([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | 压缩工具输出(`git diff`、`grep`、`ls`、`tree`...)后再发送给 LLM | 每次请求节省 **20-40% 输入 tokens** | +| 🪨 **Caveman 模式**([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | 注入 caveman 风格提示词 → LLM 回复简洁,保留技术实质 | 节省 **高达 65% 输出 tokens** | +| 🎯 **智能三层切换** | 自动路由:订阅 → 低价 → 免费 | 编程永不停歇,零停机时间 | +| 📊 **实时配额追踪** | 实时 token 计数 + 重置倒计时 | 充分利用订阅价值 | +| 🔄 **格式转换** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | 兼容任何 CLI 工具 | +| 👥 **多账户支持** | 每个提供商支持多个账户 | 负载均衡 + 冗余备份 | +| 🔄 **自动 Token 刷新** | OAuth token 自动刷新 | 无需手动重新登录 | +| 🎨 **自定义组合** | 创建无限模型组合 | 自定义适合你的切换策略 | +| 📝 **请求日志** | 调试模式下的完整请求/响应日志 | 轻松排查问题 | +| 💾 **云同步** | 跨设备同步配置 | 处处相同设置 | +| 📊 **使用分析** | 追踪 tokens、成本、趋势 | 优化开支 | +| 🌐 **任意部署** | 本地、VPS、Docker、Cloudflare Workers | 灵活部署选项 | + +
+📖 功能详情 + +### 🚀 RTK Token 节省器 + +工具输出(`git diff`、`grep`、`find`、`ls`、`tree`、日志转储...)通常占用 30-50% 的提示词预算。RTK 在请求到达 LLM 之前检测并应用智能、无损压缩: + +- **过滤器:** `git-diff`、`git-status`、`grep`、`find`、`ls`、`tree`、`dedup-log`、`smart-truncate`、`read-numbered`、`search-list` +- **自动检测:** 无需配置 — RTK 检查每个 `tool_result` 的前 1KB,选择合适的过滤器。 +- **安全设计:** 如果过滤器失败、抛出异常或使输出变大,RTK 会静默保留原始文本。错误永远不会中断你的请求。 +- **通用兼容:** 适用于所有格式(OpenAI、Claude、Gemini、Cursor、Kiro、OpenAI Responses),因为它在任何格式转换**之前**运行。 +- **默认开启:** 可随时在控制面板 → 端点设置中切换。 + +``` +不使用 RTK:47K tokens 发送给 LLM +使用 RTK: 28K tokens 发送给 LLM (节省 40% · 相同上下文 · 相同答案) +``` + +### 🎯 智能三层切换 + +创建具有自动切换功能的组合: + +``` +组合:"my-coding-stack" + 1. cc/claude-opus-4-6 (你的订阅) + 2. glm/glm-4.7 (低价备份,$0.6/1M) + 3. if/kimi-k2-thinking (免费备选) + +→ 配额用完或出错时自动切换 +``` + +### 📊 实时配额追踪 + +- 每个提供商的 token 消耗 +- 重置倒计时(5小时、每日、每周) +- 付费等级的成本估算 +- 月度支出报告 + +### 🔄 格式转换 + +格式间无缝转换: +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** +- 你的 CLI 工具发送 OpenAI 格式 → 9Router 转换 → 提供商接收原生格式 +- 适用于任何支持自定义 OpenAI 端点的工具 + +### 👥 多账户支持 + +- 每个提供商添加多个账户 +- 自动轮询或基于优先级的路由 +- 当一个账户达到配额时切换到下一个 + +### 🔄 自动 Token 刷新 + +- OAuth token 在过期前自动刷新 +- 无需手动重新认证 +- 所有提供商的无缝体验 + +### 🎨 自定义组合 + +- 创建无限模型组合 +- 混合订阅、低价和免费等级 +- 为组合命名以便访问 +- 使用云同步跨设备共享组合 + +### 📝 请求日志 + +- 启用调试模式获取完整请求/响应日志 +- 追踪 API 调用、请求头和载荷 +- 排查集成问题 +- 导出日志进行分析 + +### 💾 云同步 + +- 跨设备同步提供商、组合和设置 +- 自动后台同步 +- 安全加密存储 +- 从任何地方访问你的设置 + +#### 云运行时说明 + +- 生产环境中优先使用服务端云变量: + - `BASE_URL`(云同步调度程序使用的内部回调 URL) + - `CLOUD_URL`(云同步端点基础 URL) +- `NEXT_PUBLIC_BASE_URL` 和 `NEXT_PUBLIC_CLOUD_URL` 仍用于兼容性/UI,但服务端运行时现在优先使用 `BASE_URL`/`CLOUD_URL`。 +- 云同步请求现在使用超时 + 快速失败行为,以避免云 DNS/网络不可用时 UI 挂起。 + +### 📊 使用分析 + +- 追踪每个提供商和模型的 token 使用量 +- 成本估算和支出趋势 +- 月度报告和洞察 +- 优化你的 AI 支出 + +> **💡 重要 - 了解控制面板成本:** +> +> 使用分析中显示的"成本"**仅用于追踪和比较目的**。 +> 9Router 本身**永远不会向你收费**。你只直接向提供商付款(如果使用付费服务)。 +> +> **示例:** 如果你的控制面板显示使用 iFlow 模型时"总成本 $290",这代表你如果直接使用付费 API 需要支付的金额。你的实际成本 = **$0**(iFlow 免费无限量)。 +> +> 把它想象成一个"节省追踪器",展示你通过使用免费模型或通过 9Router 路由节省了多少钱! + +### 🌐 任意部署 + +- 💻 **本地** - 默认,离线可用 +- ☁️ **VPS/云** - 跨设备共享 +- 🐳 **Docker** - 一键部署 +- 🚀 **Cloudflare Workers** - 全球边缘网络 + +
+ +--- + +## 💰 价格一览 + +| 等级 | 提供商 | 成本 | 配额重置 | 适用场景 | +|------|----------|------|-------------|----------| +| **🚀 TOKEN 节省器** | **RTK(内置)** | **免费** | 始终开启 | **每次请求节省 20-40% tokens** | +| **💳 订阅** | Claude Code (Pro/Max) | $20-200/月 | 5小时 + 每周 | 已有订阅的用户 | +| | Codex (Plus/Pro) | $20-200/月 | 5小时 + 每周 | OpenAI 用户 | +| | GitHub Copilot | $10-19/月 | 每月 | GitHub 用户 | +| | Cursor IDE | $20/月 | 每月 | Cursor 用户 | +| **💰 低价** | GLM-5.1 / GLM-4.7 | $0.6/1M | 每日 10AM | 预算备份 | +| | MiniMax M2.7 | $0.2/1M | 5小时滚动 | 最便宜选项 | +| | Kimi K2.5 | $9/月固定 | 10M tokens/月 | 可预测成本 | +| **🆓 免费** | Kiro AI | $0 | 无限量 | Claude 4.5 + GLM-5 + MiniMax 免费 | +| | OpenCode Free | $0 | 无限量 | 无需认证,自动获取模型 | +| | Vertex AI | $300 额度 | 新 GCP 账户 | Gemini 3 Pro + DeepSeek + GLM-5 | + +**💡 专业提示:** RTK + Kiro AI + OpenCode Free 组合 = **$0 成本 + 节省 20-40% tokens**! + +--- + +### 📊 理解 9Router 成本与计费 + +**9Router 计费真相:** + +✅ **9Router 软件 = 永久免费**(开源,绝不收费) +✅ **控制面板"成本" = 仅用于显示/追踪**(不是实际账单) +✅ **你直接向提供商付款**(订阅或 API 费用) +✅ **免费提供商保持免费**(iFlow、Kiro、Qwen = $0 无限量) +❌ **9Router 永不发送发票** 或扣款 + +**成本显示如何工作:** + +控制面板显示**估算成本**,如同你直接使用付费 API。这**不是计费** — 它是一个比较工具,展示你的节省。 + +**示例场景:** +``` +控制面板显示: +• 总请求数:1,662 +• 总 Tokens:47M +• 显示成本:$290 + +实际检查: +• 提供商:iFlow(免费无限量) +• 实际支付:$0.00 +• $290 意味着什么:通过使用免费模型节省的金额! +``` + +**付款规则:** +- **订阅提供商**(Claude Code、Codex):通过他们的网站直接付款 +- **低价提供商**(GLM、MiniMax):直接付款,9Router 只做路由 +- **免费提供商**(iFlow、Kiro、Qwen):真正的永久免费,无隐藏费用 +- **9Router**:从不收取任何费用,永远不会 + +--- + +## 🎯 使用场景 + +### 场景 1:"我有 Claude Pro 订阅" + +**问题:** 配额到期未用完,繁忙编码时遇到速率限制 + +**解决方案:** +``` +组合:"maximize-claude" + 1. cc/claude-opus-4-7 (充分利用订阅) + 2. glm/glm-5.1 (配额用完时的低价备份) + 3. kr/claude-sonnet-4.5 (免费紧急备选) + +月成本:$20(订阅)+ ~$5(备份)= $25 总计 +对比:$20 + 遇到限制 = 沮丧 +``` + +### 场景 2:"我想零成本" + +**问题:** 负担不起订阅,需要可靠的 AI 编码 + +**解决方案:** +``` +组合:"free-forever" + 1. kr/claude-sonnet-4.5 (Claude 4.5 免费无限量) + 2. kr/glm-5 (通过 Kiro 免费使用 GLM-5) + 3. oc/ (OpenCode Free,无需认证) + +月成本:$0 +质量:生产级模型 + RTK 节省 20-40% tokens +``` + +### 场景 3:"我需要 24/7 编码,不中断" + +**问题:** 截止日期紧迫,不能承受停机 + +**解决方案:** +``` +组合:"always-on" + 1. cc/claude-opus-4-7 (最佳质量) + 2. cx/gpt-5.5 (第二个订阅) + 3. glm/glm-5.1 (低价,每日重置) + 4. minimax/MiniMax-M2.7 (最便宜,5小时重置) + 5. kr/claude-sonnet-4.5 (免费无限量) + +结果:5 层切换 = 零停机时间 +月成本:$20-200(订阅)+ $10-20(备份) +``` + +### 场景 4:"我想在 OpenClaw 中使用免费 AI" + +**问题:** 需要在消息应用(WhatsApp、Telegram、Slack...)中使用 AI 助手,完全免费 + +**解决方案:** +``` +组合:"openclaw-free" + 1. kr/claude-sonnet-4.5 (Claude 4.5 免费) + 2. kr/glm-5 (GLM-5 免费) + 3. kr/MiniMax-M2.5 (MiniMax 免费) + +月成本:$0 +访问方式:WhatsApp、Telegram、Slack、Discord、iMessage、Signal... +``` + +--- + +## ❓ 常见问题 + +
+📊 为什么我的控制面板显示高成本? + +控制面板追踪你的 token 使用情况,并显示**估算成本**,如同你直接使用付费 API。这**不是实际计费** — 它是一个参考,展示你通过使用免费模型或通过 9Router 路由现有订阅节省了多少钱。 + +**示例:** +- **控制面板显示:** "$290 总成本" +- **实际情况:** 你在使用 iFlow(免费无限量) +- **你的实际成本:** **$0.00** +- **$290 的含义:** 你通过使用免费模型而不是付费 API **节省**的金额! + +成本显示是一个"节省追踪器",帮助你了解使用模式和优化机会。 + +
+ +
+💳 9Router 会扣我的钱吗? + +**不会。** 9Router 是在你自己的电脑上运行的开源软件。它永远不会向你收取任何费用。 + +**你只需支付:** +- ✅ **订阅提供商**(Claude Code $20/月、Codex $20-200/月)→ 在他们的网站上直接付款 +- ✅ **低价提供商**(GLM、MiniMax)→ 直接付款,9Router 只是路由你的请求 +- ❌ **9Router 本身** → **永不收费,永远不会** + +9Router 是一个本地代理/路由器。它没有你的信用卡,不能发送发票,也没有计费系统。它是完全免费的软件。 + +
+ +
+🆓 免费提供商真的是无限量的吗? + +**是的!** 当前的免费提供商(Kiro、OpenCode Free、Vertex)是真正的免费,**无隐藏费用**。 + +这些是各公司提供的免费服务: +- **Kiro AI**:通过 AWS Builder ID / Google / GitHub OAuth 免费无限量使用 Claude 4.5 + GLM-5 + MiniMax +- **OpenCode Free**:无认证直连代理,模型从 `opencode.ai/zen/v1/models` 自动获取 +- **Vertex AI**:新 Google Cloud 账户可获得 $300 免费额度(90 天) + +9Router 只是路由你的请求到它们 — 没有"陷阱"或未来的计费。它们是真正的免费服务,9Router 让它们易于使用并支持切换。 + +**已停止的免费等级(不再推荐):** +- ❌ **iFlow**:曾是免费无限量,现在改为付费(2026) +- ❌ **Qwen Code**:阿里巴巴于 2026-04-15 停止免费 OAuth 等级 +- ❌ **Gemini CLI**:仍可用,但与非 CLI 工具(Claude、Codex、Cursor...)一起使用可能会导致账户被封 — 仅在你坚持使用 Gemini CLI 本身时才使用 + +
+ +
+💰 如何最小化我的实际 AI 成本? + +**免费优先策略:** + +1. **从 100% 免费组合开始:** + ``` + 1. gc/gemini-3-flash (Google 每月 180K 免费) + 2. if/kimi-k2-thinking (iFlow 无限量免费) + 3. qw/qwen3-coder-plus (Qwen 无限量免费) + ``` + **成本:$0/月** + +2. **仅在需要时添加低价备份:** + ``` + 4. glm/glm-4.7 ($0.6/1M tokens) + ``` + **额外成本:只为实际使用的部分付费** + +3. **最后使用订阅提供商:** + - 仅当你已有订阅时 + - 9Router 通过配额追踪帮助最大化其价值 + +**结果:** 大多数用户可以仅使用免费等级以 $0/月运行! + +
+ +
+📈 如果我的使用量突然激增怎么办? + +9Router 的智能切换可以防止意外费用: + +**场景:** 你正在进行编码冲刺,用尽了配额 + +**没有 9Router:** +- ❌ 达到速率限制 → 工作停止 → 沮丧 +- ❌ 或者:不慎累积大量 API 账单 + +**有 9Router:** +- ✅ 订阅达到限制 → 自动切换到低价等级 +- ✅ 低价等级变得昂贵 → 自动切换到免费等级 +- ✅ 编程永不停歇 → 可预测的成本 + +**你掌控一切:** 在控制面板中设置每个提供商的支出限制,9Router 会遵守它们。 + +
+ +--- + +## 📖 设置指南 + +
+🔐 订阅提供商(充分利用价值) + +### Claude Code (Pro/Max) + +```bash +控制面板 → 提供商 → 连接 Claude Code +→ OAuth 登录 → 自动 token 刷新 +→ 5小时 + 每周配额追踪 + +模型: + cc/claude-opus-4-7 + cc/claude-opus-4-6 + cc/claude-sonnet-4-6 + cc/claude-haiku-4-5-20251001 +``` + +**专业提示:** 复杂任务使用 Opus,追求速度使用 Sonnet。9Router 按模型追踪配额! + +### OpenAI Codex (Plus/Pro) + +```bash +控制面板 → 提供商 → 连接 Codex +→ OAuth 登录(端口 1455) +→ 5小时 + 每周重置 + +模型: + cx/gpt-5.5 + cx/gpt-5.4 + cx/gpt-5.3-codex + cx/gpt-5.2-codex +``` + +### GitHub Copilot + +```bash +控制面板 → 提供商 → 连接 GitHub +→ 通过 GitHub 进行 OAuth +→ 每月重置(每月 1 日) + +模型: + gh/gpt-5.4 + gh/claude-opus-4.7 + gh/claude-sonnet-4.6 + gh/gemini-3.1-pro-preview + gh/grok-code-fast-1 +``` + +### Cursor IDE + +```bash +控制面板 → 提供商 → 连接 Cursor +→ OAuth 登录 +→ 每月订阅 + +模型: + cu/claude-4.6-opus-max + cu/claude-4.5-sonnet-thinking + cu/gpt-5.3-codex +``` + +
+ +
+💰 低价提供商(备份) + +### GLM-5.1 / GLM-4.7(每日重置,$0.6/1M) + +1. 注册:[Zhipu AI](https://open.bigmodel.cn/) +2. 从编程计划获取 API key +3. 控制面板 → 添加 API Key: + - 提供商:`glm` + - API Key:`your-key` + +**使用:** `glm/glm-5.1`、`glm/glm-5`、`glm/glm-4.7` + +**专业提示:** 编程计划提供 3 倍配额,成本仅为 1/7!每日 10:00 AM 重置。 + +### MiniMax M2.7(5小时重置,$0.20/1M) + +1. 注册:[MiniMax](https://www.minimax.io/) +2. 获取 API key +3. 控制面板 → 添加 API Key + +**使用:** `minimax/MiniMax-M2.7`、`minimax/MiniMax-M2.5` + +**专业提示:** 长上下文(1M tokens)的最便宜选项! + +### Kimi K2.5($9/月固定) + +1. 订阅:[Moonshot AI](https://platform.moonshot.ai/) +2. 获取 API key +3. 控制面板 → 添加 API Key + +**使用:** `kimi/kimi-k2.5`、`kimi/kimi-k2.5-thinking` + +**专业提示:** 每月 $9 固定费用获得 10M tokens = 实际成本 $0.90/1M! + +
+ +
+🆓 免费提供商(推荐) + +### Kiro AI(Claude 4.5 + GLM-5 + MiniMax 免费) + +```bash +控制面板 → 连接 Kiro +→ AWS Builder ID、AWS IAM Identity Center、Google 或 GitHub +→ 无限量使用 + +模型: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 + kr/glm-5 + kr/MiniMax-M2.5 + kr/qwen3-coder-next + kr/deepseek-3.2 +``` + +**专业提示:** Claude 最佳免费选项。无需 API key,无需付款,完全无限量。 + +### OpenCode Free(无需认证,自动获取模型) + +```bash +控制面板 → 连接 OpenCode Free +→ 无需登录(直连代理) +→ 模型从 opencode.ai/zen/v1/models 自动获取 +``` + +**专业提示:** 最快的设置。连接后即可开始编码。 + +### Vertex AI(新 GCP 账户 $300 免费额度) + +```bash +控制面板 → 连接 Vertex AI +→ 上传 Google Cloud 服务账户 JSON +→ 在你的 GCP 项目中启用 Vertex AI API + +模型: + vertex/gemini-3.1-pro-preview + vertex/gemini-3-flash-preview + vertex/gemini-2.5-flash + +Vertex 合作伙伴(通过 Vertex 提供 Anthropic / DeepSeek / GLM / Qwen): + vertex-partner/glm-5-maas + vertex-partner/deepseek-v3.2-maas + vertex-partner/qwen3-next-80b-a3b-thinking-maas +``` + +**专业提示:** 新 Google Cloud 账户可获得 90 天内 $300 免费额度。足够日常编码使用。 + +
+ +
+🎨 创建组合 + +### 示例 1:充分利用订阅 → 低价备份 + +``` +控制面板 → 组合 → 创建新组合 + +名称:premium-coding +模型: + 1. cc/claude-opus-4-7 (订阅主用) + 2. glm/glm-5.1 (低价备份,$0.6/1M) + 3. minimax/MiniMax-M2.7 (最便宜的备选,$0.20/1M) + +在 CLI 中使用:premium-coding + +月度成本示例(100M tokens): + 80M 通过 Claude(订阅):$0 额外费用 + 15M 通过 GLM:$9 + 5M 通过 MiniMax:$1 + 总计:$10 + 你的订阅费用 +``` + +### 示例 2:仅免费(零成本) + +``` +名称:free-combo +模型: + 1. kr/claude-sonnet-4.5 (Claude 4.5 免费无限量) + 2. kr/glm-5 (通过 Kiro 免费使用 GLM-5) + 3. vertex/gemini-3.1-pro-preview ($300 免费额度) + +成本:通过 RTK 永久 $0(+ 节省 20-40% tokens)! +``` + +
+ +
+🔧 CLI 集成 + +### Cursor IDE + +``` +设置 → 模型 → 高级: + OpenAI API Base URL:http://localhost:20128/v1 + OpenAI API Key:[来自 9router 控制面板] + Model:cc/claude-opus-4-7 +``` + +或使用组合:`premium-coding` + +### Claude Code + +编辑 `~/.claude/config.json`: + +```json +{ + "anthropic_api_base": "http://localhost:20128/v1", + "anthropic_api_key": "your-9router-api-key" +} +``` + +### Codex CLI + +```bash +export OPENAI_BASE_URL="http://localhost:20128" +export OPENAI_API_KEY="your-9router-api-key" + +codex "your prompt" +``` + +### OpenClaw + +**选项 1 — 控制面板(推荐):** + +``` +控制面板 → CLI 工具 → OpenClaw → 选择模型 → 应用 +``` + +**选项 2 — 手动:** 编辑 `~/.openclaw/openclaw.json`: + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "9router/kr/claude-sonnet-4.5" + } + } + }, + "models": { + "providers": { + "9router": { + "baseUrl": "http://127.0.0.1:20128/v1", + "apiKey": "sk_9router", + "api": "openai-completions", + "models": [ + { + "id": "kr/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (Kiro Free)" + } + ] + } + } + } +} +``` + +> **注意:** OpenClaw 仅适用于本地 9Router。使用 `127.0.0.1` 而不是 `localhost` 以避免 IPv6 解析问题。 + +### Cline / Continue / RooCode + +``` +Provider:OpenAI 兼容 +Base URL:http://localhost:20128/v1 +API Key:[来自控制面板] +Model:cc/claude-opus-4-7 +``` + +
+ +
+🚀 部署 + +### VPS 部署 + +```bash +# 克隆并安装 +git clone https://github.com/decolua/9router.git +cd 9router +npm install +npm run build + +# 配置 +export JWT_SECRET="your-secure-secret-change-this" +export INITIAL_PASSWORD="your-password" +export DATA_DIR="/var/lib/9router" +export PORT="20128" +export HOSTNAME="0.0.0.0" +export NODE_ENV="production" +export NEXT_PUBLIC_BASE_URL="http://localhost:20128" +export NEXT_PUBLIC_CLOUD_URL="https://9router.com" +export API_KEY_SECRET="endpoint-proxy-api-key-secret" +export MACHINE_ID_SALT="endpoint-proxy-salt" + +# 启动 +npm run start + +# 或使用 PM2 +npm install -g pm2 +pm2 start npm --name 9router -- start +pm2 save +pm2 startup +``` + +### Docker + +```bash +# 构建镜像(从仓库根目录) +docker build -t 9router . + +# 运行容器(当前设置使用的命令) +docker run -d \ + --name 9router \ + -p 20128:20128 \ + --env-file /root/dev/9router/.env \ + -v 9router-data:/app/data \ + -v 9router-usage:/root/.9router \ + 9router +``` + +便携命令(如果你已经在仓库根目录): + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + --env-file ./.env \ + -v 9router-data:/app/data \ + -v 9router-usage:/root/.9router \ + 9router +``` + +容器默认值: +- `PORT=20128` +- `HOSTNAME=0.0.0.0` + +常用命令: + +```bash +docker logs -f 9router +docker restart 9router +docker stop 9router && docker rm 9router +``` + +### 环境变量 + +| 变量 | 默认值 | 描述 | +|----------|---------|-------------| +| `JWT_SECRET` | 自动生成(`~/.9router/jwt-secret`) | 用于控制面板 auth cookie 的 JWT 签名密钥(设置可在多实例间共享) | +| `INITIAL_PASSWORD` | `123456` | 当没有保存的哈希时首次登录的密码 | +| `DATA_DIR` | `~/.9router` | 主应用数据库位置(`db.json`) | +| `PORT` | 框架默认值 | 服务端口(示例中为 `20128`) | +| `HOSTNAME` | 框架默认值 | 绑定主机(Docker 默认为 `0.0.0.0`) | +| `NODE_ENV` | 运行时默认值 | 设置 `production` 用于部署 | +| `BASE_URL` | `http://localhost:20128` | 云同步任务使用的服务端内部基础 URL | +| `CLOUD_URL` | `https://9router.com` | 服务端云同步端点基础 URL | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | 向后兼容/公开基础 URL(服务端运行时优先使用 `BASE_URL`) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | 向后兼容/公开云 URL(服务端运行时优先使用 `CLOUD_URL`) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | 生成 API key 的 HMAC 密钥 | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | 稳定机器 ID 哈希的盐值 | +| `ENABLE_REQUEST_LOGS` | `false` | 在 `logs/` 下启用请求/响应日志 | +| `AUTH_COOKIE_SECURE` | `false` | 强制 `Secure` auth cookie(在 HTTPS 反向代理后面设置为 `true`) | +| `REQUIRE_API_KEY` | `false` | 在 `/v1/*` 路由上强制使用 Bearer API key(面向互联网部署时推荐) | +| `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY` | 空 | 用于上游提供商调用的可选出站代理 | + +注意: +- 也支持小写代理变量:`http_proxy`、`https_proxy`、`all_proxy`、`no_proxy`。 +- `.env` 不会打包到 Docker 镜像中(`.dockerignore`);使用 `--env-file` 或 `-e` 注入运行时配置。 +- 在 Windows 上,`APPDATA` 可用于本地存储路径解析。 +- `INSTANCE_NAME` 出现在较旧的文档/环境变量模板中,但当前运行时未使用。 + +### 运行时文件和存储 + +- 主应用状态:`${DATA_DIR}/db.json`(提供商、组合、别名、密钥、设置),由 `src/lib/localDb.js` 管理。 +- 使用历史和日志:`${DATA_DIR}/usage.json` 和 `${DATA_DIR}/log.txt`,由 `src/lib/usageDb.js` 管理。 +- 可选的请求/翻译器日志:`ENABLE_REQUEST_LOGS=true` 时位于 `/logs/...`。 +- `${DATA_DIR}` 和 `~/.9router` 在 Docker 容器中解析到同一位置 — 符号链接 `/root/.9router -> /app/data` 在构建时创建。 + +
+ +--- + +## 📊 可用模型 + +
+查看所有可用模型 + +**Claude Code(`cc/`)** - Pro/Max: +- `cc/claude-opus-4-7` +- `cc/claude-opus-4-6` +- `cc/claude-sonnet-4-6` +- `cc/claude-sonnet-4-5-20250929` +- `cc/claude-haiku-4-5-20251001` + +**Codex(`cx/`)** - Plus/Pro: +- `cx/gpt-5.5` +- `cx/gpt-5.4` +- `cx/gpt-5.3-codex` +- `cx/gpt-5.2-codex` +- `cx/gpt-5.1-codex-max` + +**GitHub Copilot(`gh/`)**: +- `gh/gpt-5.4` +- `gh/claude-opus-4.7` +- `gh/claude-sonnet-4.6` +- `gh/gemini-3.1-pro-preview` +- `gh/grok-code-fast-1` + +**Cursor(`cu/`)** - 订阅: +- `cu/claude-4.6-opus-max` +- `cu/claude-4.5-sonnet-thinking` +- `cu/gpt-5.3-codex` +- `cu/kimi-k2.5` + +**GLM(`glm/`)** - $0.6/1M: +- `glm/glm-5.1` +- `glm/glm-5` +- `glm/glm-4.7` + +**MiniMax(`minimax/`)** - $0.2/1M: +- `minimax/MiniMax-M2.7` +- `minimax/MiniMax-M2.5` + +**Kimi(`kimi/`)** - $9/月固定: +- `kimi/kimi-k2.5` +- `kimi/kimi-k2.5-thinking` + +**Kiro(`kr/`)** - 免费无限量: +- `kr/claude-sonnet-4.5` +- `kr/claude-haiku-4.5` +- `kr/glm-5` +- `kr/MiniMax-M2.5` +- `kr/qwen3-coder-next` +- `kr/deepseek-3.2` + +**OpenCode Free(`oc/`)** - 免费无需认证: +- 从 `opencode.ai/zen/v1/models` 自动获取 + +**Vertex AI(`vertex/`)** - $300 免费额度: +- `vertex/gemini-3.1-pro-preview` +- `vertex/gemini-3-flash-preview` +- `vertex/gemini-2.5-flash` +- `vertex-partner/glm-5-maas` +- `vertex-partner/deepseek-v3.2-maas` + +
+ +--- + +## 🐛 故障排除 + +**"语言模型未提供消息"** +- 提供商配额耗尽 → 检查控制面板配额追踪器 +- 解决方案:使用组合切换或切换到更便宜的等级 + +**速率限制** +- 订阅配额用完 → 切换到 GLM/MiniMax +- 添加组合:`cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5` + +**OAuth token 已过期** +- 9Router 自动刷新 +- 如果问题持续:控制面板 → 提供商 → 重新连接 + +**高成本** +- 在控制面板 → 端点设置中启用 RTK(默认开启,节省 20-40% tokens) +- 在控制面板中检查使用统计 +- 将主模型切换到 GLM/MiniMax +- 对于非关键任务使用免费等级(Kiro、OpenCode Free、Vertex) + +**控制面板在错误端口打开** +- 设置 `PORT=20128` 和 `NEXT_PUBLIC_BASE_URL=http://localhost:20128` + +**首次登录不工作** +- 检查 `.env` 中的 `INITIAL_PASSWORD` +- 如果未设置,回退密码是 `123456` + +**`logs/` 下没有请求日志** +- 设置 `ENABLE_REQUEST_LOGS=true` + +--- + +## 🛠️ 技术栈 + +- **运行时**:Node.js 20+ +- **框架**:Next.js 16 +- **UI**:React 19 + Tailwind CSS 4 +- **数据库**:LowDB(基于 JSON 文件) +- **流式传输**:Server-Sent Events (SSE) +- **认证**:OAuth 2.0 (PKCE) + JWT + API Keys + +--- + +## 📝 API 参考 + +### 聊天补全 + +```bash +POST http://localhost:20128/v1/chat/completions +Authorization: Bearer your-api-key +Content-Type: application/json + +{ + "model": "cc/claude-opus-4-6", + "messages": [ + {"role": "user", "content": "Write a function to..."} + ], + "stream": true +} +``` + +### 列出模型 + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer your-api-key + +→ 以 OpenAI 格式返回所有模型和组合 +``` + +## 📧 支持 + +- **网站**:[9router.com](https://9router.com) +- **GitHub**:[github.com/decolua/9router](https://github.com/decolua/9router) +- **问题**:[github.com/decolua/9router/issues](https://github.com/decolua/9router/issues) + +--- + +## 👥 贡献者 + +感谢所有帮助改进 9Router 的贡献者! + +[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1&v=20260309)](https://github.com/decolua/9router/graphs/contributors) + +--- + +## 📊 Star 图表 + +[![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) + + + +## 🔀 分支 + +**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — 9Router 的全功能 TypeScript 分支。增加了 36+ 提供商、4 层自动切换、多模态 API(图像、嵌入、音频、TTS)、断路器、语义缓存、LLM 评估和精美的控制面板。368+ 单元测试。可通过 npm 和 Docker 使用。 + +--- + +## 🙏 致谢 + +站在巨人的肩膀上构建: + +- **CLIProxyAPI** — 启发了这个 JavaScript 移植的原始 Go 实现。 +- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — Rust token 节省器。9Router 将其压缩管道移植到 JS → 每次请求 **减少 20-40% 输入 tokens**。 +- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — 病毒式传播的 *"为什么用很多 token 当少的 token 就能搞定"*。9Router 适配其提示词 → **减少 65% 输出 tokens**。 + +非常感谢这些作者 — 没有他们的工作,9Router 的 token 节省功能就不会存在。在 GitHub 上给他们加星! + +--- + +## 📄 许可证 + +MIT 许可证 — 详见 [LICENSE](LICENSE)。 + +--- + +
+ 用 ❤️ 为 24/7 编程的开发者构建 +
\ No newline at end of file diff --git a/captain-definition b/captain-definition new file mode 100644 index 0000000..0e14f82 --- /dev/null +++ b/captain-definition @@ -0,0 +1,4 @@ +{ + "schemaVersion": 2, + "dockerfilePath": "./Dockerfile" +} diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..55fd8c4 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,2 @@ +app/* +node_modules/* diff --git a/cli/.npmignore b/cli/.npmignore new file mode 100644 index 0000000..231c936 --- /dev/null +++ b/cli/.npmignore @@ -0,0 +1,9 @@ +# Ignore everything except what's in package.json "files" +* +!cli.js +!hooks/ +!app/ +!package.json +!README.md +!LICENSE + diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 0000000..ddd5dd4 --- /dev/null +++ b/cli/LICENSE @@ -0,0 +1,42 @@ +MIT License + +Copyright (c) 2026 9Router 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/cli/README.md b/cli/README.md new file mode 100644 index 0000000..050d996 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,125 @@ +# 9Router - FREE AI Router & Token Saver + +**Never stop coding. Save 20-40% tokens with RTK + auto-fallback to FREE & cheap AI models.** + +**Connect All AI Code Tools (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) to 40+ AI Providers & 100+ Models.** + +[![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) +[![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) +[![Docker Pulls](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/decolua/9router) +[![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router) +[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + +decolua%2F9router | Trendshift + +[🌐 Website](https://9router.com) • [📖 Full Docs](https://github.com/decolua/9router) + +--- + +## 🤔 Why 9Router? + +**Stop wasting money, tokens and hitting limits:** + +- ❌ Subscription quota expires unused every month +- ❌ Rate limits stop you mid-coding +- ❌ Tool outputs (git diff, grep, ls...) burn tokens fast +- ❌ Expensive APIs ($20-50/month per provider) + +**9Router solves this:** + +- ✅ **RTK Token Saver** - Auto-compress tool_result, save 20-40% tokens +- ✅ **Maximize subscriptions** - Track quota, use every bit before reset +- ✅ **Auto fallback** - Subscription → Cheap → Free, zero downtime +- ✅ **Multi-account** - Round-robin between accounts per provider +- ✅ **Universal** - Works with any OpenAI/Claude-compatible CLI + +--- + +## ⚡ Quick Start + +**Option 1 — npm (recommended for desktop):** + +```bash +npm install -g 9router +9router + +# Or run directly with npx +npx 9router +``` + +**Option 2 — Docker (server/VPS):** + +```bash +docker run -d --name 9router -p 20128:20128 \ + -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data \ + decolua/9router:latest +``` + +Published images: [Docker Hub](https://hub.docker.com/r/decolua/9router) • [GHCR](https://github.com/decolua/9router/pkgs/container/9router) (multi-platform amd64/arm64). + +🎉 Dashboard opens at `http://localhost:20128` + +**2. Connect a FREE provider (no signup needed):** + +Dashboard → Providers → Connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → Done! + +**3. Use in your CLI tool:** + +``` +Claude Code/Codex/OpenClaw/Cursor/Cline Settings: + Endpoint: http://localhost:20128/v1 + API Key: [copy from dashboard] + Model: kr/claude-sonnet-4.5 +``` + +That's it! Start coding with FREE AI models. + +--- + +## 🚀 CLI Options + +```bash +9router # Start with default settings +9router --port 8080 # Custom port +9router --no-browser # Don't open browser +9router --skip-update # Skip auto-update check +9router --help # Show all options +``` + +**Dashboard**: `http://localhost:20128/dashboard` + +--- + +## 🛠️ Supported CLI Tools + +Claude-Code • OpenClaw • Codex • OpenCode • Cursor • Antigravity • Cline • Continue • Droid • Roo • Copilot • Kilo Code • Gemini CLI • Qwen Code • iFlow • Crush • Crusher • Aider + +Any tool supporting OpenAI/Claude-compatible API works. + +--- + +## 💾 Data Location + +- **macOS/Linux**: `~/.9router/db/data.sqlite` +- **Windows**: `%APPDATA%/9router/db/data.sqlite` +- **Docker**: `/app/data/db/data.sqlite` (mount `$HOME/.9router` to persist) + +--- + +## 📚 Documentation + +Full docs, advanced setup, video tutorials & development guide: + +- **GitHub**: https://github.com/decolua/9router +- **Full README**: https://github.com/decolua/9router/blob/main/app/README.md +- **Website**: https://9router.com + +--- + +## 🙏 Acknowledgments + +- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** - Original Go implementation + +## 📄 License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/cli/cli.js b/cli/cli.js new file mode 100755 index 0000000..6a0fc06 --- /dev/null +++ b/cli/cli.js @@ -0,0 +1,852 @@ +#!/usr/bin/env node + +const { spawn, exec, execSync } = require("child_process"); +const path = require("path"); +const fs = require("fs"); +const https = require("https"); +const net = require("net"); +const os = require("os"); + +// Poll until the server accepts TCP connections on port, or timeout — avoids blind fixed waits. +function waitServerReady(port, { timeoutMs = 15000, intervalMs = 150 } = {}) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve) => { + const tryConnect = () => { + const socket = net.connect({ host: "127.0.0.1", port }, () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + socket.destroy(); + if (Date.now() >= deadline) return resolve(false); + setTimeout(tryConnect, intervalMs); + }); + }; + tryConnect(); + }); +} + +// Native spinner - no external dependency +function createSpinner(text) { + const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let i = 0; + let interval = null; + let currentText = text; + return { + start() { + if (process.stdout.isTTY) { + process.stdout.write(`\r${frames[0]} ${currentText}`); + interval = setInterval(() => { + process.stdout.write(`\r${frames[i++ % frames.length]} ${currentText}`); + }, 80); + } + return this; + }, + stop() { + if (interval) { + clearInterval(interval); + interval = null; + } + if (process.stdout.isTTY) { + process.stdout.write("\r\x1b[K"); + } + }, + succeed(msg) { + this.stop(); + console.log(`✅ ${msg}`); + }, + fail(msg) { + this.stop(); + console.log(`❌ ${msg}`); + } + }; +} + +const pkg = require("./package.json"); +const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime"); +const { ensureTrayRuntime } = require("./hooks/trayRuntime"); +const args = process.argv.slice(2); + +// Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime +// so the server can resolve them via NODE_PATH. Best-effort — sql.js is required, +// better-sqlite3 is optional. Logs to stderr only on failure. +try { ensureSqliteRuntime({ silent: true }); } catch {} + +// Self-heal tray runtime (systray for macOS/Linux only). Windows skipped. +try { ensureTrayRuntime({ silent: true }); } catch {} + +// Configuration constants +const APP_NAME = pkg.name; // Use from package.json +const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`; + +const DEFAULT_PORT = 20128; +const DEFAULT_HOST = "0.0.0.0"; + +// First non-internal IPv4 — the address remote peers actually reach when bound to 0.0.0.0. +function getLanIp() { + for (const ifaces of Object.values(os.networkInterfaces())) { + for (const i of ifaces || []) { + if (i.family === "IPv4" && !i.internal) return i.address; + } + } + return null; +} + +// Local URL stays "localhost"; warn separately when bound to all interfaces (network-exposed). +function getDisplayHost() { + return host === DEFAULT_HOST ? "localhost" : host; +} +const MAX_PORT_ATTEMPTS = 10; +// Identifiers for killAllAppProcesses - only kill 9router specifically +const PROCESS_IDENTIFIERS = [ + '9router' // Only package name - avoid killing other apps +]; + +// Parse arguments +let port = DEFAULT_PORT; +let host = DEFAULT_HOST; +let noBrowser = false; +let skipUpdate = false; +let showLog = false; +let trayMode = false; + +for (let i = 0; i < args.length; i++) { + if (args[i] === "--port" || args[i] === "-p") { + port = parseInt(args[i + 1], 10) || DEFAULT_PORT; + i++; + } else if (args[i] === "--host" || args[i] === "-H") { + host = args[i + 1] || DEFAULT_HOST; + i++; + } else if (args[i] === "--no-browser" || args[i] === "-n") { + noBrowser = true; + } else if (args[i] === "--log" || args[i] === "-l") { + showLog = true; + } else if (args[i] === "--skip-update") { + skipUpdate = true; + } else if (args[i] === "--tray" || args[i] === "-t") { + trayMode = true; + process.env.TRAY_MODE = "1"; + } else if (args[i] === "--help" || args[i] === "-h") { + console.log(` +Usage: ${APP_NAME} [options] + +Options: + -p, --port Port to run the server (default: ${DEFAULT_PORT}) + -H, --host Host to bind (default: ${DEFAULT_HOST}) + -n, --no-browser Don't open browser automatically + -l, --log Show server logs (default: hidden) + -t, --tray Run in system tray mode (background) + --skip-update Skip auto-update check + -h, --help Show this help message + -v, --version Show version +`); + process.exit(0); + } else if (args[i] === "--version" || args[i] === "-v") { + console.log(pkg.version); + process.exit(0); + } +} + +// Auto-relaunch after update: detached process has no TTY → fallback to tray +if (skipUpdate && !trayMode && !process.stdin.isTTY) { + trayMode = true; + process.env.TRAY_MODE = "1"; +} + +// Always use Node.js runtime with absolute path +const RUNTIME = process.execPath; + +// Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal +function compareVersions(a, b) { + const partsA = a.split(".").map(Number); + const partsB = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if (partsA[i] > partsB[i]) return 1; + if (partsA[i] < partsB[i]) return -1; + } + return 0; +} + +// Get app data dir (matches app/src/lib/dataDir.js convention) +function getAppDataDir() { + return process.platform === "win32" + ? path.join(process.env.APPDATA || "", "9router") + : path.join(os.homedir(), ".9router"); +} + +// Kill PID from file (best-effort, removes file after) +function killByPidFile(pidFile) { + try { + if (!fs.existsSync(pidFile)) return; + const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10); + if (!pid) return; + try { + if (process.platform === "win32") { + execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); + } else { + process.kill(pid, "SIGKILL"); + } + } catch { } + try { fs.unlinkSync(pidFile); } catch { } + } catch { } +} + +// Kill tunnel processes (cloudflared/tailscale) by their PID files +function killTunnelByPidFile() { + const tunnelDir = path.join(getAppDataDir(), "tunnel"); + killByPidFile(path.join(tunnelDir, "cloudflared.pid")); + killByPidFile(path.join(tunnelDir, "tailscale.pid")); +} + +// Kill cloudflared whose --url targets this app's port (covers stale PID file case) +function killCloudflaredByAppPort(appPort) { + if (!appPort) return []; + const portMatchers = [`localhost:${appPort}`, `127.0.0.1:${appPort}`]; + const pids = []; + try { + if (process.platform === "win32") { + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"cloudflared.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`; + const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: 5000 }); + const lines = output.split("\n").slice(1).filter(l => l.trim()); + lines.forEach(line => { + if (portMatchers.some(m => line.includes(m))) { + const match = line.match(/^"(\d+)"/); + if (match && match[1]) pids.push(match[1]); + } + }); + } else { + const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 }); + output.split("\n").forEach(line => { + if (line.includes("cloudflared") && portMatchers.some(m => line.includes(m))) { + const parts = line.trim().split(/\s+/); + const pid = parts[0]; + if (pid && !isNaN(pid)) pids.push(pid); + } + }); + } + } catch { } + return pids; +} + +// Kill all 9router processes +function killAllAppProcesses(appPort) { + return new Promise((resolve) => { + try { + // Background: MITM + tunnel/cloudflared run on separate ports/processes — + // killing them doesn't free the app port, so don't block the critical path. + // Server-side MITM manager has stale-lock recovery and starts deferred (~3s). + setImmediate(() => { + try { killProxyByPidFile(); } catch {} + try { killTunnelByPidFile(); } catch {} + try { killCloudflaredByAppPort(appPort); } catch {} + }); + + const platform = process.platform; + let pids = []; + + if (platform === "win32") { + // Windows: use WMI to get full CommandLine (tasklist /V doesn't include it) + try { + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"node.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`; + const output = execSync(psCmd, { + encoding: "utf8", + windowsHide: true, + timeout: 5000 + }); + const lines = output.split("\n").slice(1).filter(l => l.trim()); + lines.forEach(line => { + // Whitelist: real node process running 9router/cli.js, or next-server. + // Avoids killing editors/grep/strace/cursor that just have "9router" in cmdline. + const cmd = line.toLowerCase(); + const isAppProcess = + (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("\\9router") || cmd.includes("/9router"))) + || cmd.includes("next-server"); + if (isAppProcess) { + const match = line.match(/^"(\d+)"/); + if (match && match[1] && match[1] !== process.pid.toString()) { + pids.push(match[1]); + } + } + }); + } catch (e) { + // No processes found or error - continue + } + } else { + // macOS/Linux: use ps to find all matching processes + try { + const output = execSync('ps aux 2>/dev/null', { + encoding: 'utf8', + timeout: 5000 + }); + const lines = output.split('\n'); + + lines.forEach(line => { + // Whitelist: real node process running 9router/cli.js, or next-server. + // Avoids killing grep/strace/editors/cursor that incidentally match "9router". + const cmd = line.toLowerCase(); + const isAppProcess = + (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("/9router"))) + || cmd.includes("next-server"); + if (isAppProcess) { + const parts = line.trim().split(/\s+/); + const pid = parts[1]; + if (pid && !isNaN(pid) && pid !== process.pid.toString()) { + pids.push(pid); + } + } + }); + } catch (e) { + // No processes found or error - continue + } + } + + // Kill all found processes + if (pids.length > 0) { + pids.forEach(pid => { + try { + if (platform === "win32") { + execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 }); + } else { + execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 }); + } + } catch (err) { + // Process already dead or can't kill - continue + } + }); + + // Wait for processes to fully terminate + setTimeout(() => resolve(), 1000); + } else { + resolve(); + } + } catch (err) { + // Silent fail - continue anyway + resolve(); + } + }); +} + +// Sleep helper using SharedArrayBuffer wait (sync, no busy-loop) +function sleepSync(ms) { + try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ } +} + +// Wait until process dies or timeout reached +function waitForExit(pid, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { process.kill(pid, 0); } catch { return true; } + sleepSync(100); + } + return false; +} + +// Kill MIT server by PID file (runs privileged, needs special handling) +// Sends SIGTERM first so MIT can clean up host entries before dying. +function killProxyByPidFile() { + try { + const pidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid"); + if (!fs.existsSync(pidFile)) return; + const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10); + if (!pid) return; + + if (process.platform === "win32") { + // Graceful first (lets server cleanup hosts), then force + try { execSync(`taskkill /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { } + if (!waitForExit(pid, 1500)) { + try { execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { } + } + // Last-resort: PowerShell Stop-Process (sometimes succeeds where taskkill fails on admin processes) + if (!waitForExit(pid, 500)) { + try { execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${pid} -Force"`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { } + } + } else { + // SIGTERM via cached sudo token first + try { execSync(`sudo -n kill -TERM ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); } + catch { try { process.kill(pid, "SIGTERM"); } catch { } } + if (!waitForExit(pid, 1500)) { + try { execSync(`sudo -n kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); } + catch { try { process.kill(pid, "SIGKILL"); } catch { } } + } + } + try { fs.unlinkSync(pidFile); } catch { } + } catch { } +} + +// Kill any process on specific port +function killProcessOnPort(port) { + return new Promise((resolve) => { + try { + const platform = process.platform; + let pid; + + if (platform === "win32") { + try { + const output = execSync(`netstat -ano | findstr :${port}`, { + encoding: 'utf8', + shell: true, + windowsHide: true, + timeout: 5000 + }).trim(); + const lines = output.split('\n').filter(l => l.includes('LISTENING')); + if (lines.length > 0) { + pid = lines[0].trim().split(/\s+/).pop(); + execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 }); + } + } catch (e) { + // Port is free or error + } + } else { + // macOS/Linux + try { + const pidOutput = execSync(`lsof -ti:${port}`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }).trim(); + if (pidOutput) { + pid = pidOutput.split('\n')[0]; + execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 }); + } + } catch (e) { + // Port is free or error + } + } + + // Wait for port to be released + setTimeout(() => resolve(), 500); + } catch (err) { + // Silent fail - continue anyway + resolve(); + } + }); +} + + +// Detect if running in restricted environment (Codespaces, Docker) +function isRestrictedEnvironment() { + // Check for Codespaces + if (process.env.CODESPACES === "true" || process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN) { + return "GitHub Codespaces"; + } + + // Check for Docker + if (fs.existsSync("/.dockerenv") || (fs.existsSync("/proc/1/cgroup") && fs.readFileSync("/proc/1/cgroup", "utf8").includes("docker"))) { + return "Docker"; + } + + return null; +} + +// Check if new version available, return latest version or null +function checkForUpdate() { + return new Promise((resolve) => { + if (skipUpdate) { + resolve(null); + return; + } + + const spinner = createSpinner("Checking for updates...").start(); + let resolved = false; + + const safetyTimeout = setTimeout(() => { + if (!resolved) { + resolved = true; + spinner.stop(); + resolve(null); + } + }, 8000); + + const done = (version) => { + if (resolved) return; + resolved = true; + clearTimeout(safetyTimeout); + spinner.stop(); + resolve(version); + }; + + const req = https.get(`https://registry.npmjs.org/${pkg.name}/latest`, { timeout: 3000 }, (res) => { + let data = ""; + res.on("data", chunk => data += chunk); + res.on("end", () => { + try { + const latest = JSON.parse(data); + if (latest.version && compareVersions(latest.version, pkg.version) > 0) { + done(latest.version); + } else { + done(null); + } + } catch (e) { + done(null); + } + }); + }); + + req.on("error", () => done(null)); + req.on("timeout", () => { req.destroy(); done(null); }); + }); +} + +// Open browser +function openBrowser(url) { + const platform = process.platform; + let cmd; + + if (platform === "darwin") { + cmd = `open "${url}"`; + } else if (platform === "win32") { + cmd = `start "" "${url}"`; + } else { + cmd = `xdg-open "${url}"`; + } + + exec(cmd, { windowsHide: true }, (err) => { + if (err) { + console.log(`Open browser manually: ${url}`); + } + }); +} + +// Find standalone server (bundled in bin/app for published package). +// Prefer custom-server.js (injects real socket IP) when present. +const standaloneDir = path.join(__dirname, "app"); +const customServerPath = path.join(standaloneDir, "custom-server.js"); +const serverPath = fs.existsSync(customServerPath) + ? customServerPath + : path.join(standaloneDir, "server.js"); + +if (!fs.existsSync(serverPath)) { + console.error("Error: Standalone build not found."); + console.error("Please run 'npm run build:cli' first."); + process.exit(1); +} + +// Start server immediately; run update check in parallel (not on the critical path). +const updatePromise = checkForUpdate(); +killAllAppProcesses(port) + .then(() => killProcessOnPort(port)) + .then(() => startServer(updatePromise)); + +// Show interface selection menu +async function showInterfaceMenu(latestVersion) { + const { selectMenu } = require("./src/cli/utils/input"); + const { clearScreen } = require("./src/cli/utils/display"); + const { getEndpoint } = require("./src/cli/utils/endpoint"); + + clearScreen(); + + const displayHost = getDisplayHost(); + + // Detect tunnel/local mode for server URL display + let serverUrl; + try { + const { endpoint, tunnelEnabled } = await getEndpoint(port); + serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`; + } catch (e) { + serverUrl = `http://${displayHost}:${port}`; + } + + const subtitle = `🚀 Server: \x1b[32m${serverUrl}\x1b[0m`; + + const menuItems = []; + + if (latestVersion) { + menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" }); + } + + menuItems.push( + { label: "Web UI (Open in Browser)", icon: "🌐" }, + { label: "Terminal UI (Interactive CLI)", icon: "💻" }, + { label: "Hide to Tray (Background)", icon: "🔔" }, + { label: "Exit", icon: "🚪" } + ); + + const selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle); + + const offset = latestVersion ? 1 : 0; + + if (latestVersion && selected === 0) return "update"; + if (selected === offset) return "web"; + if (selected === offset + 1) return "terminal"; + if (selected === offset + 2) return "hide"; + return "exit"; +} + +const MAX_RESTARTS = 2; +const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s + +function startServer(updatePromise) { + // Accept either a Promise (parallel update check) or a resolved value. + const latestVersionPromise = Promise.resolve(updatePromise); + const displayHost = getDisplayHost(); + const url = `http://${displayHost}:${port}/dashboard`; + // Surface real network exposure when bound to all interfaces (default 0.0.0.0). + if (host === DEFAULT_HOST) { + const lanIp = getLanIp(); + if (lanIp) console.log(`\x1b[33m⚠ Network-exposed: reachable at http://${lanIp}:${port} (bound 0.0.0.0). Use --host 127.0.0.1 for local-only.\x1b[0m`); + } + + let restartCount = 0; + let serverStartTime = Date.now(); + + const CRASH_LOG_LINES = 50; + let crashLog = []; + + function spawnServer() { + serverStartTime = Date.now(); + crashLog = []; + const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], { + cwd: standaloneDir, + stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"], + detached: true, + windowsHide: true, + env: { + ...buildEnvWithRuntime(process.env), + PORT: port.toString(), + HOSTNAME: host + } + }); + if (!showLog && child.stderr) { + child.stderr.on("data", (data) => { + const lines = data.toString().split("\n").filter(Boolean); + crashLog.push(...lines); + if (crashLog.length > CRASH_LOG_LINES) crashLog = crashLog.slice(-CRASH_LOG_LINES); + }); + } + return child; + } + + let server = spawnServer(); + + // Cleanup function - force kill server process + let isCleaningUp = false; + function cleanup() { + if (isCleaningUp) return; + isCleaningUp = true; + try { + // Kill tray if running + try { + const { killTray } = require("./src/cli/tray/tray"); + killTray(); + } catch (e) { } + // Kill MIT server (privileged process) via PID file + killProxyByPidFile(); + // Kill cloudflared/tailscale via PID file (only this app's tunnel) + killTunnelByPidFile(); + // Kill server process directly + if (server.pid) { + process.kill(server.pid, "SIGKILL"); + } + // Also try to kill process group + process.kill(-server.pid, "SIGKILL"); + } catch (e) { } + } + + // Suppress all errors during shutdown (systray lib throws JSON parse errors) + let isShuttingDown = false; + process.on("uncaughtException", (err) => { + if (isShuttingDown) return; + console.error("Error:", err.message); + }); + + // Handle all exit scenarios + process.on("SIGINT", () => { + if (isShuttingDown) return; + isShuttingDown = true; + console.log("\nExiting..."); + cleanup(); + setTimeout(() => process.exit(0), 100); + }); + process.on("SIGTERM", () => { + if (isShuttingDown) return; + isShuttingDown = true; + cleanup(); + setTimeout(() => process.exit(0), 100); + }); + process.on("SIGHUP", () => { + if (isShuttingDown) return; + isShuttingDown = true; + cleanup(); + setTimeout(() => process.exit(0), 100); + }); + + // Initialize tray icon (runs alongside TUI) + const initTrayIcon = () => { + try { + const { initTray } = require("./src/cli/tray/tray"); + initTray({ + port, + onQuit: () => { + isShuttingDown = true; + console.log("\n👋 Shutting down from tray..."); + cleanup(); + setTimeout(() => process.exit(0), 100); + }, + onOpenDashboard: () => openBrowser(url) + }); + } catch (err) { + // Tray not available - continue without it + } + }; + + // Tray-only mode: no TUI, just tray icon + if (trayMode) { + // Ignore SIGHUP so macOS terminal close doesn't kill the background tray process + process.removeAllListeners("SIGHUP"); + process.on("SIGHUP", () => {}); + + console.log(`\n🚀 ${pkg.name} v${pkg.version}`); + console.log(`Server: http://${displayHost}:${port}`); + + waitServerReady(port).then(() => { + initTrayIcon(); + console.log("\n💡 Router is now running in system tray. Close this terminal if you want."); + console.log(" Right-click tray icon to open dashboard or quit.\n"); + }); + + return; + } + + // Wait for server to be ready, then show interface menu loop + tray + waitServerReady(port).then(async () => { + // Resolve parallel update check (already running); don't block server start on it. + const latestVersion = await latestVersionPromise; + // Start tray icon alongside TUI + initTrayIcon(); + + try { + while (true) { + const choice = await showInterfaceMenu(latestVersion); + + if (choice === "update") { + isShuttingDown = true; + const { clearScreen } = require("./src/cli/utils/display"); + clearScreen(); + console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`); + console.log(`Run this after exit:\n`); + console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`); + cleanup(); + await killAllAppProcesses(port); + await killProcessOnPort(port); + setTimeout(() => process.exit(0), 200); + return; + } else if (choice === "web") { + openBrowser(url); + // Wait for user to come back + const { pause } = require("./src/cli/utils/input"); + await pause("\nPress Enter to go back to menu..."); + } else if (choice === "terminal") { + // Start Terminal UI - it will return when user selects Back + const { startTerminalUI } = require("./src/cli/terminalUI"); + await startTerminalUI(port); + // Loop continues, show menu again + } else if (choice === "hide") { + const { clearScreen } = require("./src/cli/utils/display"); + clearScreen(); + + // Enable auto startup on OS boot + try { + const { enableAutoStart } = require("./src/cli/tray/autostart"); + enableAutoStart(__filename); + } catch (e) { } + + if (process.platform === "darwin") { + // macOS: keep current process alive — spawning a detached child puts + // it outside the login session so NSStatusItem silently fails. + process.removeAllListeners("SIGHUP"); + process.on("SIGHUP", () => {}); + + console.log(`\n⏳ Switching to tray mode... (icon already visible in menu bar)`); + console.log(`🔔 9Router is running in tray (PID: ${process.pid})`); + console.log(` Server: http://${displayHost}:${port}`); + console.log(`\n💡 You can close this terminal. Right-click tray icon to quit.\n`); + + // Tray already init'd at startup — just keep event loop alive. + return; + } + + // Windows/Linux: spawn detached bgProcess (systray works fine in child) + console.log(`\n⏳ Starting background process... (tray icon will appear in ~3s)`); + + const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { ...process.env } + }); + bgProcess.unref(); + + console.log(`🔔 9Router is now running in background (PID: ${bgProcess.pid})`); + console.log(` Server: http://${displayHost}:${port}`); + console.log(`\n💡 You can close this terminal. Right-click tray icon to quit.\n`); + + // cleanup() kills server so bgProcess can claim the port fresh + cleanup(); + process.exit(0); + } else if (choice === "exit") { + isShuttingDown = true; + console.log("\nExiting..."); + cleanup(); + setTimeout(() => process.exit(0), 100); + } + } + } catch (err) { + console.error("Error:", err.message); + cleanup(); + process.exit(1); + } + }); + + function attachServerEvents() { + server.on("error", (err) => { + console.error("Failed to start server:", err.message); + if (!isShuttingDown) tryRestart(); + else { cleanup(); process.exit(1); } + }); + + server.on("close", (code) => { + if (isShuttingDown || code === 0) { + process.exit(code || 0); + return; + } + tryRestart(code); + }); + } + + function tryRestart(code) { + const aliveMs = Date.now() - serverStartTime; + // Reset counter if last run was stable + if (aliveMs >= RESTART_RESET_MS) restartCount = 0; + + if (restartCount >= MAX_RESTARTS) { + console.error(`\n⚠️ Server crashed ${MAX_RESTARTS} times. Disabling MIT and restarting...`); + try { + const dbPath = path.join(os.homedir(), process.platform === "win32" ? path.join("AppData", "Roaming", "9router", "db.json") : path.join(".9router", "db.json")); + if (fs.existsSync(dbPath)) { + const db = JSON.parse(fs.readFileSync(dbPath, "utf-8")); + if (db.settings) db.settings.mitmEnabled = false; + fs.writeFileSync(dbPath, JSON.stringify(db, null, 2)); + } + } catch { /* best effort */ } + restartCount = 0; + server = spawnServer(); + attachServerEvents(); + return; + } + + restartCount++; + const delay = Math.min(1000 * restartCount, 10000); + console.error(`\n⚠️ Server exited (code=${code ?? "unknown"}). Restarting in ${delay / 1000}s... (${restartCount}/${MAX_RESTARTS})`); + if (crashLog.length) { + console.error("\n--- Server crash log ---"); + crashLog.forEach(l => console.error(l)); + console.error("--- End crash log ---\n"); + } + + setTimeout(() => { + server = spawnServer(); + attachServerEvents(); + }, delay); + } + + attachServerEvents(); +} diff --git a/cli/hooks/postinstall.js b/cli/hooks/postinstall.js new file mode 100644 index 0000000..3a59332 --- /dev/null +++ b/cli/hooks/postinstall.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +// Postinstall: warm-up SQLite deps into ~/.9router/runtime so the first +// `9router` start doesn't need network. Failure here is non-fatal — +// cli.js will retry at runtime if anything is missing. +const { ensureSqliteRuntime } = require("./sqliteRuntime"); +const { ensureTrayRuntime } = require("./trayRuntime"); + +try { + ensureSqliteRuntime({ silent: false }); + console.log("[9router] runtime SQLite deps ready"); +} catch (e) { + console.warn(`[9router] runtime warm-up skipped: ${e.message}`); +} + +try { + ensureTrayRuntime({ silent: false }); +} catch (e) { + console.warn(`[9router] tray runtime skipped: ${e.message}`); +} + +process.exit(0); diff --git a/cli/hooks/sqliteRuntime.js b/cli/hooks/sqliteRuntime.js new file mode 100644 index 0000000..feca2f5 --- /dev/null +++ b/cli/hooks/sqliteRuntime.js @@ -0,0 +1,156 @@ +// Ensure better-sqlite3 is installed in USER_DATA_DIR/runtime/node_modules +// (user-writable, avoids Windows EBUSY locks during npm i -g updates). +// sql.js is bundled in bin/app already; node:sqlite / bun:sqlite are built-in. +const { execSync, spawnSync } = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const BETTER_SQLITE3_VERSION = "12.6.2"; +const SQL_JS_VERSION = "1.14.1"; + +function getDataDir() { + if (process.env.DATA_DIR) return process.env.DATA_DIR; + return process.platform === "win32" + ? path.join(process.env.APPDATA || os.homedir(), "9router") + : path.join(os.homedir(), ".9router"); +} + +function getRuntimeDir() { + return path.join(getDataDir(), "runtime"); +} + +function getRuntimeNodeModules() { + return path.join(getRuntimeDir(), "node_modules"); +} + +function ensureRuntimeDir() { + const dir = getRuntimeDir(); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + // Minimal package.json so npm treats it as a project root + const pkgPath = path.join(dir, "package.json"); + if (!fs.existsSync(pkgPath)) { + fs.writeFileSync(pkgPath, JSON.stringify({ + name: "9router-runtime", + version: "1.0.0", + private: true, + description: "User-writable runtime deps for 9router (better-sqlite3 native binary)", + }, null, 2)); + } + return dir; +} + +function hasModule(name) { + return fs.existsSync(path.join(getRuntimeNodeModules(), name, "package.json")); +} + +function isBetterSqliteBinaryValid() { + const binary = path.join(getRuntimeNodeModules(), "better-sqlite3", "build", "Release", "better_sqlite3.node"); + if (!fs.existsSync(binary)) return false; + try { + const fd = fs.openSync(binary, "r"); + const buf = Buffer.alloc(4); + fs.readSync(fd, buf, 0, 4, 0); + fs.closeSync(fd); + const magic = buf.toString("hex"); + if (process.platform === "linux") return magic.startsWith("7f454c46"); + if (process.platform === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); + if (process.platform === "win32") return magic.startsWith("4d5a"); + return true; + } catch { return false; } +} + +// Extract a short, user-friendly reason from npm stderr. +function summarizeNpmError(stderr = "") { + const text = String(stderr); + if (/ENOTFOUND|ETIMEDOUT|EAI_AGAIN|network|getaddrinfo/i.test(text)) return "No internet connection or registry unreachable"; + if (/EACCES|EPERM|permission denied/i.test(text)) return "Permission denied (check folder permissions)"; + if (/ENOSPC|no space/i.test(text)) return "Not enough disk space"; + if (/node-gyp|gyp ERR|python|MSBuild|Visual Studio|Xcode/i.test(text)) return "Missing build tools (Xcode CLT / Python / VS Build Tools)"; + if (/ETARGET|version.*not found/i.test(text)) return "Package version not found on registry"; + const m = text.match(/npm ERR! (.+)/); + if (m) return m[1].slice(0, 200); + const lastLine = text.trim().split(/\r?\n/).filter(Boolean).pop(); + return lastLine ? lastLine.slice(0, 200) : "Unknown error"; +} + +function runNpmInstall({ cwd, pkgs, extraArgs = [], timeout = 180000 }) { + const args = ["install", ...pkgs, "--no-audit", "--no-fund", "--prefer-online", ...extraArgs]; + const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"; + const res = spawnSync(npmCmd, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + timeout, + shell: process.platform === "win32", + encoding: "utf8", + }); + return { ok: res.status === 0, code: res.status, stderr: res.stderr || "", stdout: res.stdout || "" }; +} + +function npmInstall(pkgs, opts = {}) { + const cwd = ensureRuntimeDir(); + const extra = opts.optional ? ["--no-save"] : []; + if (!opts.silent) console.log("⏳ Installing SQLite engine (first run)..."); + const res = runNpmInstall({ cwd, pkgs, extraArgs: extra, timeout: opts.timeout || 180000 }); + if (!res.ok && !opts.silent) { + const reason = summarizeNpmError(res.stderr); + console.warn("⚠️ SQLite engine install failed — using fallback"); + console.warn(` Reason: ${reason}`); + console.warn(` Retry: cd "${cwd}" && npm install ${pkgs.join(" ")}`); + } + return res.ok; +} + +// Public: ensure better-sqlite3 native module is installed in user-writable +// runtime dir. sql.js may be bundled in bin/app, but npm publish strips .wasm +// from nested node_modules — verify and reinstall if missing. node:sqlite is +// built-in. This is purely a *speed optimization* — app works without +// better-sqlite3 via fallbacks. +function isSqlJsWasmValid() { + const bundledWasm = path.join(__dirname, "..", "app", "node_modules", "sql.js", "dist", "sql-wasm.wasm"); + if (fs.existsSync(bundledWasm)) return true; + const runtimeWasm = path.join(getRuntimeNodeModules(), "sql.js", "dist", "sql-wasm.wasm"); + return fs.existsSync(runtimeWasm); +} + +function ensureSqliteRuntime({ silent = false } = {}) { + ensureRuntimeDir(); + + let sqlJsOk = isSqlJsWasmValid(); + if (!sqlJsOk) { + sqlJsOk = npmInstall([`sql.js@${SQL_JS_VERSION}`], { silent }); + if (sqlJsOk) sqlJsOk = isSqlJsWasmValid(); + } + + const needBetterSqlite = !hasModule("better-sqlite3") || !isBetterSqliteBinaryValid(); + if (!needBetterSqlite) { + if (!silent) console.log("✅ SQLite engine ready"); + return { betterSqlite: true, sqlJs: sqlJsOk }; + } + + const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent }); + return { + betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid(), + sqlJs: sqlJsOk, + }; +} + +// Inject runtime + bundled node_modules into NODE_PATH so child Node processes +// resolve sql.js (bundled in bin/app/node_modules) and better-sqlite3 (runtime). +function buildEnvWithRuntime(baseEnv = process.env) { + const runtimeNm = getRuntimeNodeModules(); + const bundledNm = path.join(__dirname, "..", "app", "node_modules"); + const existing = baseEnv.NODE_PATH || ""; + const NODE_PATH = [runtimeNm, bundledNm, existing].filter(Boolean).join(path.delimiter); + return { ...baseEnv, NODE_PATH }; +} + +module.exports = { + ensureSqliteRuntime, + buildEnvWithRuntime, + getRuntimeDir, + getRuntimeNodeModules, + runNpmInstall, + summarizeNpmError, +}; diff --git a/cli/hooks/trayRuntime.js b/cli/hooks/trayRuntime.js new file mode 100644 index 0000000..dafb215 --- /dev/null +++ b/cli/hooks/trayRuntime.js @@ -0,0 +1,107 @@ +// Lazy install systray2 for macOS/Linux into USER_DATA_DIR/runtime/node_modules. +// Windows uses PowerShell NotifyIcon (no binary) → no systray needed. +// This keeps the published npm tarball free of unsigned Go binaries that +// trigger antivirus false positives (e.g. Kaspersky flagging tray_windows.exe). +// +// We use the maintained `systray2` fork. The original `systray@1.0.5` package +// bundles a 2017 x86_64 Go binary whose Mach-O headers are rejected by modern +// dyld (macOS 14+), so the tray silently fails to register on Apple Silicon. +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const { getRuntimeDir, getRuntimeNodeModules, runNpmInstall, summarizeNpmError } = require("./sqliteRuntime"); + +const SYSTRAY_PKG = "systray2"; +const SYSTRAY_VERSION = "2.1.4"; +const LEGACY_SYSTRAY_PKG = "systray"; + +function hasSystray() { + return fs.existsSync(path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "package.json")); +} + +// Remove the legacy `systray` package from all known locations. +// On Windows it was an AV false-positive risk; on macOS/Linux its bundled +// binary is broken on modern OS versions. +function cleanupLegacySystray({ silent = false } = {}) { + // 1) Runtime dir: ~/.9router/runtime/node_modules/systray (or %APPDATA% on Win) + // 2) npm global nested: /node_modules/9router/node_modules/systray + // __dirname here = /hooks → up 1 = pkg root + const targets = [ + path.join(getRuntimeNodeModules(), LEGACY_SYSTRAY_PKG), + path.join(__dirname, "..", "node_modules", LEGACY_SYSTRAY_PKG) + ]; + for (const dir of targets) { + if (fs.existsSync(dir)) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + if (!silent) console.log(`[9router][runtime] removed legacy systray: ${dir}`); + } catch (e) { + if (!silent) console.warn(`[9router][runtime] failed to remove ${dir}: ${e.message}`); + } + } + } +} + +// systray2's npm tarball sometimes ships the bundled Go binary without the +// executable bit set on macOS, causing spawn() to fail with EACCES. Set +x +// best-effort so the tray actually starts. +function chmodSystrayBin({ silent = false } = {}) { + if (process.platform === "win32") return; + const binName = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release"; + const binPath = path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "traybin", binName); + if (!fs.existsSync(binPath)) return; + try { + fs.chmodSync(binPath, 0o755); + } catch (e) { + if (!silent) console.warn(`[9router][runtime] chmod tray bin failed: ${e.message}`); + } +} + +function ensureRuntimeDir() { + const dir = getRuntimeDir(); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const pkgPath = path.join(dir, "package.json"); + if (!fs.existsSync(pkgPath)) { + fs.writeFileSync(pkgPath, JSON.stringify({ + name: "9router-runtime", + version: "1.0.0", + private: true + }, null, 2)); + } + return dir; +} + +function npmInstall(pkgs, { silent = false } = {}) { + const cwd = ensureRuntimeDir(); + if (!silent) console.log("⏳ Installing system tray (first run)..."); + const res = runNpmInstall({ cwd, pkgs, extraArgs: ["--no-save"], timeout: 120000 }); + if (!res.ok && !silent) { + const reason = summarizeNpmError(res.stderr); + console.warn("⚠️ System tray install failed — tray disabled"); + console.warn(` Reason: ${reason}`); + console.warn(` Retry: cd "${cwd}" && npm install ${pkgs.join(" ")}`); + } + return res.ok; +} + +// Public: ensure systray2 is installed on macOS/Linux only. +// Windows skips entirely (uses PowerShell tray). +function ensureTrayRuntime({ silent = false } = {}) { + // Always evict the legacy `systray` package — its binary is broken on + // modern macOS and an AV false-positive on Windows. + cleanupLegacySystray({ silent }); + + if (process.platform === "win32") { + return { systray: false, skipped: true }; + } + if (hasSystray()) { + chmodSystrayBin({ silent }); + if (!silent) console.log("✅ System tray ready"); + return { systray: true }; + } + const ok = npmInstall([`${SYSTRAY_PKG}@${SYSTRAY_VERSION}`], { silent }); + if (ok) chmodSystrayBin({ silent }); + return { systray: ok && hasSystray() }; +} + +module.exports = { ensureTrayRuntime }; diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..e72fd18 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,48 @@ +{ + "name": "9router", + "version": "0.5.30", + "description": "9Router CLI - Start and manage 9Router server", + "bin": { + "9router": "./cli.js" + }, + "files": [ + "cli.js", + "src", + "hooks", + "app", + "README.md", + "LICENSE" + ], + "scripts": { + "dev": "nodemon -I --watch cli.js --watch src --watch hooks --ext js,json cli.js", + "build": "node scripts/build-cli.js", + "pack:cli": "npm run build && npm pack --pack-destination ../..", + "publish:cli": "npm run build && npm publish", + "postinstall": "node hooks/postinstall.js", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "enquirer": "^2.4.1", + "node-forge": "^1.3.3", + "node-machine-id": "^1.1.12", + "react": "19.2.1", + "react-dom": "19.2.1" + }, + "comment_sqlite": "sql.js + better-sqlite3 are NOT bundled here. They are installed into ~/.9router/runtime/node_modules by hooks/postinstall.js (and re-checked at runtime by cli.js). This avoids Windows EBUSY errors when updating the global CLI, since native .node files no longer live under the locked install dir.", + "comment_systray": "systray2 is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails on modern macOS dyld.", + "engines": { + "node": ">=18.0.0" + }, + "keywords": [ + "9router", + "cli", + "proxy", + "ai", + "api" + ], + "license": "MIT", + "devDependencies": { + "esbuild": "^0.25.12", + "nodemon": "^3.1.14" + } +} diff --git a/cli/scripts/build-cli.js b/cli/scripts/build-cli.js new file mode 100644 index 0000000..a1b28c5 --- /dev/null +++ b/cli/scripts/build-cli.js @@ -0,0 +1,281 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); + +const cliDir = path.resolve(__dirname, ".."); +const appDir = path.resolve(cliDir, ".."); +const rootDir = path.resolve(appDir, ".."); +const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app"); +const buildHomeDir = path.join(cliDir, ".build-home"); +const buildDistDirName = ".next-cli-build"; +const buildDistDir = path.join(appDir, buildDistDirName); + +// Exclude patterns for files/folders we don't want to copy +const EXCLUDE_PATTERNS = [ + "@img", // Sharp image processing (not needed with unoptimized images) + "sharp", // Sharp core lib (not needed with unoptimized images) + "detect-libc", // Sharp dependency + ".env", // Environment files + ".env.local", + ".env.*.local", + "*.log", // Log files + "tmp", // Temp files + ".DS_Store", // macOS files +]; + +function shouldExclude(name) { + return EXCLUDE_PATTERNS.some(pattern => { + if (pattern.includes("*")) { + const regex = new RegExp("^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$"); + return regex.test(name); + } + return name === pattern; + }); +} + +function copyRecursive(src, dest) { + if (!fs.existsSync(src)) { + console.warn(`Warning: Source ${src} does not exist`); + return; + } + + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + + const entries = fs.readdirSync(src, { withFileTypes: true }); + for (const entry of entries) { + if (shouldExclude(entry.name)) { + continue; + } + + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + // Skip broken symlinks (common in workspace setups) + try { + fs.accessSync(srcPath); + } catch { + continue; + } + + if (entry.isDirectory()) { + copyRecursive(srcPath, destPath); + } else if (entry.isSymbolicLink()) { + // Resolve and copy target (avoid linking outside bundle) + try { + const real = fs.realpathSync(srcPath); + if (fs.statSync(real).isDirectory()) { + copyRecursive(real, destPath); + } else { + fs.copyFileSync(real, destPath); + } + } catch {} + } else { + try { + fs.copyFileSync(srcPath, destPath); + } catch {} + } + } +} + +console.log("📦 Building 9Router CLI package with Next.js...\n"); + +fs.mkdirSync(buildHomeDir, { recursive: true }); +fs.mkdirSync(path.join(buildHomeDir, "AppData", "Roaming"), { recursive: true }); +fs.mkdirSync(path.join(buildHomeDir, "AppData", "Local"), { recursive: true }); + +// Step 0: Sync version from app/cli/package.json to app/package.json +console.log("0️⃣ Syncing version to app/package.json..."); +const cliPkg = JSON.parse(fs.readFileSync(path.join(cliDir, "package.json"), "utf8")); +const appPkgPath = path.join(appDir, "package.json"); +const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8")); +if (appPkg.version !== cliPkg.version) { + appPkg.version = cliPkg.version; + fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n"); + console.log(`✅ Version synced: ${cliPkg.version}\n`); +} else { + console.log(`✅ Version already synced: ${cliPkg.version}\n`); +} + +// Step 1: Build app with Next.js (workspace tracing root → traced node_modules in standalone). +console.log("1️⃣ Building Next.js app..."); +try { + execSync("npm run build", { + stdio: "inherit", + cwd: appDir, + env: { + ...process.env, + HOME: buildHomeDir, + USERPROFILE: buildHomeDir, + APPDATA: path.join(buildHomeDir, "AppData", "Roaming"), + LOCALAPPDATA: path.join(buildHomeDir, "AppData", "Local"), + NEXT_DIST_DIR: buildDistDirName, + NEXT_TRACING_ROOT_MODE: "workspace", + } + }); + console.log("✅ Next.js build completed\n"); +} catch (error) { + console.error("❌ Next.js build failed"); + process.exit(1); +} + +// Step 2: Clean old app/cli/app if exists +console.log("2️⃣ Cleaning old app/cli/app..."); +if (fs.existsSync(cliAppDir)) { + fs.rmSync(cliAppDir, { recursive: true, force: true }); +} +console.log("✅ Cleaned\n"); + +// Step 3: Copy Next.js standalone build to app/cli/app. +// Newer Next.js standalone output writes server.js/package.json plus .next/, src/, and +// node_modules/ directly under .next/standalone. Older builds may still use a nested app/. +console.log("3️⃣ Copying Next.js standalone build to app/cli/app..."); +const standaloneRoot = path.join(appDir, ".next", "standalone"); +const standaloneRootResolved = path.join(buildDistDir, "standalone"); +let standaloneRootToUse = fs.existsSync(standaloneRootResolved) ? standaloneRootResolved : standaloneRoot; +// Next.js 16 nests standalone output under the project name when NEXT_TRACING_ROOT_MODE=workspace +// e.g. .next-cli-build/standalone/9router/server.js +const pkgName = path.basename(appDir); +const nestedRoot = path.join(standaloneRootToUse, pkgName); +if (fs.existsSync(path.join(nestedRoot, "server.js")) && !fs.existsSync(path.join(standaloneRootToUse, "server.js"))) { + console.log(`ℹ️ Detected nested standalone output: ${pkgName}/`); + standaloneRootToUse = nestedRoot; +} +const standaloneApp = fs.existsSync(path.join(standaloneRootToUse, "server.js")) + ? standaloneRootToUse + : path.join(standaloneRootToUse, "app"); +if (!fs.existsSync(standaloneApp)) { + console.error("❌ Next.js standalone build not found under .next/standalone"); + console.error("Expected either .next/standalone/server.js or .next/standalone/app/"); + process.exit(1); +} +copyRecursive(standaloneApp, cliAppDir); + +// Older nested-app layout stores traced node_modules at standalone root. +const standaloneNodeModules = path.join(standaloneRootToUse, "node_modules"); +if (standaloneApp !== standaloneRootToUse && fs.existsSync(standaloneNodeModules)) { + copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules")); +} +console.log("✅ Copied standalone build\n"); + +// Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF). +const customServerSrc = path.join(appDir, "custom-server.js"); +if (fs.existsSync(customServerSrc)) { + fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js")); + console.log("✅ Copied custom-server.js\n"); +} else { + console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n"); +} + +// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules. +// Strip better-sqlite3 (native) — it lives in ~/.9router/runtime to avoid +// Windows EBUSY during global CLI updates. node:sqlite (Node ≥22.5) is also +// available as a no-install middle tier. +console.log("3️⃣ b Configuring SQLite drivers..."); +function ensureModuleInBundle(pkg) { + const dest = path.join(cliAppDir, "node_modules", pkg); + if (fs.existsSync(dest)) { + console.log(`✅ ${pkg} already bundled`); + return; + } + const candidates = [ + path.join(appDir, "node_modules", pkg), + path.join(rootDir, "node_modules", pkg), + ]; + const src = candidates.find((p) => fs.existsSync(p)); + if (!src) { + console.warn(`⚠️ ${pkg} not found locally — bundle will rely on node:sqlite or runtime install`); + return; + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + copyRecursive(src, dest); + console.log(`✅ Bundled ${pkg}`); +} +ensureModuleInBundle("sql.js"); +const betterDir = path.join(cliAppDir, "node_modules", "better-sqlite3"); +if (fs.existsSync(betterDir)) { + fs.rmSync(betterDir, { recursive: true, force: true }); + console.log("✅ Stripped better-sqlite3 (lives in ~/.9router/runtime)"); +} +console.log(""); + +// Step 4: Copy static files +console.log("4️⃣ Copying static files..."); +const staticSrc = path.join(appDir, ".next", "static"); +const staticSrcResolved = path.join(buildDistDir, "static"); +const staticDest = path.join(cliAppDir, buildDistDirName, "static"); +if (fs.existsSync(staticSrcResolved) || fs.existsSync(staticSrc)) { + copyRecursive(fs.existsSync(staticSrcResolved) ? staticSrcResolved : staticSrc, staticDest); + console.log("✅ Copied static files\n"); +} else { + console.log("⏭️ No static files found\n"); +} + +// Step 5: Copy public folder if exists +console.log("5️⃣ Copying public folder..."); +const publicSrc = path.join(appDir, "public"); +const publicDest = path.join(cliAppDir, "public"); +if (fs.existsSync(publicSrc)) { + copyRecursive(publicSrc, publicDest); + console.log("✅ Copied public folder\n"); +} else { + console.log("⏭️ No public folder found\n"); +} + +// Step 6: Copy vendor-chunks (required for production) +console.log("6️⃣ Copying vendor-chunks..."); +const vendorChunksSrc = path.join(appDir, ".next", "server", "vendor-chunks"); +const vendorChunksSrcResolved = path.join(buildDistDir, "server", "vendor-chunks"); +const vendorChunksDest = path.join(cliAppDir, buildDistDirName, "server", "vendor-chunks"); +if (fs.existsSync(vendorChunksSrcResolved) || fs.existsSync(vendorChunksSrc)) { + copyRecursive(fs.existsSync(vendorChunksSrcResolved) ? vendorChunksSrcResolved : vendorChunksSrc, vendorChunksDest); + console.log("✅ Copied vendor-chunks\n"); +} else { + console.log("⏭️ No vendor-chunks found\n"); +} + +// Step 7: Copy MITM server files (not bundled by Next.js standalone) +console.log("7️⃣ Copying MITM server files..."); +const mitmSrc = path.join(appDir, "src", "mitm"); +const mitmDest = path.join(cliAppDir, "src", "mitm"); +if (fs.existsSync(mitmSrc)) { + copyRecursive(mitmSrc, mitmDest); + console.log("✅ Copied MITM files\n"); +} else { + console.log("⏭️ No MITM files found\n"); +} + +// Step 7b: Copy standalone updater (headless Node process for install progress) +console.log("7️⃣ b Copying updater files..."); +const updaterSrc = path.join(appDir, "src", "lib", "updater"); +const updaterDest = path.join(cliAppDir, "src", "lib", "updater"); +if (fs.existsSync(updaterSrc)) { + copyRecursive(updaterSrc, updaterDest); + console.log("✅ Copied updater files\n"); +} else { + console.log("⏭️ No updater files found\n"); +} + +// Step 8: Build MITM server (config driven - see app/cli/scripts/buildMitm.js) +console.log("8️⃣ Building MITM server..."); +try { + execSync("node scripts/buildMitm.js", { stdio: "inherit", cwd: cliDir }); + console.log("✅ MITM server build completed\n"); +} catch (error) { + console.error("❌ MITM build failed"); + process.exit(1); +} + +console.log("✨ CLI package build completed!"); +console.log(`📁 Output: ${cliAppDir}`); + +try { + const { execSync: exec } = require("child_process"); + const size = exec(`du -sh "${cliAppDir}"`, { encoding: "utf8" }).trim(); + console.log(`📊 Package size: ${size.split("\t")[0]}`); +} catch (e) { + // Silent fail on size check +} diff --git a/cli/scripts/buildMitm.js b/cli/scripts/buildMitm.js new file mode 100644 index 0000000..e47f593 --- /dev/null +++ b/cli/scripts/buildMitm.js @@ -0,0 +1,71 @@ +const esbuild = require("esbuild"); +const fs = require("fs"); +const path = require("path"); + +// ── Build config ───────────────────────────────────────── +const BUILD_CONFIG = { + bundle: true, + minify: true, + cleanPlainFiles: true, +}; +// ───────────────────────────────────────────────────────── + +const cliDir = path.resolve(__dirname, ".."); +const appDir = path.resolve(cliDir, ".."); +const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app"); +const cliMitmDir = path.join(cliAppDir, "src", "mitm"); +// Bundle everything — no externals. This keeps MITM runtime self-contained so +// it can be copied to DATA_DIR/runtime/ and spawned from there (escapes +// node_modules file locks that block `npm i -g 9router@latest` on Windows). +const EXTERNALS = []; +const ENTRIES = ["server.js"]; + +async function buildEntry(entry) { + const mitmSrc = path.join(appDir, "src", "mitm"); + const output = path.join(cliMitmDir, entry); + + const buildPlugin = { + name: "build-plugin", + setup(build) { + // Stub .git file scanned by esbuild + build.onResolve({ filter: /\.git/ }, args => ({ path: args.path, namespace: "git-stub" })); + build.onLoad({ filter: /.*/, namespace: "git-stub" }, () => ({ contents: "module.exports={}", loader: "js" })); + }, + }; + + const steps = []; + + if (BUILD_CONFIG.bundle) { + await esbuild.build({ + entryPoints: [path.join(mitmSrc, entry)], + bundle: true, + minify: BUILD_CONFIG.minify, + platform: "node", + target: "node18", + external: EXTERNALS, + plugins: [buildPlugin], + outfile: output, + }); + steps.push("bundled"); + if (BUILD_CONFIG.minify) steps.push("minified"); + } + + console.log(`✅ ${steps.join(" + ")} → ${output}`); +} + +async function run() { + const flags = Object.entries(BUILD_CONFIG).filter(([, v]) => v).map(([k]) => k).join(", "); + console.log(`⚙️ Config: ${flags}`); + + for (const entry of ENTRIES) await buildEntry(entry); + + if (BUILD_CONFIG.cleanPlainFiles) { + const keep = new Set(ENTRIES); + for (const name of fs.readdirSync(cliMitmDir)) { + if (!keep.has(name)) fs.rmSync(path.join(cliMitmDir, name), { recursive: true, force: true }); + } + console.log("✅ Removed plain MITM files from CLI bundle"); + } +} + +run().catch((e) => { console.error(e); process.exit(1); }); diff --git a/cli/src/cli/api/client.js b/cli/src/cli/api/client.js new file mode 100644 index 0000000..257fcd2 --- /dev/null +++ b/cli/src/cli/api/client.js @@ -0,0 +1,556 @@ +const http = require("http"); +const https = require("https"); +const crypto = require("crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const { machineIdSync } = require("node-machine-id"); + +// Default configuration +const DEFAULT_CONFIG = { + host: "localhost", + port: 20128, + protocol: "http:", +}; + +const CLI_TOKEN_HEADER = "x-9r-cli-token"; +const CLI_TOKEN_SALT = "9r-cli-auth"; +const APP_NAME = "9router"; + +function getDataDir() { + if (process.env.DATA_DIR) return process.env.DATA_DIR; + if (process.platform === "win32") { + return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), APP_NAME); + } + return path.join(os.homedir(), `.${APP_NAME}`); +} + +const MACHINE_ID_FILE = path.join(getDataDir(), "machine-id"); +const AUTH_DIR = path.join(getDataDir(), "auth"); +const CLI_SECRET_FILE = path.join(AUTH_DIR, "cli-secret"); + +let config = { ...DEFAULT_CONFIG }; +let cachedCliToken = null; +let cachedCliSecret = null; + +// Read raw machineId from shared file (written by server) → guarantees token match +function loadRawMachineId() { + try { + const raw = fs.readFileSync(MACHINE_ID_FILE, "utf8").trim(); + if (raw) return raw; + } catch {} + try { return machineIdSync(); } catch { return ""; } +} + +// Random secret shared with server via file → token unpredictable from machineId alone. +function loadCliSecret() { + if (cachedCliSecret) return cachedCliSecret; + try { + cachedCliSecret = fs.readFileSync(CLI_SECRET_FILE, "utf8").trim(); + if (cachedCliSecret) return cachedCliSecret; + } catch {} + cachedCliSecret = crypto.randomBytes(32).toString("hex"); + try { + fs.mkdirSync(AUTH_DIR, { recursive: true }); + fs.writeFileSync(CLI_SECRET_FILE, cachedCliSecret, { mode: 0o600 }); + } catch {} + return cachedCliSecret; +} + +function getCliToken() { + if (cachedCliToken !== null) return cachedCliToken; + const raw = loadRawMachineId(); + const secret = loadCliSecret(); + cachedCliToken = raw ? crypto.createHash("sha256").update(raw + CLI_TOKEN_SALT + secret).digest("hex").substring(0, 16) : ""; + return cachedCliToken; +} + +/** + * Configure API client + * @param {Object} options - Configuration options + * @param {string} options.host - API host + * @param {number} options.port - API port + * @param {string} options.protocol - Protocol (http: or https:) + */ +function configure(options = {}) { + config = { ...config, ...options }; +} + +/** + * Make HTTP request to API + * @param {string} method - HTTP method + * @param {string} path - API path + * @param {Object} body - Request body (optional) + * @returns {Promise} Response with { success, data/error } + */ +function makeRequest(method, path, body = null) { + return new Promise((resolve) => { + const httpModule = config.protocol === "https:" ? https : http; + + const options = { + hostname: config.host, + port: config.port, + path: path, + method: method, + headers: { + "Content-Type": "application/json", + [CLI_TOKEN_HEADER]: getCliToken(), + }, + }; + + // Add Content-Length for POST/PUT requests + if (body && (method === "POST" || method === "PUT" || method === "PATCH")) { + const bodyString = JSON.stringify(body); + options.headers["Content-Length"] = Buffer.byteLength(bodyString); + } + + const req = httpModule.request(options, (res) => { + let data = ""; + + res.on("data", (chunk) => { + data += chunk; + }); + + res.on("end", () => { + try { + const parsed = data ? JSON.parse(data) : {}; + + // Check if response indicates error + if (res.statusCode >= 400 || parsed.error) { + resolve({ + success: false, + error: parsed.error || `HTTP ${res.statusCode}`, + statusCode: res.statusCode, + }); + } else { + resolve({ + success: true, + data: parsed, + statusCode: res.statusCode, + }); + } + } catch (err) { + resolve({ + success: false, + error: `Failed to parse response: ${err.message}`, + }); + } + }); + }); + + req.on("error", (err) => { + resolve({ + success: false, + error: `Network error: ${err.message}`, + }); + }); + + req.on("timeout", () => { + req.destroy(); + resolve({ + success: false, + error: "Request timeout", + }); + }); + + // Set timeout (30 seconds) + req.setTimeout(30000); + + // Write body if present + if (body && (method === "POST" || method === "PUT" || method === "PATCH")) { + req.write(JSON.stringify(body)); + } + + req.end(); + }); +} + +// ============================================================================ +// PROVIDERS API +// ============================================================================ + +/** + * Get all providers + * @returns {Promise} { success, data: { connections } } + */ +async function getProviders() { + return makeRequest("GET", "/api/providers"); +} + +/** + * Get provider by ID + * @param {string} id - Provider ID + * @returns {Promise} { success, data: { connection } } + */ +async function getProviderById(id) { + return makeRequest("GET", `/api/providers/${id}`); +} + +/** + * Test provider connection + * @param {string} id - Provider ID + * @returns {Promise} { success, data: { valid, error } } + */ +async function testProvider(id) { + return makeRequest("POST", `/api/providers/${id}/test`); +} + +/** + * Delete provider + * @param {string} id - Provider ID + * @returns {Promise} { success, data: { message } } + */ +async function deleteProvider(id) { + return makeRequest("DELETE", `/api/providers/${id}`); +} + +/** + * Get provider models + * @param {string} id - Provider ID + * @returns {Promise} { success, data: { provider, connectionId, models } } + */ +async function getProviderModels(id) { + return makeRequest("GET", `/api/providers/${id}/models`); +} + +// ============================================================================ +// OAUTH API +// ============================================================================ + +/** + * Get OAuth authorization URL + * @param {string} provider - Provider ID + * @returns {Promise} { success, data: { authUrl, codeVerifier, state, redirectUri } } + */ +async function getOAuthAuthUrl(provider) { + // Codex requires fixed port 1455 and path /auth/callback + const redirectUri = provider === "codex" + ? "http://localhost:1455/auth/callback" + : "http://localhost:20128/callback"; + return makeRequest("GET", `/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`); +} + +/** + * Exchange OAuth authorization code for token + * @param {string} provider - Provider ID + * @param {Object} data - { code, redirectUri, codeVerifier, state } + * @returns {Promise} { success, data } + */ +async function exchangeOAuthCode(provider, data) { + return makeRequest("POST", `/api/oauth/${provider}/exchange`, data); +} + +/** + * Get OAuth device code + * @param {string} provider - Provider ID + * @returns {Promise} { success, data: { device_code, user_code, verification_uri, verification_uri_complete, codeVerifier, extraData } } + */ +async function getOAuthDeviceCode(provider) { + return makeRequest("GET", `/api/oauth/${provider}/device-code`); +} + +/** + * Poll OAuth token using device code + * @param {string} provider - Provider ID + * @param {Object} data - { deviceCode, codeVerifier, extraData } + * @returns {Promise} { success, data: { pending } } + */ +async function pollOAuthToken(provider, data) { + return makeRequest("POST", `/api/oauth/${provider}/poll`, data); +} + +/** + * Create API key provider connection + * @param {Object} data - { provider, name, apiKey } + * @returns {Promise} { success, data } + */ +async function createApiKeyProvider(data) { + return makeRequest("POST", "/api/providers", data); +} + +/** + * Update provider connection + * @param {string} id - Connection ID + * @param {Object} data - { name, priority, defaultModel, isActive } + * @returns {Promise} { success, data: { connection } } + */ +async function updateConnection(id, data) { + return makeRequest("PUT", `/api/providers/${id}`, data); +} + +// ============================================================================ +// API KEYS API +// ============================================================================ + +/** + * Get all API keys + * @returns {Promise} { success, data: { keys } } + */ +async function getApiKeys() { + return makeRequest("GET", "/api/keys"); +} + +/** + * Create new API key + * @param {string} name - Key name + * @returns {Promise} { success, data: { key, name, id, machineId } } + */ +async function createApiKey(name) { + return makeRequest("POST", "/api/keys", { name }); +} + +/** + * Delete API key + * @param {string} id - Key ID + * @returns {Promise} { success, data: { success } } + */ +async function deleteApiKey(id) { + return makeRequest("DELETE", `/api/keys/${id}`); +} + +// ============================================================================ +// COMBOS API +// ============================================================================ + +/** + * Get all combos + * @returns {Promise} { success, data: { combos } } + */ +async function getCombos() { + return makeRequest("GET", "/api/combos"); +} + +/** + * Get combo by ID + * @param {string} id - Combo ID + * @returns {Promise} { success, data: combo } + */ +async function getComboById(id) { + return makeRequest("GET", `/api/combos/${id}`); +} + +/** + * Create new combo + * @param {Object} data - Combo data { name, models } + * @returns {Promise} { success, data: combo } + */ +async function createCombo(data) { + return makeRequest("POST", "/api/combos", data); +} + +/** + * Update combo + * @param {string} id - Combo ID + * @param {Object} data - Update data { name?, models? } + * @returns {Promise} { success, data: combo } + */ +async function updateCombo(id, data) { + return makeRequest("PUT", `/api/combos/${id}`, data); +} + +/** + * Delete combo + * @param {string} id - Combo ID + * @returns {Promise} { success, data: { success } } + */ +async function deleteCombo(id) { + return makeRequest("DELETE", `/api/combos/${id}`); +} + +// ============================================================================ +// CLI TOOLS API +// ============================================================================ + +/** + * Get CLI tool settings + * @param {string} tool - Tool name: claude | codex | droid | openclaw + * @returns {Promise} { success, data: { installed, has9Router, ... } } + */ +async function getCliToolSettings(tool) { + return makeRequest("GET", `/api/cli-tools/${tool}-settings`); +} + +/** + * Apply CLI tool settings (POST) + * @param {string} tool - Tool name: claude | codex | droid | openclaw + * @param {Object} body - Payload depends on tool + * @returns {Promise} { success, data } + */ +async function applyCliToolSettings(tool, body) { + return makeRequest("POST", `/api/cli-tools/${tool}-settings`, body); +} + +/** + * Reset CLI tool settings (DELETE) + * @param {string} tool - Tool name: claude | codex | droid | openclaw + * @returns {Promise} { success, data } + */ +async function resetCliToolSettings(tool) { + return makeRequest("DELETE", `/api/cli-tools/${tool}-settings`); +} + +// ============================================================================ +// SETTINGS API +// ============================================================================ + +/** + * Get settings + * @returns {Promise} { success, data: settings } + */ +async function getSettings() { + return makeRequest("GET", "/api/settings"); +} + +/** + * Update settings + * @param {Object} data - Settings data + * @returns {Promise} { success, data: settings } + */ +async function updateSettings(data) { + return makeRequest("PATCH", "/api/settings", data); +} + +/** + * Reset dashboard password to default (clears stored hash server-side) + * @returns {Promise} { success } + */ +async function resetPassword() { + return makeRequest("POST", "/api/auth/reset-password"); +} + +// ============================================================================ +// MODELS API +// ============================================================================ + +/** + * Get all models (internal API) + * @returns {Promise} { success, data: { models } } + */ +async function getModels() { + return makeRequest("GET", "/api/models"); +} + +/** + * Get available models from active providers + combos (OpenAI compatible) + * @returns {Promise} { success, data: { object, data: [...models] } } + */ +async function getAvailableModels() { + return makeRequest("GET", "/v1/models"); +} + +// ============================================================================ +// PROVIDER NODES API (custom providers) +// ============================================================================ + +async function getProviderNodes() { + return makeRequest("GET", "/api/provider-nodes"); +} + +async function createProviderNode(data) { + return makeRequest("POST", "/api/provider-nodes", data); +} + +async function updateProviderNode(id, data) { + return makeRequest("PUT", `/api/provider-nodes/${id}`, data); +} + +async function deleteProviderNode(id) { + return makeRequest("DELETE", `/api/provider-nodes/${id}`); +} + +async function validateProviderNode(data) { + return makeRequest("POST", "/api/provider-nodes/validate", data); +} + +// ============================================================================ +// TUNNEL API +// ============================================================================ + +/** + * Get tunnel status + * @returns {Promise} { success, data: { enabled, tunnelUrl, shortId, running } } + */ +async function getTunnelStatus() { + return makeRequest("GET", "/api/tunnel/status"); +} + +/** + * Enable tunnel + * @returns {Promise} { success, data: { tunnelUrl, shortId } } + */ +async function enableTunnel() { + return makeRequest("POST", "/api/tunnel/enable"); +} + +/** + * Disable tunnel + * @returns {Promise} { success, data: { success } } + */ +async function disableTunnel() { + return makeRequest("POST", "/api/tunnel/disable"); +} + +// ============================================================================ +// EXPORTS +// ============================================================================ + +module.exports = { + configure, + + // Providers + getProviders, + getProviderById, + testProvider, + deleteProvider, + getProviderModels, + + // Connection aliases + testConnection: testProvider, + deleteConnection: deleteProvider, + updateConnection, + + // OAuth + getOAuthAuthUrl, + exchangeOAuthCode, + getOAuthDeviceCode, + pollOAuthToken, + createApiKeyProvider, + + // API Keys + getApiKeys, + createApiKey, + deleteApiKey, + + // Combos + getCombos, + getComboById, + createCombo, + updateCombo, + deleteCombo, + + // CLI Tools + getCliToolSettings, + applyCliToolSettings, + resetCliToolSettings, + + // Settings + getSettings, + updateSettings, + resetPassword, + + // Tunnel + getTunnelStatus, + enableTunnel, + disableTunnel, + + // Models + getModels, + getAvailableModels, + + // Provider Nodes (custom providers) + getProviderNodes, + createProviderNode, + updateProviderNode, + deleteProviderNode, + validateProviderNode, +}; diff --git a/cli/src/cli/menus/apiKeys.js b/cli/src/cli/menus/apiKeys.js new file mode 100644 index 0000000..ecd4e68 --- /dev/null +++ b/cli/src/cli/menus/apiKeys.js @@ -0,0 +1,233 @@ +const api = require("../api/client"); +const { prompt, confirm, pause } = require("../utils/input"); +const { clearScreen, showStatus, showHeader } = require("../utils/display"); +const { maskKey, formatDate, getRelativeTime } = require("../utils/format"); +const { showMenuWithBack } = require("../utils/menuHelper"); +const { copyToClipboard } = require("../utils/clipboard"); +const { getEndpoint } = require("../utils/endpoint"); + +/** + * Display API keys list with formatted output + * @param {Array} keys - Array of API key objects + * @param {number} port - Server port + */ +function displayApiKeys(keys, port) { + console.log("┌─────────────────────────────────────────────────────────┐"); + console.log("│ 🔑 API Keys Management │"); + console.log("├─────────────────────────────────────────────────────────┤"); + // Note: This function is legacy, endpoint shown in menu header instead + console.log("│ │"); + + if (keys.length === 0) { + console.log("│ No API keys found. │"); + } else { + console.log(`│ Your API Keys (${keys.length}):${" ".repeat(42 - String(keys.length).length)}│`); + + keys.forEach((key, index) => { + console.log("│ │"); + console.log(`│ ${index + 1}. ${key.name}${" ".repeat(52 - String(index + 1).length - key.name.length)}│`); + + const maskedKey = maskKey(key.key); + console.log(`│ Key: ${maskedKey}${" ".repeat(47 - maskedKey.length)}│`); + + const created = formatDate(key.createdAt); + console.log(`│ Created: ${created}${" ".repeat(43 - created.length)}│`); + + if (key.lastUsedAt) { + const lastUsed = getRelativeTime(key.lastUsedAt); + console.log(`│ Last used: ${lastUsed}${" ".repeat(41 - lastUsed.length)}│`); + } else { + console.log("│ Last used: Never │"); + } + }); + } + + console.log("│ │"); + console.log("│ Actions: │"); + console.log("│ 1. Create New API Key │"); + console.log("│ 2. View Full Key (by number) │"); + console.log("│ 3. Copy Key to Clipboard (by number) │"); + console.log("│ 4. Delete Key (by number) │"); + console.log("│ 0. ← Back to Main Menu │"); + console.log("└─────────────────────────────────────────────────────────┘"); +} + +/** + * Handle creating new API key + * @returns {Promise} Success status + */ +async function handleCreateKey() { + console.log("\n📝 Create New API Key"); + console.log("─".repeat(30)); + + const name = await prompt("Enter key name: "); + + if (!name) { + showStatus("Key name cannot be empty", "error"); + await pause(); + return false; + } + + const result = await api.createApiKey(name); + + if (!result.success) { + showStatus(`Failed to create key: ${result.error}`, "error"); + await pause(); + return false; + } + + console.log("\n✅ API Key created successfully!"); + console.log("\n⚠️ IMPORTANT: Save this key now. You won't be able to see it again!"); + console.log(`\nKey: ${result.data.key}`); + console.log(`Name: ${result.data.name}`); + console.log(`ID: ${result.data.id}`); + + const shouldCopy = await confirm("\nCopy key to clipboard?"); + if (shouldCopy) { + if (copyToClipboard(result.data.key)) { + showStatus("Key copied to clipboard!", "success"); + } else { + showStatus("Failed to copy to clipboard", "error"); + } + } + + await pause(); + return true; +} + +/** + * Handle viewing full API key + * @param {Object} key - API key object + */ +async function handleViewFullKey(key) { + console.log("\n🔍 Full API Key"); + console.log("─".repeat(30)); + console.log(`Name: ${key.name}`); + console.log(`Key: ${key.key}`); + console.log(`ID: ${key.id}`); + console.log(`Created: ${formatDate(key.createdAt)}`); + + if (key.lastUsedAt) { + console.log(`Last used: ${getRelativeTime(key.lastUsedAt)}`); + } else { + console.log("Last used: Never"); + } + + await pause(); +} + +/** + * Handle copying API key to clipboard + * @param {Object} key - API key object + */ +async function handleCopyKey(key) { + if (copyToClipboard(key.key)) { + showStatus(`Key "${key.name}" copied to clipboard!`, "success"); + } else { + showStatus("Failed to copy to clipboard", "error"); + } + await pause(); +} + +/** + * Handle deleting API key + * @param {Object} key - API key object + * @returns {Promise} Success status + */ +async function handleDeleteKey(key) { + console.log(`\n⚠️ Delete API Key: ${key.name}`); + console.log("─".repeat(30)); + console.log(`Key: ${maskKey(key.key)}`); + console.log(`Created: ${formatDate(key.createdAt)}`); + + const confirmed = await confirm("\nAre you sure you want to delete this key?"); + + if (!confirmed) { + showStatus("Deletion cancelled", "info"); + await pause(); + return false; + } + + const result = await api.deleteApiKey(key.id); + + if (!result.success) { + showStatus(`Failed to delete key: ${result.error}`, "error"); + await pause(); + return false; + } + + showStatus("API key deleted successfully", "success"); + await pause(); + return true; +} + +/** + * Show actions for a specific key + * @param {Object} key - API key object + * @param {number} port - Server port + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showKeyActions(key, port, breadcrumb = []) { + const { endpoint } = await getEndpoint(port); + await showMenuWithBack({ + title: `🔑 ${key.name}`, + breadcrumb: [...breadcrumb, key.name], + headerContent: `Name: ${key.name}\nKey: ${key.key}\nEndpoint: ${endpoint}`, + items: [ + { + label: "Copy to Clipboard", + action: async () => { + await handleCopyKey(key); + return true; + } + }, + { + label: "Delete Key", + action: async () => { + await handleDeleteKey(key); + return false; // Exit after delete + } + } + ] + }); +} + +/** + * Main API Keys menu + * @param {number} port - Server port number + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showApiKeysMenu(port, breadcrumb = []) { + const { showListMenu } = require("../utils/menuHelper"); + + const { endpoint } = await getEndpoint(port); + await showListMenu({ + title: "🔑 API Keys Management", + breadcrumb, + headerContent: `Endpoint: ${endpoint}`, + fetchItems: async () => { + const result = await api.getApiKeys(); + if (!result.success) { + clearScreen(); + showStatus(`Failed to fetch API keys: ${result.error}`, "error"); + await pause(); + return null; + } + return { items: result.data.keys || [] }; + }, + formatItem: (key) => `${key.name} (${maskKey(key.key)})`, + onSelect: async (key) => { + await showKeyActions(key, port, breadcrumb); + }, + createAction: { + label: "Create New API Key", + action: async () => { + await handleCreateKey(); + } + } + }); +} + +module.exports = { + showApiKeysMenu +}; diff --git a/cli/src/cli/menus/cliTools.js b/cli/src/cli/menus/cliTools.js new file mode 100644 index 0000000..3a84a07 --- /dev/null +++ b/cli/src/cli/menus/cliTools.js @@ -0,0 +1,618 @@ +const api = require("../api/client"); +const { pause, confirm } = require("../utils/input"); +const { showStatus } = require("../utils/display"); +const { selectModelFromList } = require("../utils/modelSelector"); +const { showMenuWithBack } = require("../utils/menuHelper"); +const { getEndpoint } = require("../utils/endpoint"); + +const COLORS = { + reset: "\x1b[0m", + green: "\x1b[32m", + red: "\x1b[31m", + dim: "\x1b[2m", + cyan: "\x1b[36m" +}; + +// Claude model types with defaults (matching Web UI) +const CLAUDE_MODEL_TYPES = [ + { id: "sonnet", name: "Sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-5-20250929" }, + { id: "opus", name: "Opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-5-20251101" }, + { id: "haiku", name: "Haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" }, +]; + +// ─── Shared helpers ─────────────────────────────────────────────────────────── + +/** + * Get first available API key from server + * @returns {Promise} + */ +async function getFirstApiKey() { + const result = await api.getApiKeys(); + const keys = result.success ? (result.data.keys || []) : []; + return keys.length > 0 ? keys[0].key : null; +} + +// ─── Claude Code ────────────────────────────────────────────────────────────── + +/** + * Build header showing current Claude config status + * @returns {Promise} + */ +async function buildClaudeHeader() { + const result = await api.getCliToolSettings("claude"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const settings = result.data.settings; + const currentUrl = settings?.env?.ANTHROPIC_BASE_URL; + const currentKey = settings?.env?.ANTHROPIC_AUTH_TOKEN; + const lines = []; + + if (currentUrl) { + lines.push(`Status: ${COLORS.green}✓ Configured${COLORS.reset}`); + lines.push(`Endpoint: ${COLORS.cyan}${currentUrl}${COLORS.reset}`); + if (currentKey) { + lines.push(`API Key: ${COLORS.dim}${currentKey.substring(0, 10)}...${COLORS.reset}`); + } + } else { + lines.push(`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`); + lines.push(`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`); + } + + return lines.join("\n"); +} + +/** + * Get current Claude model from settings + * @param {string} envKey + * @returns {Promise} + */ +async function getClaudeModel(envKey) { + const result = await api.getCliToolSettings("claude"); + return result.success ? (result.data.settings?.env?.[envKey] || "Not set") : "Not set"; +} + +/** + * Quick setup for Claude Code — sets endpoint, key, and all default models + * @param {number} port + */ +async function claudeQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + const env = { ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: apiKey, API_TIMEOUT_MS: "600000" }; + CLAUDE_MODEL_TYPES.forEach(t => { env[t.envKey] = t.defaultValue; }); + + const result = await api.applyCliToolSettings("claude", { env }); + showStatus(result.success ? "Quick Setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Select and save a specific Claude model type + * @param {Object} modelType + * @param {number} port + */ +async function claudeSelectModel(modelType, port) { + const current = await getClaudeModel(modelType.envKey); + const selected = await selectModelFromList(`Select ${modelType.name} Model`, current, { excludeCombos: true }); + if (!selected) return; + + const env = { [modelType.envKey]: selected }; + + // Also set base URL if not configured yet + const settingsResult = await api.getCliToolSettings("claude"); + if (!settingsResult.data?.settings?.env?.ANTHROPIC_BASE_URL) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + env.ANTHROPIC_BASE_URL = endpoint; + env.API_TIMEOUT_MS = "600000"; + if (apiKey) env.ANTHROPIC_AUTH_TOKEN = apiKey; + } + + const result = await api.applyCliToolSettings("claude", { env }); + showStatus(result.success ? `${modelType.name} → ${selected} saved!` : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Reset Claude Code settings + */ +async function claudeReset() { + const result = await api.resetCliToolSettings("claude"); + showStatus(result.success ? "Settings reset successfully!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Claude Code submenu + * @param {number} port + * @param {Array} breadcrumb + */ +async function showClaudeCodeMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "🔧 Claude Code Settings", + breadcrumb, + headerContent: buildClaudeHeader, + refresh: async () => ({ + sonnet: await getClaudeModel("ANTHROPIC_DEFAULT_SONNET_MODEL"), + opus: await getClaudeModel("ANTHROPIC_DEFAULT_OPUS_MODEL"), + haiku: await getClaudeModel("ANTHROPIC_DEFAULT_HAIKU_MODEL"), + }), + items: [ + { + label: "⚡ Quick Setup (recommended)", + action: async () => { await claudeQuickSetup(port); return true; } + }, + { + label: (d) => `Sonnet → ${d.sonnet}`, + action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[0], port); return true; } + }, + { + label: (d) => `Opus → ${d.opus}`, + action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[1], port); return true; } + }, + { + label: (d) => `Haiku → ${d.haiku}`, + action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[2], port); return true; } + }, + { + label: "Reset to Default", + action: async () => { await claudeReset(); return true; } + } + ] + }); +} + +// ─── Codex CLI ──────────────────────────────────────────────────────────────── + +/** + * Build header showing current Codex config status + * @returns {Promise} + */ +async function buildCodexHeader() { + const result = await api.getCliToolSettings("codex"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const { installed, has9Router, config } = result.data; + if (!installed) return `Status: ${COLORS.red}✗ Codex CLI not installed${COLORS.reset}`; + + if (!has9Router) { + return [ + `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`, + `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}` + ].join("\n"); + } + + // Parse base_url and model from raw TOML string + const baseUrlMatch = config && config.match(/base_url\s*=\s*"([^"]+)"/); + const modelMatch = config && config.match(/^model\s*=\s*"([^"]+)"/m); + const baseUrl = baseUrlMatch ? baseUrlMatch[1] : ""; + const model = modelMatch ? modelMatch[1] : ""; + + const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`]; + if (baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${baseUrl}${COLORS.reset}`); + if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`); + return lines.join("\n"); +} + +/** + * Quick setup for Codex CLI + * @param {number} port + */ +async function codexQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + // Get model selection + const model = await selectModelFromList("Select Codex Model", "cx/claude-sonnet-4-5-20250929", { excludeCombos: true }); + if (!model) return; + + const result = await api.applyCliToolSettings("codex", { baseUrl: endpoint, apiKey, model }); + showStatus(result.success ? "Codex setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Reset Codex CLI settings + */ +async function codexReset() { + const result = await api.resetCliToolSettings("codex"); + showStatus(result.success ? "Codex settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Codex CLI submenu + * @param {number} port + * @param {Array} breadcrumb + */ +async function showCodexMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "🤖 Codex CLI Settings", + breadcrumb, + headerContent: buildCodexHeader, + refresh: async () => ({}), + items: [ + { + label: "⚡ Quick Setup", + action: async () => { await codexQuickSetup(port); return true; } + }, + { + label: "Reset to Default", + action: async () => { await codexReset(); return true; } + } + ] + }); +} + +// ─── Factory Droid ──────────────────────────────────────────────────────────── + +/** + * Build header showing current Droid config status + * @returns {Promise} + */ +async function buildDroidHeader() { + const result = await api.getCliToolSettings("droid"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const { installed, has9Router, settings } = result.data; + if (!installed) return `Status: ${COLORS.red}✗ Factory Droid not installed${COLORS.reset}`; + + if (!has9Router) { + return [ + `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`, + `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}` + ].join("\n"); + } + + // Extract 9Router custom model config + const custom = settings?.customModels?.find(m => m.id === "custom:9Router-0"); + const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`]; + if (custom?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${custom.baseUrl}${COLORS.reset}`); + if (custom?.model) lines.push(`Model: ${COLORS.dim}${custom.model}${COLORS.reset}`); + return lines.join("\n"); +} + +/** + * Quick setup for Factory Droid + * @param {number} port + */ +async function droidQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + const model = await selectModelFromList("Select Droid Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true }); + if (!model) return; + + const result = await api.applyCliToolSettings("droid", { baseUrl: endpoint, apiKey, model }); + showStatus(result.success ? "Factory Droid setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Reset Factory Droid settings + */ +async function droidReset() { + const result = await api.resetCliToolSettings("droid"); + showStatus(result.success ? "Factory Droid settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Factory Droid submenu + * @param {number} port + * @param {Array} breadcrumb + */ +async function showDroidMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "🤖 Factory Droid Settings", + breadcrumb, + headerContent: buildDroidHeader, + refresh: async () => ({}), + items: [ + { + label: "⚡ Quick Setup", + action: async () => { await droidQuickSetup(port); return true; } + }, + { + label: "Reset to Default", + action: async () => { await droidReset(); return true; } + } + ] + }); +} + +// ─── Open Claw ──────────────────────────────────────────────────────────────── + +/** + * Build header showing current OpenClaw config status + * @returns {Promise} + */ +async function buildOpenClawHeader() { + const result = await api.getCliToolSettings("openclaw"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const { installed, has9Router, settings } = result.data; + if (!installed) return `Status: ${COLORS.red}✗ Open Claw not installed${COLORS.reset}`; + + if (!has9Router) { + return [ + `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`, + `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}` + ].join("\n"); + } + + // Extract 9Router provider config + const provider = settings?.models?.providers?.["9router"]; + const primary = settings?.agents?.defaults?.model?.primary || ""; + const model = primary.startsWith("9router/") ? primary.replace("9router/", "") : (provider?.models?.[0]?.id || ""); + const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`]; + if (provider?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${provider.baseUrl}${COLORS.reset}`); + if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`); + return lines.join("\n"); +} + +/** + * Quick setup for Open Claw + * @param {number} port + */ +async function openClawQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + const model = await selectModelFromList("Select OpenClaw Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true }); + if (!model) return; + + const result = await api.applyCliToolSettings("openclaw", { baseUrl: endpoint, apiKey, model }); + showStatus(result.success ? "Open Claw setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Reset Open Claw settings + */ +async function openClawReset() { + const result = await api.resetCliToolSettings("openclaw"); + showStatus(result.success ? "Open Claw settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Open Claw submenu + * @param {number} port + * @param {Array} breadcrumb + */ +async function showOpenClawMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "🦞 Open Claw Settings", + breadcrumb, + headerContent: buildOpenClawHeader, + refresh: async () => ({}), + items: [ + { + label: "⚡ Quick Setup", + action: async () => { await openClawQuickSetup(port); return true; } + }, + { + label: "Reset to Default", + action: async () => { await openClawReset(); return true; } + } + ] + }); +} + +// ─── OpenCode CLI ───────────────────────────────────────────────────────────── + +async function buildOpenCodeHeader() { + const result = await api.getCliToolSettings("opencode"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const { installed, has9Router, opencode } = result.data; + if (!installed) return `Status: ${COLORS.red}✗ OpenCode CLI not installed${COLORS.reset}`; + + if (!has9Router) { + return [ + `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`, + `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}` + ].join("\n"); + } + + const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`]; + if (opencode?.baseURL) lines.push(`Endpoint: ${COLORS.cyan}${opencode.baseURL}${COLORS.reset}`); + if (opencode?.activeModel) lines.push(`Active: ${COLORS.dim}${opencode.activeModel}${COLORS.reset}`); + if (Array.isArray(opencode?.models) && opencode.models.length > 0) { + lines.push(`Models: ${COLORS.dim}${opencode.models.join(", ")}${COLORS.reset}`); + } + return lines.join("\n"); +} + +async function openCodeQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + // Pick first model (also becomes active model by default) + const firstModel = await selectModelFromList("Select Active Model (OpenCode)", "", { excludeCombos: true }); + if (!firstModel) return; + + const models = [firstModel]; + + // Optionally add more models + while (true) { + const more = await confirm(`Add another model? (current: ${models.length})`); + if (!more) break; + const next = await selectModelFromList(`Add Model #${models.length + 1}`, models.join(", "), { excludeCombos: true }); + if (!next) break; + if (!models.includes(next)) models.push(next); + } + + // Optional subagent model + let subagentModel = firstModel; + const wantSubagent = await confirm(`Set a different subagent model? (default: ${firstModel})`); + if (wantSubagent) { + const picked = await selectModelFromList("Select Subagent Model", firstModel, { excludeCombos: true }); + if (picked) subagentModel = picked; + } + + const result = await api.applyCliToolSettings("opencode", { + baseUrl: endpoint, + apiKey, + models, + activeModel: firstModel, + subagentModel, + }); + showStatus(result.success ? "OpenCode setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +async function openCodeReset() { + const result = await api.resetCliToolSettings("opencode"); + showStatus(result.success ? "OpenCode settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +async function showOpenCodeMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "💻 OpenCode CLI Settings", + breadcrumb, + headerContent: buildOpenCodeHeader, + refresh: async () => ({}), + items: [ + { label: "⚡ Quick Setup", action: async () => { await openCodeQuickSetup(port); return true; } }, + { label: "Reset to Default", action: async () => { await openCodeReset(); return true; } } + ] + }); +} + +// ─── Hermes Agent ───────────────────────────────────────────────────────────── + +async function buildHermesHeader() { + const result = await api.getCliToolSettings("hermes"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const { installed, has9Router, settings } = result.data; + if (!installed) return `Status: ${COLORS.red}✗ Hermes Agent not installed${COLORS.reset}`; + + if (!has9Router) { + return [ + `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`, + `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}` + ].join("\n"); + } + + const model = settings?.model || {}; + const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`]; + if (model.base_url) lines.push(`Endpoint: ${COLORS.cyan}${model.base_url}${COLORS.reset}`); + if (model.default) lines.push(`Model: ${COLORS.dim}${model.default}${COLORS.reset}`); + return lines.join("\n"); +} + +async function hermesQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + const model = await selectModelFromList("Select Hermes Model", "", { excludeCombos: true }); + if (!model) return; + + const result = await api.applyCliToolSettings("hermes", { baseUrl: endpoint, apiKey, model }); + showStatus(result.success ? "Hermes setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +async function hermesReset() { + const result = await api.resetCliToolSettings("hermes"); + showStatus(result.success ? "Hermes settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +async function showHermesMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "⚡ Hermes Agent Settings", + breadcrumb, + headerContent: buildHermesHeader, + refresh: async () => ({}), + items: [ + { label: "⚡ Quick Setup", action: async () => { await hermesQuickSetup(port); return true; } }, + { label: "Reset to Default", action: async () => { await hermesReset(); return true; } } + ] + }); +} + +// ─── Main CLI Tools Menu ────────────────────────────────────────────────────── + +/** + * Main CLI Tools menu + * @param {number} port + * @param {Array} breadcrumb + */ +async function showCliToolsMenu(port, breadcrumb = []) { + const { endpoint } = await getEndpoint(port); + await showMenuWithBack({ + title: "🔧 CLI Tools", + breadcrumb, + headerContent: `Configure CLI tools to use 9Router\nEndpoint: ${endpoint}`, + items: [ + { + label: "Claude Code", + action: async () => { await showClaudeCodeMenu(port, [...breadcrumb, "Claude Code"]); return true; } + }, + { + label: "Codex CLI", + action: async () => { await showCodexMenu(port, [...breadcrumb, "Codex CLI"]); return true; } + }, + { + label: "Factory Droid", + action: async () => { await showDroidMenu(port, [...breadcrumb, "Factory Droid"]); return true; } + }, + { + label: "Open Claw", + action: async () => { await showOpenClawMenu(port, [...breadcrumb, "Open Claw"]); return true; } + }, + { + label: "OpenCode", + action: async () => { await showOpenCodeMenu(port, [...breadcrumb, "OpenCode"]); return true; } + }, + { + label: "Hermes", + action: async () => { await showHermesMenu(port, [...breadcrumb, "Hermes"]); return true; } + } + ] + }); +} + +module.exports = { showCliToolsMenu }; diff --git a/cli/src/cli/menus/combos.js b/cli/src/cli/menus/combos.js new file mode 100644 index 0000000..5a8b202 --- /dev/null +++ b/cli/src/cli/menus/combos.js @@ -0,0 +1,477 @@ +const api = require("../api/client"); +const { prompt, confirm, pause } = require("../utils/input"); +const { clearScreen, showStatus, showHeader } = require("../utils/display"); +const { formatDate } = require("../utils/format"); +const { selectModelFromList } = require("../utils/modelSelector"); +const { showMenuWithBack } = require("../utils/menuHelper"); + +/** + * Format model to string (handle both string and object) + */ +function formatModel(model) { + if (typeof model === "string") return model; + if (model && typeof model === "object") { + return model.id || model.name || `${model.provider}/${model.model}` || JSON.stringify(model); + } + return String(model); +} + +/** + * Show actions for a specific combo + * @param {Object} combo - Combo object + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showComboActions(combo, breadcrumb = []) { + const modelsChain = Array.isArray(combo.models) + ? combo.models.map(formatModel).join(" → ") + : ""; + + await showMenuWithBack({ + title: `🔀 ${combo.name}`, + breadcrumb: [...breadcrumb, combo.name], + headerContent: `Name: ${combo.name}\nModels: ${modelsChain}`, + items: [ + { + label: "Edit Combo", + action: async () => { + await handleEditSingleCombo(combo); + return true; + } + }, + { + label: "Delete Combo", + action: async () => { + await handleDeleteSingleCombo(combo); + return false; // Exit after delete + } + } + ] + }); +} + +/** + * Handle editing a single combo + * @param {Object} combo - Combo to edit + */ +async function handleEditSingleCombo(combo) { + clearScreen(); + console.log(`\n✏️ Edit Combo: ${combo.name}\n`); + + const newName = await prompt(`New name (Enter to keep "${combo.name}"): `); + const name = newName || combo.name; + + console.log("\nCurrent models: " + (Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : "")); + console.log("\nSelect models for this combo (add one by one):"); + + const models = []; + let addMore = true; + + while (addMore) { + const currentChain = models.length > 0 ? models.join(" → ") : "None"; + const model = await selectModelFromList(`Add Model #${models.length + 1}`, `Chain: ${currentChain}`); + + if (model) { + models.push(model); + console.log(`\n✓ Added: ${model}`); + console.log(`Current chain: ${models.join(" → ")}\n`); + + const continueAdding = await confirm("Add another model?"); + addMore = continueAdding; + } else { + addMore = false; + } + } + + // Use new models if any were added, otherwise keep current + const finalModels = models.length > 0 ? models : combo.models; + + const result = await api.updateCombo(combo.id, { name, models: finalModels }); + + if (result.success) { + showStatus("Combo updated!", "success"); + } else { + showStatus(`Update failed: ${result.error}`, "error"); + } + await pause(); +} + +/** + * Handle deleting a single combo + * @param {Object} combo - Combo to delete + */ +async function handleDeleteSingleCombo(combo) { + const confirmed = await confirm(`Delete combo "${combo.name}"?`); + if (confirmed) { + const result = await api.deleteCombo(combo.id); + if (result.success) { + showStatus("Combo deleted!", "success"); + } else { + showStatus(`Delete failed: ${result.error}`, "error"); + } + await pause(); + } +} + +/** + * Main combos menu - list all combos and actions + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showCombosMenu(breadcrumb = []) { + const { showListMenu } = require("../utils/menuHelper"); + + await showListMenu({ + title: "🔀 Combos Management", + breadcrumb, + fetchItems: async () => { + const result = await api.getCombos(); + if (!result.success) { + clearScreen(); + showStatus(`Failed to load combos: ${result.error}`, "error"); + await pause(); + return null; + } + return { items: result.data.combos || [] }; + }, + formatItem: (combo) => { + const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : ""; + const maxLen = 35; + const displayModels = modelsChain.length > maxLen + ? modelsChain.substring(0, maxLen - 3) + "..." + : modelsChain; + return `${combo.name}: ${displayModels}`; + }, + onSelect: async (combo) => { + await showComboActions(combo, breadcrumb); + }, + createAction: { + label: "Create New Combo", + action: async () => { + await handleCreateCombo(); + } + } + }); +} + +/** + * Show combo detail with stats + */ +async function showComboDetail(comboId) { + clearScreen(); + + const result = await api.getComboById(comboId); + + if (!result.success) { + showStatus(`Failed to load combo: ${result.error}`, "error"); + await pause(); + return; + } + + const combo = result.data; + + console.log("┌─────────────────────────────────────────────────────────┐"); + console.log(`│ 🔀 Combo: ${combo.name.padEnd(46)} │`); + console.log("├─────────────────────────────────────────────────────────┤"); + console.log("│ │"); + console.log(`│ ID: ${combo.id.padEnd(51)} │`); + console.log(`│ Created: ${formatDate(combo.createdAt).padEnd(46)} │`); + console.log(`│ Updated: ${formatDate(combo.updatedAt).padEnd(46)} │`); + console.log("│ │"); + console.log("│ Model Chain: │"); + + // Models is array of strings like ["ag/claude-sonnet-4-5", "kr/claude-sonnet-4.5"] + const models = Array.isArray(combo.models) ? combo.models : []; + models.forEach((modelStr, index) => { + const arrow = index < models.length - 1 ? " →" : " "; + const displayText = `${index + 1}. ${modelStr}${arrow}`; + const padding = Math.max(0, 54 - displayText.length); + console.log(`│ ${displayText}${" ".repeat(padding)} │`); + }); + + console.log("│ │"); + console.log("└─────────────────────────────────────────────────────────┘"); + + await pause(); +} + +/** + * Format combo for menu display + */ +function formatComboLabel(combo) { + const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : ""; + const maxLen = 40; + const displayModels = modelsChain.length > maxLen + ? modelsChain.substring(0, maxLen - 3) + "..." + : modelsChain; + return `${combo.name}: ${displayModels}`; +} + +/** + * Create new combo + */ +async function handleCreateCombo() { + clearScreen(); + + showStatus("Create New Combo", "info"); + console.log(); + + // Get combo name + const name = await prompt("Combo name: "); + if (!name) { + showStatus("Combo name is required", "error"); + await pause(); + return; + } + + // Fetch available models + showStatus("Loading available models...", "info"); + const modelsResult = await api.getModels(); + + if (!modelsResult.success) { + showStatus(`Failed to load models: ${modelsResult.error}`, "error"); + await pause(); + return; + } + + const availableModels = modelsResult.data.models || []; + + if (availableModels.length === 0) { + showStatus("No models available. Please add providers first.", "warning"); + await pause(); + return; + } + + // Select models for chain + const selectedModels = []; + + console.log(); + showStatus("Select models for the chain (minimum 2)", "info"); + + while (true) { + clearScreen(); + console.log(`Creating combo: ${name}`); + console.log(`Selected models (${selectedModels.length}):`); + + if (selectedModels.length > 0) { + selectedModels.forEach((m, i) => { + console.log(` ${i + 1}. ${m.provider}/${m.model}`); + }); + } else { + console.log(" (none)"); + } + + console.log(); + console.log("Available models:"); + availableModels.forEach((m, i) => { + console.log(` ${i + 1}. ${m.provider}/${m.model}`); + }); + + console.log(); + console.log("Actions:"); + console.log(" - Enter number to add model"); + console.log(" - Type 'done' to finish (min 2 models)"); + console.log(" - Type 'cancel' to abort"); + + const input = await prompt("\nAction: "); + + if (input.toLowerCase() === "cancel") { + showStatus("Cancelled", "warning"); + await pause(); + return; + } + + if (input.toLowerCase() === "done") { + if (selectedModels.length < 2) { + showStatus("Please select at least 2 models", "error"); + await pause(); + continue; + } + break; + } + + const num = parseInt(input, 10); + if (isNaN(num) || num < 1 || num > availableModels.length) { + showStatus("Invalid model number", "error"); + await pause(); + continue; + } + + selectedModels.push(availableModels[num - 1]); + } + + // Create combo + showStatus("Creating combo...", "info"); + + const createResult = await api.createCombo({ + name, + models: selectedModels + }); + + if (!createResult.success) { + showStatus(`Failed to create combo: ${createResult.error}`, "error"); + await pause(); + return; + } + + showStatus(`Combo "${name}" created successfully!`, "success"); + await pause(); +} + +/** + * Edit combo - select which combo to edit + */ +async function handleEditCombo(combos) { + if (combos.length === 0) { + showStatus("No combos available", "warning"); + await pause(); + return; + } + + let selectedCombo = null; + + await showMenuWithBack({ + title: "✏️ Select Combo to Edit", + items: combos.map(combo => ({ + label: formatComboLabel(combo), + action: async () => { + selectedCombo = combo; + return false; + } + })) + }); + + if (!selectedCombo) return; + await editSingleCombo(selectedCombo); +} + +/** + * Edit a single combo + */ +async function editSingleCombo(combo) { + clearScreen(); + showStatus(`Editing combo: ${combo.name}`, "info"); + console.log(); + + const newName = await prompt(`New name (current: ${combo.name}, press Enter to keep): `); + const editModels = await confirm("Edit model chain?"); + + let newModels = combo.models; + + if (editModels) { + newModels = []; + + while (true) { + clearScreen(); + console.log(`Editing combo: ${combo.name}`); + console.log(`Selected models (${newModels.length}):`); + + if (newModels.length > 0) { + newModels.forEach((m, i) => console.log(` ${i + 1}. ${m}`)); + } else { + console.log(" (none)"); + } + + console.log("\nType 'done' to finish (min 2 models) or 'cancel' to abort\n"); + + const model = await selectModelFromList("Add Model", ""); + + if (model === null) { + showStatus("Cancelled", "warning"); + await pause(); + return; + } + + if (model === "done") { + if (newModels.length < 2) { + showStatus("Please select at least 2 models", "error"); + await pause(); + continue; + } + break; + } + + newModels.push(model); + showStatus(`Added: ${model}`, "success"); + await pause(); + } + } + + const updateData = {}; + if (newName) updateData.name = newName; + if (editModels) updateData.models = newModels; + + if (Object.keys(updateData).length === 0) { + showStatus("No changes made", "warning"); + await pause(); + return; + } + + showStatus("Updating combo...", "info"); + + const updateResult = await api.updateCombo(combo.id, updateData); + + if (!updateResult.success) { + showStatus(`Failed to update combo: ${updateResult.error}`, "error"); + await pause(); + return; + } + + showStatus("Combo updated successfully!", "success"); + await pause(); +} + +/** + * Delete combo - select which combo to delete + */ +async function handleDeleteCombo(combos) { + if (combos.length === 0) { + showStatus("No combos available", "warning"); + await pause(); + return; + } + + let selectedCombo = null; + + await showMenuWithBack({ + title: "🗑️ Select Combo to Delete", + items: combos.map(combo => ({ + label: formatComboLabel(combo), + action: async () => { + selectedCombo = combo; + return false; + } + })) + }); + + if (!selectedCombo) return; + + clearScreen(); + showStatus(`Combo: ${selectedCombo.name}`, "warning"); + const modelsDisplay = Array.isArray(selectedCombo.models) + ? selectedCombo.models.map(formatModel).join(" → ") + : ""; + console.log(`Models: ${modelsDisplay}`); + console.log(); + + const confirmed = await confirm("Are you sure you want to delete this combo?"); + + if (!confirmed) { + showStatus("Cancelled", "info"); + await pause(); + return; + } + + showStatus("Deleting combo...", "info"); + + const deleteResult = await api.deleteCombo(selectedCombo.id); + + if (!deleteResult.success) { + showStatus(`Failed to delete combo: ${deleteResult.error}`, "error"); + await pause(); + return; + } + + showStatus("Combo deleted successfully!", "success"); + await pause(); +} + +module.exports = { showCombosMenu }; diff --git a/cli/src/cli/menus/providers.js b/cli/src/cli/menus/providers.js new file mode 100644 index 0000000..5701346 --- /dev/null +++ b/cli/src/cli/menus/providers.js @@ -0,0 +1,847 @@ +const api = require("../api/client"); +const { prompt, confirm, pause } = require("../utils/input"); +const { clearScreen, showStatus, showHeader } = require("../utils/display"); +const { formatDate, getRelativeTime } = require("../utils/format"); +const { showMenuWithBack } = require("../utils/menuHelper"); +const { copyToClipboard } = require("../utils/clipboard"); + +// ANSI colors for styling +const COLORS = { + reset: "\x1b[0m", + bold: "\x1b[1m", + cyan: "\x1b[36m", + dim: "\x1b[2m" +}; + +// Provider models - static config (synced from open-sse/config/providerModels.js) +const PROVIDER_MODELS = { + cc: [ + { id: "claude-opus-4-5-20251101" }, + { id: "claude-sonnet-4-5-20250929" }, + { id: "claude-haiku-4-5-20251001" }, + ], + cx: [ + { id: "gpt-5.2-codex" }, + { id: "gpt-5.2" }, + { id: "gpt-5.1-codex-max" }, + { id: "gpt-5.1-codex" }, + { id: "gpt-5.1-codex-mini" }, + { id: "gpt-5.1" }, + { id: "gpt-5-codex" }, + { id: "gpt-5-codex-mini" }, + ], + gc: [ + { id: "gemini-3-flash-preview" }, + { id: "gemini-3-pro-preview" }, + { id: "gemini-2.5-pro" }, + { id: "gemini-2.5-flash" }, + { id: "gemini-2.5-flash-lite" }, + ], + qw: [ + { id: "qwen3-coder-plus" }, + { id: "qwen3-coder-flash" }, + { id: "vision-model" }, + ], + if: [ + { id: "qwen3-coder-plus" }, + { id: "kimi-k2" }, + { id: "kimi-k2-thinking" }, + { id: "deepseek-r1" }, + { id: "deepseek-v3.2-chat" }, + { id: "deepseek-v3.2-reasoner" }, + { id: "minimax-m2" }, + { id: "glm-4.7" }, + ], + ag: [ + { id: "gemini-3-flash-agent" }, + { id: "gemini-3.5-flash-low" }, + { id: "gemini-3.5-flash-extra-low" }, + { id: "gemini-pro-agent" }, + { id: "gemini-3.1-pro-low" }, + { id: "claude-sonnet-4-6" }, + { id: "claude-opus-4-6-thinking" }, + { id: "gpt-oss-120b-medium" }, + { id: "gemini-3-flash" }, + ], + gh: [ + { id: "gpt-5" }, + { id: "gpt-5-mini" }, + { id: "gpt-5.1-codex" }, + { id: "gpt-5.1-codex-max" }, + { id: "gpt-4.1" }, + { id: "claude-4.5-sonnet" }, + { id: "claude-4.5-opus" }, + { id: "claude-4.5-haiku" }, + { id: "gemini-3-pro" }, + { id: "gemini-3-flash" }, + { id: "gemini-2.5-pro" }, + { id: "grok-code-fast-1" }, + ], + kr: [ + { id: "claude-sonnet-5" }, + { id: "claude-sonnet-4.5" }, + { id: "claude-haiku-4.5" }, + ], + openai: [ + { id: "gpt-4o" }, + { id: "gpt-4o-mini" }, + { id: "gpt-4-turbo" }, + { id: "o1" }, + { id: "o1-mini" }, + ], + anthropic: [ + { id: "claude-sonnet-4-20250514" }, + { id: "claude-opus-4-20250514" }, + { id: "claude-3-5-sonnet-20241022" }, + ], + gemini: [ + { id: "gemini-3-pro-preview" }, + { id: "gemini-2.5-pro" }, + { id: "gemini-2.5-flash" }, + { id: "gemini-2.5-flash-lite" }, + ], + openrouter: [ + { id: "auto" }, + ], + glm: [ + { id: "glm-4.7" }, + { id: "glm-4.6v" }, + ], + kimi: [ + { id: "kimi-latest" }, + ], + minimax: [ + { id: "MiniMax-M2.1" }, + ], +}; + +// Provider definitions +const OAUTH_PROVIDERS = { + claude: { id: "claude", alias: "cc", name: "Claude Code" }, + codex: { id: "codex", alias: "cx", name: "OpenAI Codex" }, + "gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI" }, + github: { id: "github", alias: "gh", name: "GitHub Copilot" }, + antigravity: { id: "antigravity", alias: "ag", name: "Antigravity" }, + iflow: { id: "iflow", alias: "if", name: "iFlow AI" }, + qwen: { id: "qwen", alias: "qw", name: "Qwen Code" }, + kiro: { id: "kiro", alias: "kr", name: "Kiro AI" }, +}; + +const APIKEY_PROVIDERS = { + openrouter: { id: "openrouter", name: "OpenRouter" }, + glm: { id: "glm", name: "GLM Coding" }, + minimax: { id: "minimax", name: "Minimax Coding" }, + kimi: { id: "kimi", name: "Kimi Coding" }, + openai: { id: "openai", name: "OpenAI" }, + anthropic: { id: "anthropic", name: "Anthropic" }, + gemini: { id: "gemini", name: "Gemini" }, +}; + +const ALL_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS }; + +/** + * Get auth type for provider + * @param {string} providerId - Provider ID + * @returns {string} "oauth" or "apikey" + */ +function getAuthType(providerId) { + return OAUTH_PROVIDERS[providerId] ? "oauth" : "apikey"; +} + +/** + * Count connections by provider + * @param {Array} connections - Array of connection objects + * @returns {Object} Map of providerId -> count + */ +function countConnectionsByProvider(connections) { + const counts = {}; + connections.forEach(conn => { + const providerId = conn.provider || conn.providerId; + counts[providerId] = (counts[providerId] || 0) + 1; + }); + return counts; +} + +/** + * Show main providers menu + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showProvidersMenu(breadcrumb = []) { + // Build provider items list + const providerItems = []; + + Object.values(OAUTH_PROVIDERS).forEach(provider => { + providerItems.push({ + provider, + authType: "oauth", + label: (data) => { + const count = data.counts[provider.id] || 0; + return `${provider.name} (OAuth) - ${count} Connected`; + }, + action: async (data) => { + await showProviderDetail(provider.id, "oauth", data.connections, [...breadcrumb, provider.name]); + return true; + } + }); + }); + + Object.values(APIKEY_PROVIDERS).forEach(provider => { + providerItems.push({ + provider, + authType: "apikey", + label: (data) => { + const count = data.counts[provider.id] || 0; + return `${provider.name} (API) - ${count} Connected`; + }, + action: async (data) => { + await showProviderDetail(provider.id, "apikey", data.connections, [...breadcrumb, provider.name]); + return true; + } + }); + }); + + // Custom provider nodes section + providerItems.push({ + label: () => `${COLORS.dim}── Custom Providers ──${COLORS.reset}`, + action: async () => true, // separator, no-op + isSeparator: true, + }); + providerItems.push({ + label: (data) => { + const count = data.nodeCount || 0; + return `Custom Providers - ${count} Configured`; + }, + action: async () => { + await showCustomProvidersMenu([...breadcrumb, "Custom Providers"]); + return true; + } + }); + + await showMenuWithBack({ + title: "🔌 Providers Management", + breadcrumb, + refresh: async () => { + const [provRes, nodeRes] = await Promise.all([api.getProviders(), api.getProviderNodes()]); + if (!provRes.success) { + showStatus(`Failed to fetch providers: ${provRes.error}`, "error"); + await pause(); + return null; + } + const connections = provRes.data.connections || []; + const nodes = nodeRes.success ? (nodeRes.data.nodes || nodeRes.data || []) : []; + return { + connections, + counts: countConnectionsByProvider(connections), + nodeCount: nodes.length, + }; + }, + items: providerItems + }); +} + +/** + * Build provider header with alias and models + * @param {string} providerId - Provider ID + * @returns {string} + */ +function buildProviderHeader(providerId) { + const provider = ALL_PROVIDERS[providerId]; + const alias = provider.alias || providerId; + + const lines = []; + lines.push(`Alias: ${COLORS.cyan}${alias}${COLORS.reset}`); + + // Get models from static config + const models = PROVIDER_MODELS[alias] || []; + if (models.length > 0) { + const modelList = models + .slice(0, 5) + .map(m => `${alias}/${m.id}`) + .join(", "); + const more = models.length > 5 ? ` (+${models.length - 5} more)` : ""; + lines.push(`Models: ${COLORS.dim}${modelList}${more}${COLORS.reset}`); + } else { + lines.push(`Models: ${COLORS.dim}No models configured${COLORS.reset}`); + } + + return lines.join("\n"); +} + +/** + * Show provider detail with connections and actions + * @param {string} providerId - Provider ID + * @param {string} authType - "oauth" or "apikey" + * @param {Array} allConnections - All connections + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showProviderDetail(providerId, authType, allConnections, breadcrumb = []) { + const provider = ALL_PROVIDERS[providerId]; + const { showListMenu } = require("../utils/menuHelper"); + + await showListMenu({ + title: `🔌 ${provider.name} (${authType.toUpperCase()})`, + breadcrumb, + backLabel: "← Back to Providers", + headerContent: buildProviderHeader(providerId), + fetchItems: async () => { + const response = await api.getProviders(); + if (response.success) { + allConnections.length = 0; + allConnections.push(...(response.data.connections || [])); + } + const providerConns = allConnections.filter(conn => + (conn.provider || conn.providerId) === providerId + ); + return { items: providerConns }; + }, + formatItem: (conn) => { + const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?"; + const name = conn.name || conn.email || conn.displayName || "Unnamed"; + return `${name} (${status})`; + }, + onSelect: async (conn) => { + await showConnectionActions(conn, providerId, breadcrumb); + }, + createAction: { + label: "Add New Connection", + action: async () => { + await handleAddConnection(providerId, authType); + } + } + }); +} + +/** + * Show actions for a specific connection + * @param {Object} connection - Connection object + * @param {string} providerId - Provider ID + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showConnectionActions(connection, providerId, breadcrumb = []) { + const name = connection.name || connection.email || connection.displayName || "Unnamed"; + const status = connection.testStatus === "active" ? "✓ Active" : + connection.testStatus === "error" ? "✗ Error" : "? Unknown"; + + await showMenuWithBack({ + title: `🔌 ${name}`, + breadcrumb: [...breadcrumb, name], + headerContent: `Connection: ${name}\nStatus: ${status}`, + items: [ + { + label: "Rename Connection", + action: async () => { + const newName = await prompt(`New name (current: ${name}): `); + if (newName && newName.trim()) { + showStatus("Renaming connection...", "info"); + const result = await api.updateConnection(connection.id, { name: newName.trim() }); + if (result.success) { + showStatus("Connection renamed!", "success"); + connection.name = newName.trim(); + } else { + showStatus(`Rename failed: ${result.error}`, "error"); + } + await pause(); + } + return true; + } + }, + { + label: "Test Connection", + action: async () => { + showStatus("Testing connection...", "info"); + const result = await api.testConnection(connection.id); + if (result.success) { + showStatus("Connection is working!", "success"); + } else { + showStatus(`Test failed: ${result.error}`, "error"); + } + await pause(); + return true; + } + }, + { + label: "Delete Connection", + action: async () => { + const confirmed = await confirm(`Delete connection "${name}"?`); + if (confirmed) { + const result = await api.deleteConnection(connection.id); + if (result.success) { + showStatus("Connection deleted!", "success"); + } else { + showStatus(`Delete failed: ${result.error}`, "error"); + } + await pause(); + return false; // Exit menu after delete + } + return true; + } + } + ] + }); +} + +/** + * Handle adding new connection + * @param {string} providerId - Provider ID + * @param {string} authType - "oauth" or "apikey" + */ +// Providers that use Device Code Flow (terminal-based polling) +const DEVICE_CODE_PROVIDERS = ["github", "qwen", "kiro"]; + +/** + * Handle adding new connection - auto-detect flow type + * @param {string} providerId - Provider ID + * @param {string} authType - "oauth" or "apikey" + */ +async function handleAddConnection(providerId, authType) { + if (authType === "apikey") { + await handleAddApiKeyConnection(providerId); + } else { + // OAuth: auto-detect flow type based on provider + if (DEVICE_CODE_PROVIDERS.includes(providerId)) { + // Device Code Flow for GitHub, Qwen, Kiro + await handleAddDeviceCodeConnection(providerId); + } else { + // Authorization Code Flow for Claude, Codex, Gemini, etc. + await handleAddOAuthConnection(providerId); + } + } +} + +/** + * Handle adding API Key connection + * @param {string} providerId - Provider ID + */ +async function handleAddApiKeyConnection(providerId) { + clearScreen(); + const provider = ALL_PROVIDERS[providerId]; + console.log(`\n➕ Add ${provider.name} API Key Connection\n`); + + const name = await prompt("Connection Name: "); + if (!name) { + showStatus("Cancelled", "warning"); + await pause(); + return; + } + + const apiKey = await prompt("API Key: "); + if (!apiKey) { + showStatus("Cancelled", "warning"); + await pause(); + return; + } + + showStatus("Creating connection...", "info"); + + const result = await api.createApiKeyProvider({ + provider: providerId, + name, + apiKey + }); + + if (result.success) { + showStatus("✓ Connection created successfully!", "success"); + } else { + showStatus(`✗ Failed: ${result.error}`, "error"); + } + + await pause(); +} + +/** + * Handle adding OAuth Authorization Code connection + * User opens URL manually and pastes callback URL + * @param {string} providerId - Provider ID + */ +async function handleAddOAuthConnection(providerId) { + clearScreen(); + const provider = ALL_PROVIDERS[providerId]; + + // Step 1: Get auth URL + showStatus("Requesting authorization URL...", "info"); + const authResult = await api.getOAuthAuthUrl(providerId); + + if (!authResult.success) { + showStatus(`Failed: ${authResult.error}`, "error"); + await pause(); + return; + } + + const authData = authResult.data || authResult; + const authUrl = authData.authUrl; + const codeVerifier = authData.codeVerifier; + const state = authData.state; + const redirectUri = authData.redirectUri; + + if (!authUrl) { + showStatus("Failed: No auth URL received", "error"); + await pause(); + return; + } + + // Step 2: Show URL and instructions + clearScreen(); + showHeader("🔐 OAuth Login", `Providers > ${provider.name} > Add Connection`); + + console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open this URL in your browser:`); + console.log(` ${COLORS.dim}${authUrl}${COLORS.reset}`); + if (copyToClipboard(authUrl)) { + console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`); + } + console.log(); + console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Complete authorization in browser`); + console.log(); + console.log(` ${COLORS.bold}${COLORS.cyan}3.${COLORS.reset} Copy the callback URL from address bar`); + console.log(` ${COLORS.dim}(looks like: http://localhost:20128/callback?code=...)${COLORS.reset}`); + console.log(); + + const callbackUrl = await prompt(" Paste callback URL: "); + if (!callbackUrl) { + showStatus("Cancelled", "warning"); + await pause(); + return; + } + + // Step 3: Parse callback URL and extract code + let code, urlState, error; + try { + const url = new URL(callbackUrl.trim()); + code = url.searchParams.get("code"); + urlState = url.searchParams.get("state"); + error = url.searchParams.get("error"); + + if (error) { + const errorDesc = url.searchParams.get("error_description") || error; + showStatus(`Authorization failed: ${errorDesc}`, "error"); + await pause(); + return; + } + + if (!code) { + showStatus("No authorization code found in URL", "error"); + await pause(); + return; + } + } catch (err) { + showStatus("Invalid URL format", "error"); + await pause(); + return; + } + + // Step 4: Exchange code for tokens + console.log(); + showStatus("Exchanging code for tokens...", "info"); + const exchangeResult = await api.exchangeOAuthCode(providerId, { + code, + redirectUri, + codeVerifier, + state: urlState || state + }); + + if (exchangeResult.success) { + showStatus("Connection created successfully!", "success"); + } else { + showStatus(`Failed: ${exchangeResult.error}`, "error"); + } + + await pause(); +} + +/** + * Handle adding OAuth Device Code connection + * @param {string} providerId - Provider ID + */ +async function handleAddDeviceCodeConnection(providerId) { + clearScreen(); + const provider = ALL_PROVIDERS[providerId]; + + // Step 1: Request device code + showStatus("Requesting device code...", "info"); + const deviceResult = await api.getOAuthDeviceCode(providerId); + + if (!deviceResult.success) { + showStatus(`Failed: ${deviceResult.error}`, "error"); + await pause(); + return; + } + + const deviceData = deviceResult.data || deviceResult; + const device_code = deviceData.device_code; + const user_code = deviceData.user_code; + const verification_uri = deviceData.verification_uri; + const verification_uri_complete = deviceData.verification_uri_complete; + const codeVerifier = deviceData.codeVerifier; + const extraData = deviceData.extraData || deviceData; + + if (!device_code) { + showStatus("Failed: No device code received", "error"); + await pause(); + return; + } + + // Step 2: Show instructions + clearScreen(); + const deviceUrl = verification_uri_complete || verification_uri; + showHeader("📱 Device Login", `Providers > ${provider.name} > Add Connection`); + + console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open: ${COLORS.dim}${deviceUrl}${COLORS.reset}`); + if (copyToClipboard(deviceUrl)) { + console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`); + } + console.log(); + if (!verification_uri_complete && user_code) { + console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Enter code: ${COLORS.bold}${user_code}${COLORS.reset}`); + console.log(); + } + console.log(` ${COLORS.dim}Waiting for authorization...${COLORS.reset}`); + console.log(); + + // Step 3: Poll for token + const maxAttempts = 60; // 5 minutes (5s interval) + for (let i = 0; i < maxAttempts; i++) { + await new Promise(resolve => setTimeout(resolve, 5000)); + + const pollResult = await api.pollOAuthToken(providerId, { + deviceCode: device_code, + codeVerifier, + extraData + }); + + if (pollResult.success) { + showStatus("\nConnection created successfully!", "success"); + await pause(); + return; + } + + // Check if still pending (pending flag is at root level, not in data) + const isPending = pollResult.pending || pollResult.error === "authorization_pending" || pollResult.error === "slow_down"; + if (!isPending) { + showStatus(`\nFailed: ${pollResult.error || "Unknown error"}`, "error"); + await pause(); + return; + } + + process.stdout.write("."); + } + + showStatus("\nTimeout waiting for authorization", "error"); + await pause(); +} + +// ============================================================================ +// CUSTOM PROVIDERS (provider nodes) +// ============================================================================ + +const CUSTOM_NODE_TYPES = ["openai-compatible", "anthropic-compatible"]; +const OPENAI_API_TYPES = ["chat", "responses"]; + +/** + * Show custom providers section in main providers menu + * @param {Array} nodes - List of provider nodes + * @param {Array} connections - All connections + * @param {Array} breadcrumb + */ +async function showCustomProvidersMenu(breadcrumb = []) { + const { showListMenu } = require("../utils/menuHelper"); + + await showListMenu({ + title: "🔧 Custom Providers", + breadcrumb, + backLabel: "← Back to Providers", + fetchItems: async () => { + const res = await api.getProviderNodes(); + if (!res.success) return { items: [] }; + return { items: res.data.nodes || res.data || [] }; + }, + formatItem: (node) => `[${node.prefix}] ${node.name} (${node.type})`, + onSelect: async (node) => { + await showCustomNodeDetail(node, [...breadcrumb, node.name]); + }, + createAction: { + label: "➕ Add Custom Provider", + action: async () => { + await handleAddCustomNode(); + } + } + }); +} + +/** + * Show detail menu for a custom provider node + */ +async function showCustomNodeDetail(node, breadcrumb = []) { + await showMenuWithBack({ + title: `🔧 ${node.name}`, + breadcrumb, + headerContent: [ + `Type: ${node.type}`, + `Prefix: ${COLORS.cyan}${node.prefix}${COLORS.reset}`, + `Base URL: ${COLORS.dim}${node.baseUrl}${COLORS.reset}`, + ].join("\n"), + items: [ + { + label: "Connections", + action: async () => { + await showCustomNodeConnections(node, breadcrumb); + return true; + } + }, + { + label: "Edit Node", + action: async () => { + await handleEditCustomNode(node); + return true; + } + }, + { + label: "Delete Node", + action: async () => { + const confirmed = await confirm(`Delete "${node.name}" and all its connections?`); + if (confirmed) { + const res = await api.deleteProviderNode(node.id); + if (res.success) { + showStatus("Node deleted!", "success"); + } else { + showStatus(`Delete failed: ${res.error}`, "error"); + } + await pause(); + return false; + } + return true; + } + } + ] + }); +} + +/** + * Show connections for a custom provider node + */ +async function showCustomNodeConnections(node, breadcrumb = []) { + const { showListMenu } = require("../utils/menuHelper"); + + await showListMenu({ + title: `🔌 ${node.name} – Connections`, + breadcrumb, + backLabel: "← Back", + fetchItems: async () => { + const res = await api.getProviders(); + if (!res.success) return { items: [] }; + const all = res.data.connections || []; + const items = all.filter(c => c.provider === node.id); + return { items }; + }, + formatItem: (conn) => { + const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?"; + return `${conn.name || "Unnamed"} (${status})`; + }, + onSelect: async (conn) => { + await showConnectionActions(conn, node.id, breadcrumb); + }, + createAction: { + label: "Add API Key Connection", + action: async () => { + await handleAddCustomNodeConnection(node); + } + } + }); +} + +/** + * Add API key connection to a custom provider node + */ +async function handleAddCustomNodeConnection(node) { + clearScreen(); + console.log(`\n➕ Add Connection to ${node.name}\n`); + + const name = await prompt("Connection Name: "); + if (!name) { showStatus("Cancelled", "warning"); await pause(); return; } + + const apiKey = await prompt("API Key: "); + if (!apiKey) { showStatus("Cancelled", "warning"); await pause(); return; } + + showStatus("Creating connection...", "info"); + const res = await api.createApiKeyProvider({ provider: node.id, name, apiKey }); + + showStatus(res.success ? "✓ Connection created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error"); + await pause(); +} + +/** + * Handle adding a new custom provider node + */ +async function handleAddCustomNode() { + clearScreen(); + console.log("\n➕ Add Custom Provider\n"); + + // Step 1: Select type + const typeChoices = CUSTOM_NODE_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n"); + console.log(`Select type:\n${typeChoices}\n`); + const typeInput = await prompt("Type (1/2): "); + const typeIdx = parseInt(typeInput) - 1; + if (isNaN(typeIdx) || !CUSTOM_NODE_TYPES[typeIdx]) { + showStatus("Cancelled", "warning"); await pause(); return; + } + const type = CUSTOM_NODE_TYPES[typeIdx]; + + // Step 2: Inputs + const name = await prompt("Name: "); + if (!name) { showStatus("Cancelled", "warning"); await pause(); return; } + + const prefix = await prompt("Prefix (used in model IDs, e.g. myapi): "); + if (!prefix) { showStatus("Cancelled", "warning"); await pause(); return; } + + const baseUrl = await prompt("Base URL (e.g. https://api.example.com/v1): "); + if (!baseUrl) { showStatus("Cancelled", "warning"); await pause(); return; } + + // Step 3: API type (OpenAI only) + let apiType; + if (type === "openai-compatible") { + const apiTypeChoices = OPENAI_API_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n"); + console.log(`\nAPI Type:\n${apiTypeChoices}\n`); + const apiTypeInput = await prompt("API Type (1/2, default 1): "); + const apiTypeIdx = parseInt(apiTypeInput) - 1; + apiType = OPENAI_API_TYPES[apiTypeIdx] || "chat"; + } + + showStatus("Creating provider node...", "info"); + const body = { name, prefix, baseUrl, type, ...(apiType && { apiType }) }; + const res = await api.createProviderNode(body); + + showStatus(res.success ? "✓ Provider created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error"); + await pause(); +} + +/** + * Handle editing a custom provider node + */ +async function handleEditCustomNode(node) { + clearScreen(); + console.log(`\n✏️ Edit ${node.name}\n`); + console.log(`${COLORS.dim}Leave blank to keep current value${COLORS.reset}\n`); + + const name = await prompt(`Name (${node.name}): `); + const baseUrl = await prompt(`Base URL (${node.baseUrl}): `); + const prefix = await prompt(`Prefix (${node.prefix}): `); + + const updates = {}; + if (name && name.trim()) updates.name = name.trim(); + if (baseUrl && baseUrl.trim()) updates.baseUrl = baseUrl.trim(); + if (prefix && prefix.trim()) updates.prefix = prefix.trim(); + + if (!Object.keys(updates).length) { + showStatus("No changes", "warning"); await pause(); return; + } + + showStatus("Updating...", "info"); + const res = await api.updateProviderNode(node.id, updates); + if (res.success) { + Object.assign(node, updates); + showStatus("✓ Updated!", "success"); + } else { + showStatus(`✗ Failed: ${res.error}`, "error"); + } + await pause(); +} + +module.exports = { showProvidersMenu }; diff --git a/cli/src/cli/menus/settings.js b/cli/src/cli/menus/settings.js new file mode 100644 index 0000000..ce77933 --- /dev/null +++ b/cli/src/cli/menus/settings.js @@ -0,0 +1,204 @@ +const api = require("../api/client"); +const { confirm, pause } = require("../utils/input"); +const { showStatus } = require("../utils/display"); +const { showMenuWithBack } = require("../utils/menuHelper"); + +// ANSI colors +const COLORS = { + reset: "\x1b[0m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + dim: "\x1b[2m", + cyan: "\x1b[36m" +}; + +const DEFAULT_PASSWORD = "123456"; + +/** + * Show settings menu (tunnel + RTK + reset password) + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showSettingsMenu(breadcrumb = []) { + await showMenuWithBack({ + title: "⚙️ Settings", + breadcrumb, + headerContent: async (data) => { + const lines = []; + + // Tunnel section + const tunnel = data?.tunnel || {}; + if (tunnel.enabled && tunnel.publicUrl) { + lines.push(` Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`); + lines.push(` Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`); + } else { + lines.push(` Endpoint: http://localhost:20128/v1`); + lines.push(` Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`); + } + + // RTK section + const rtkOn = data?.settings?.rtkEnabled !== false; + lines.push(` RTK: ${rtkOn ? `${COLORS.green}ON${COLORS.reset}` : `${COLORS.red}OFF${COLORS.reset}`} ${COLORS.dim}(Token Saver)${COLORS.reset}`); + const headroomOn = data?.settings?.headroomEnabled === true; + lines.push(` Headroom: ${headroomOn ? `${COLORS.green}ON${COLORS.reset}` : `${COLORS.red}OFF${COLORS.reset}`} ${COLORS.dim}(${data?.settings?.headroomUrl || "http://localhost:8787"})${COLORS.reset}`); + + // Auth mode section + const authMode = data?.settings?.authMode || "password"; + const authColor = authMode === "password" ? COLORS.green : COLORS.yellow; + lines.push(` Auth: ${authColor}${authMode.toUpperCase()}${COLORS.reset} ${COLORS.dim}(login mode)${COLORS.reset}`); + + return lines.join("\n"); + }, + refresh: async () => { + const [tunnelRes, settingsRes] = await Promise.all([ + api.getTunnelStatus(), + api.getSettings() + ]); + return { + tunnel: tunnelRes.success ? (tunnelRes.data || {}) : {}, + settings: settingsRes.success ? (settingsRes.data || {}) : {} + }; + }, + items: [ + { + label: "Tunnel ON", + action: async () => { await enableTunnel(); return true; } + }, + { + label: "Tunnel OFF", + action: async () => { await disableTunnel(); return true; } + }, + { + label: (d) => { + const on = d?.settings?.rtkEnabled !== false; + return `Token Saver (RTK): ${on ? "ON" : "OFF"} → toggle`; + }, + action: async (d) => { await toggleRtk(d?.settings?.rtkEnabled !== false); return true; } + }, + { + label: (d) => { + const on = d?.settings?.headroomEnabled === true; + return `Token Saver (Headroom): ${on ? "ON" : "OFF"} → toggle`; + }, + action: async (d) => { await toggleHeadroom(d?.settings?.headroomEnabled === true); return true; } + }, + { + label: "🔑 Reset Password to Default", + action: async () => { await resetPassword(); return true; } + }, + { + label: (d) => { + const mode = d?.settings?.authMode || "password"; + return mode === "password" ? "🔓 Reset Auth Mode (already password)" : `🔓 Reset Auth Mode to Password (current: ${mode})`; + }, + action: async () => { await resetAuthMode(); return true; } + } + ] + }); +} + +/** + * Reset authMode to "password" via API. Used when OIDC is misconfigured + * and user is locked out of dashboard. CLI bypasses auth via x-9r-cli-token. + */ +async function resetAuthMode() { + const ok = await confirm("Reset auth mode to PASSWORD (disable OIDC)?"); + if (!ok) { + showStatus("Cancelled", "info"); + await pause(); + return; + } + + const result = await api.updateSettings({ authMode: "password" }); + if (result.success) { + showStatus("Auth mode reset to password. OIDC disabled.", "success"); + } else { + showStatus(`Failed: ${result.error}`, "error"); + } + await pause(); +} + +/** + * Enable tunnel via API + */ +async function enableTunnel() { + showStatus("Creating tunnel...", "info"); + const result = await api.enableTunnel(); + + if (result.success) { + const { publicUrl, shortId, alreadyRunning } = result.data || {}; + if (alreadyRunning) { + showStatus(`Tunnel already running: ${publicUrl}`, "success"); + } else { + showStatus(`Tunnel enabled: ${publicUrl} (${shortId})`, "success"); + } + } else { + showStatus(`Failed: ${result.error}`, "error"); + } + + await pause(); +} + +/** + * Disable tunnel via API + */ +async function disableTunnel() { + const result = await api.disableTunnel(); + + if (result.success) { + showStatus("Tunnel disabled", "success"); + } else { + showStatus(`Failed: ${result.error}`, "error"); + } + + await pause(); +} + +/** + * Toggle RTK (Token Saver) via API + * @param {boolean} currentlyOn + */ +async function toggleRtk(currentlyOn) { + const next = !currentlyOn; + const result = await api.updateSettings({ rtkEnabled: next }); + if (result.success) { + showStatus(`Token Saver ${next ? "enabled" : "disabled"}`, "success"); + } else { + showStatus(`Failed: ${result.error}`, "error"); + } + await pause(); +} + +async function toggleHeadroom(currentlyOn) { + const next = !currentlyOn; + const result = await api.updateSettings({ headroomEnabled: next }); + if (result.success) { + showStatus(`Headroom ${next ? "enabled" : "disabled"}`, "success"); + } else { + showStatus(`Failed: ${result.error}`, "error"); + } + await pause(); +} + +/** + * Reset dashboard password to default via server API (writes the live SQLite DB). + * After reset, user can log in with the default password "123456". + */ +async function resetPassword() { + const ok = await confirm(`Reset dashboard password to default "${DEFAULT_PASSWORD}"?`); + if (!ok) { + showStatus("Cancelled", "info"); + await pause(); + return; + } + + const result = await api.resetPassword(); + if (result.success) { + showStatus(`Password reset. Default: ${DEFAULT_PASSWORD}`, "success"); + } else { + showStatus(`Failed to reset password: ${result.error}`, "error"); + } + await pause(); +} + +module.exports = { showSettingsMenu }; diff --git a/cli/src/cli/terminalUI.js b/cli/src/cli/terminalUI.js new file mode 100644 index 0000000..fb28330 --- /dev/null +++ b/cli/src/cli/terminalUI.js @@ -0,0 +1,121 @@ +const api = require("./api/client"); +const { showMenuWithBack } = require("./utils/menuHelper"); +const { showProvidersMenu } = require("./menus/providers"); +const { showApiKeysMenu } = require("./menus/apiKeys"); +const { showCombosMenu } = require("./menus/combos"); +const { showSettingsMenu } = require("./menus/settings"); +const { showCliToolsMenu } = require("./menus/cliTools"); + +const COLORS = { + reset: "\x1b[0m", + green: "\x1b[32m", + red: "\x1b[31m", + dim: "\x1b[2m", + cyan: "\x1b[36m" +}; + +// Cached header (SWR): show last value instantly, refresh in background. +let cachedHeader = ""; +let fetchingHeader = false; + +function renderHeader(port, keys, tunnel) { + const tunnelEnabled = tunnel && tunnel.enabled === true; + const lines = []; + if (tunnelEnabled && tunnel.publicUrl) { + lines.push(`Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`); + lines.push(`Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`); + } else { + lines.push(`Endpoint: http://localhost:${port}/v1`); + lines.push(`Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`); + } + if (!keys || keys.length === 0) { + lines.push(`Key: ${COLORS.dim}No API keys yet${COLORS.reset}`); + } else { + lines.push(`Key: ${COLORS.cyan}${keys[0].key}${COLORS.reset}`); + keys.slice(1).forEach(k => lines.push(` ${COLORS.cyan}${k.key}${COLORS.reset}`)); + } + return lines.join("\n"); +} + +async function refreshHeaderBg(port) { + if (fetchingHeader) return; + fetchingHeader = true; + try { + const [keysResult, tunnelResult] = await Promise.all([ + api.getApiKeys(), + api.getTunnelStatus() + ]); + const keys = keysResult.success ? (keysResult.data.keys || []) : []; + const tunnel = tunnelResult.success ? (tunnelResult.data || {}) : {}; + cachedHeader = renderHeader(port, keys, tunnel); + } finally { + fetchingHeader = false; + } +} + +function getHeader(port) { + // Kick off background refresh; return cache (or placeholder on first call). + refreshHeaderBg(port); + return cachedHeader || `Endpoint: http://localhost:${port}/v1\nTunnel: ${COLORS.dim}...${COLORS.reset}\nKey: ${COLORS.dim}...${COLORS.reset}`; +} + +/** + * Start Terminal UI + * @param {number} port - Server port number + */ +async function startTerminalUI(port) { + // Configure API client + api.configure({ port }); + + const basePath = ["9Router"]; + + // Prime header cache before first render + await refreshHeaderBg(port); + + // Main menu + await showMenuWithBack({ + title: "📡 9Router Terminal UI", + breadcrumb: basePath, + headerContent: () => getHeader(port), + items: [ + { + label: "Providers", + action: async () => { + await showProvidersMenu([...basePath, "Providers"]); + return true; // Continue + } + }, + { + label: "API Keys", + action: async () => { + await showApiKeysMenu(port, [...basePath, "API Keys"]); + return true; + } + }, + { + label: "Combos", + action: async () => { + await showCombosMenu([...basePath, "Combos"]); + return true; + } + }, + { + label: "CLI Tools", + action: async () => { + await showCliToolsMenu(port, [...basePath, "CLI Tools"]); + return true; + } + }, + { + label: "Settings", + action: async () => { + await showSettingsMenu([...basePath, "Settings"]); + return true; + } + } + ], + backLabel: "← Back to Interface Menu" + }); +} + +module.exports = { startTerminalUI }; diff --git a/cli/src/cli/tray/autostart.js b/cli/src/cli/tray/autostart.js new file mode 100644 index 0000000..4ab93cf --- /dev/null +++ b/cli/src/cli/tray/autostart.js @@ -0,0 +1,306 @@ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { execSync } = require("child_process"); + +const APP_NAME = "9router"; +const APP_LABEL = "com.9router.autostart"; + +/** + * Resolve the absolute path to this package's cli.js. + * + * Order of preference: + * 1. Explicit `cliPath` argument — cleanest, used when called from running + * cli.js with `__filename`. + * 2. `process.argv[1]` if it's our cli.js — true when 9router is currently + * running and the tray menu fires this code path. + * 3. Compute relative to this file's own location. autostart.js lives at + * `/src/cli/tray/autostart.js`, so cli.js is three levels up. + * This works for any global install layout (nvm, Volta, asdf, Homebrew, + * /usr/local, etc.) without depending on `npm bin -g` (removed in npm 9) + * or a hardcoded `/usr/local/...` path. + * + * Returns null if no candidate exists — callers should not write an autostart + * entry pointing at a non-existent script. + */ +function getCliJsPath(cliPath) { + if (cliPath) { + const resolved = path.resolve(cliPath); + if (fs.existsSync(resolved)) return resolved; + } + if (process.argv[1]) { + const resolved = path.resolve(process.argv[1]); + if (path.basename(resolved) === "cli.js" && fs.existsSync(resolved)) { + return resolved; + } + } + const computed = path.resolve(__dirname, "..", "..", "..", "cli.js"); + if (fs.existsSync(computed)) return computed; + return null; +} + +/** + * Enable auto startup on OS boot + * @param {string} cliPath - Optional path to cli.js (defaults to auto-detect) + * @returns {boolean} success + */ +function enableAutoStart(cliPath) { + const platform = process.platform; + + if (!["darwin", "win32", "linux"].includes(platform)) return false; + if (platform === "linux" && !process.env.DISPLAY) return false; + + try { + if (platform === "darwin") return enableMacOS(cliPath); + if (platform === "win32") return enableWindows(cliPath); + if (platform === "linux") return enableLinux(cliPath); + } catch (err) { + // Silent fail — autostart is optional + } + return false; +} + +/** + * Disable auto startup + * @returns {boolean} success + */ +function disableAutoStart() { + const platform = process.platform; + try { + if (platform === "darwin") return disableMacOS(); + if (platform === "win32") return disableWindows(); + if (platform === "linux") return disableLinux(); + } catch (err) {} + return false; +} + +/** + * Check if autostart is enabled. + * + * On macOS, both the plist file and the launchd registration must be present — + * otherwise the tray menu would lie about the state (showing "✓ Enabled" even + * when launchd has the agent in a failed state or hasn't loaded it). + */ +function isAutoStartEnabled() { + const platform = process.platform; + + try { + if (platform === "darwin") { + const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`); + if (!fs.existsSync(plistPath)) return false; + try { + execSync(`launchctl list ${APP_LABEL}`, { + stdio: ["ignore", "ignore", "ignore"], + timeout: 3000 + }); + return true; + } catch (e) { + return false; + } + } else if (platform === "win32") { + const startupPath = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`); + return fs.existsSync(startupPath); + } else if (platform === "linux") { + const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`); + return fs.existsSync(desktopPath); + } + } catch (e) {} + return false; +} + +// ============ macOS ============ + +/** + * Returns true when the current Node process IS the running instance that + * launchd is managing under our agent label. + * + * `launchctl unload ` (and `load`) for an Aqua user-domain agent sends + * SIGTERM to the running process. When the running 9router cli.js was itself + * spawned by the autostart launchd agent (i.e. user enabled autostart at + * some point, then rebooted, then clicked the tray icon's "Disable + * Auto-start" menu item), an unload would kill the very process executing + * the click handler — and the tray icon would disappear instead of the menu + * label flipping back to "Enable Auto-start". This helper lets the enable + * and disable paths sidestep that by skipping launchctl when we'd otherwise + * be killing ourselves. + */ +function isAgentSelfMacOS() { + try { + const output = execSync(`launchctl list ${APP_LABEL}`, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 3000 + }); + const match = output.match(/"PID"\s*=\s*(\d+)/); + return !!(match && parseInt(match[1], 10) === process.pid); + } catch (e) { + return false; + } +} + +function enableMacOS(cliPath) { + const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents"); + const plistPath = path.join(launchAgentsDir, `${APP_LABEL}.plist`); + + if (!fs.existsSync(launchAgentsDir)) { + fs.mkdirSync(launchAgentsDir, { recursive: true }); + } + + const nodePath = process.execPath; + const routerScript = getCliJsPath(cliPath); + // Don't write a broken plist that references a non-existent script. + if (!routerScript) return false; + + // Invoke node + cli.js directly with absolute paths — no shell wrapper. + // The previous design ran `zsh -l -c "..."` so a login shell would source + // nvm/.zshrc and set PATH; that's fragile (nvm.sh sourcing varies by user, + // some setups don't put node on PATH from a non-interactive login shell). + // EnvironmentVariables.PATH explicitly includes node's bin dir so child + // processes spawned by cli.js (npm install at runtime, etc.) resolve. + const launchPath = `${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin`; + + const plistContent = ` + + + + Label + ${APP_LABEL} + ProgramArguments + + ${nodePath} + ${routerScript} + --tray + --skip-update + + EnvironmentVariables + + PATH + ${launchPath} + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/9router.log + StandardErrorPath + /tmp/9router.error.log + +`; + + fs.writeFileSync(plistPath, plistContent); + + // If we're the running agent already, launchctl unload/load would send + // ourselves SIGTERM. Skip it — the plist file is updated on disk and + // launchd will pick it up at next login. isAutoStartEnabled() will still + // return true because launchctl already has the agent loaded. + if (isAgentSelfMacOS()) { + return true; + } + + // Register with launchd in the current session. Without this, the agent + // only takes effect on the next user login and the user has no signal that + // anything actually happened. `unload` first defends against re-enable + // replacing an existing plist. + try { + execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" }); + } catch (e) {} + try { + execSync(`launchctl load -w "${plistPath}"`, { stdio: "ignore" }); + } catch (e) { + // Even if load fails, the plist is on disk and will be picked up at next + // login; report success based on the file write. + } + return true; +} + +function disableMacOS() { + const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`); + + // Don't kill ourselves: when the current process is the running agent, + // `launchctl unload` would send SIGTERM and the user clicking + // "Disable Auto-start" from the tray menu would lose their tray icon + // instead of just flipping the menu label. Skip the unload — removing the + // plist file is enough to prevent the agent from starting on next login. + if (!isAgentSelfMacOS()) { + try { + execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" }); + } catch (e) {} + } + + if (fs.existsSync(plistPath)) { + fs.unlinkSync(plistPath); + } + return true; +} + +// ============ Windows ============ + +function enableWindows(cliPath) { + const startupDir = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup"); + const vbsPath = path.join(startupDir, `${APP_NAME}.vbs`); + + if (!fs.existsSync(startupDir)) return false; + + const nodePath = process.execPath; + const routerScript = getCliJsPath(cliPath); + if (!routerScript) return false; + + // Run node + cli.js directly, hidden window. Avoids the fragile + // `9router.cmd` lookup that depended on the npm prefix path. + const vbsContent = `Set WshShell = CreateObject("WScript.Shell") +WshShell.Run """${nodePath}"" ""${routerScript}"" --tray --skip-update", 0, False +`; + fs.writeFileSync(vbsPath, vbsContent); + return true; +} + +function disableWindows() { + const vbsPath = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`); + if (fs.existsSync(vbsPath)) { + fs.unlinkSync(vbsPath); + } + return true; +} + +// ============ Linux ============ + +function enableLinux(cliPath) { + const autostartDir = path.join(os.homedir(), ".config", "autostart"); + const desktopPath = path.join(autostartDir, `${APP_NAME}.desktop`); + + if (!fs.existsSync(autostartDir)) { + try { fs.mkdirSync(autostartDir, { recursive: true }); } + catch (e) { return false; } + } + + const nodePath = process.execPath; + const routerScript = getCliJsPath(cliPath); + if (!routerScript) return false; + + const desktopContent = `[Desktop Entry] +Type=Application +Name=9Router +Comment=9Router API Proxy +Exec=${nodePath} ${routerScript} --tray --skip-update +Hidden=false +NoDisplay=false +X-GNOME-Autostart-enabled=true +`; + fs.writeFileSync(desktopPath, desktopContent); + return true; +} + +function disableLinux() { + const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`); + if (fs.existsSync(desktopPath)) { + fs.unlinkSync(desktopPath); + } + return true; +} + +module.exports = { + enableAutoStart, + disableAutoStart, + isAutoStartEnabled +}; diff --git a/cli/src/cli/tray/icon.ico b/cli/src/cli/tray/icon.ico new file mode 100644 index 0000000..9cf0e3d Binary files /dev/null and b/cli/src/cli/tray/icon.ico differ diff --git a/cli/src/cli/tray/icon.png b/cli/src/cli/tray/icon.png new file mode 100644 index 0000000..55bf515 Binary files /dev/null and b/cli/src/cli/tray/icon.png differ diff --git a/cli/src/cli/tray/tray.js b/cli/src/cli/tray/tray.js new file mode 100644 index 0000000..6658e94 --- /dev/null +++ b/cli/src/cli/tray/tray.js @@ -0,0 +1,322 @@ +const { exec } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +let trayInstance = null; +let isWinTray = false; + +/** + * Get icon base64 from file — used for systray (mac/linux) + */ +function getIconBase64() { + const isWin = process.platform === "win32"; + const iconFile = isWin ? "icon.ico" : "icon.png"; + try { + const iconPath = path.join(__dirname, iconFile); + if (fs.existsSync(iconPath)) { + return fs.readFileSync(iconPath).toString("base64"); + } + } catch (e) {} + // Fallback: minimal green dot icon (PNG) + return "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAHpJREFUOE9jYBgFgwEwMjIy/Gdg+P8fyP4PxP8ZGBgEcBnGyMjIsICBgSEAhyH/gfgBUNN8XJoZsdkCVL8Ah+b/QPwbqvkBMvk/AwMDAzYX/GdgYAhAN+A/SICRWAMYGfFEJSMjzriEiwDR/xmIa2RkZCSqnZERb3QCAAo3KxzxbKe1AAAAAElFTkSuQmCC"; +} + +/** + * Check if system tray is supported on current OS + * Supported: macOS, Windows, Linux (with GUI) + */ +function isTraySupported() { + const platform = process.platform; + if (!["darwin", "win32", "linux"].includes(platform)) { + return false; + } + if (platform === "linux" && !process.env.DISPLAY) { + return false; + } + return true; +} + +/** + * Initialize system tray with menu + * @param {Object} options - { port, onQuit, onOpenDashboard } + * @returns {Object|null} tray instance or null if not supported/failed + */ +function initTray(options) { + if (!isTraySupported()) { + return null; + } + + // Windows uses PowerShell NotifyIcon (AV-safe), others use systray + if (process.platform === "win32") { + return initWindowsTray(options); + } + return initUnixTray(options); +} + +/** + * Build menu items array shared between platforms + */ +function buildMenuItems(port, autostartEnabled) { + return [ + { title: `9Router (Port ${port})`, tooltip: "Server is running", enabled: false }, + { title: "Open Dashboard", tooltip: "Open in browser", enabled: true }, + { + title: autostartEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start", + tooltip: "Run on OS startup", + enabled: true + }, + { title: "Quit", tooltip: "Stop server and exit", enabled: true } + ]; +} + +// Menu item indexes +const MENU_INDEX = { STATUS: 0, DASHBOARD: 1, AUTOSTART: 2, QUIT: 3 }; + +/** + * Get current autostart state + */ +function getAutostartEnabled() { + try { + const { isAutoStartEnabled } = require("./autostart"); + return isAutoStartEnabled(); + } catch (e) { + return false; + } +} + +/** + * Handle menu item click (shared logic) + */ +function handleClick(index, options, onAutostartToggle) { + const { onQuit, onOpenDashboard, port } = options; + if (index === MENU_INDEX.DASHBOARD) { + if (onOpenDashboard) onOpenDashboard(); + else openBrowser(`http://localhost:${port}/dashboard`); + } else if (index === MENU_INDEX.AUTOSTART) { + const enabled = getAutostartEnabled(); + try { + const { enableAutoStart, disableAutoStart } = require("./autostart"); + if (enabled) disableAutoStart(); + else enableAutoStart(); + onAutostartToggle(!enabled); + } catch (e) {} + } else if (index === MENU_INDEX.QUIT) { + console.log("\n👋 Shutting down..."); + if (onQuit) onQuit(); + killTray(); + setTimeout(() => process.exit(0), 500); + } +} + +/** + * Windows tray via PowerShell NotifyIcon + */ +function initWindowsTray(options) { + const { port } = options; + try { + const { initWinTray } = require("./trayWin"); + const iconPath = path.join(__dirname, "icon.ico"); + const autostartEnabled = getAutostartEnabled(); + const items = buildMenuItems(port, autostartEnabled); + + trayInstance = initWinTray({ + iconPath, + tooltip: `9Router - Port ${port}`, + items, + onClick: (index) => { + handleClick(index, options, (newEnabled) => { + const newTitle = newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start"; + trayInstance.updateItem(MENU_INDEX.AUTOSTART, newTitle, true); + }); + } + }); + + isWinTray = true; + return trayInstance; + } catch (err) { + return null; + } +} + +/** + * macOS/Linux tray via systray binary + * + * Prefers `systray2` (active fork of `systray`, ships newer + * getlantern/systray-portable binaries that work on macOS 14+ and Apple + * Silicon under Rosetta). Falls back to legacy `systray@1.0.5` if systray2 + * is not available, though that binary's Mach-O headers are rejected by + * modern dyld and the icon will not appear. + */ +function resolveSystray() { + let runtimeDir = null; + try { + const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime"); + runtimeDir = getRuntimeNodeModules(); + } catch (e) {} + + // 1) systray2 in runtime dir (where ensureTrayRuntime installs it) + if (runtimeDir) { + try { return { mod: require(path.join(runtimeDir, "systray2")).default, isV2: true }; } catch (e) {} + } + // 2) systray2 resolvable from the package's own node_modules / NODE_PATH + try { return { mod: require("systray2").default, isV2: true }; } catch (e) {} + // 3) Legacy systray fallback (unlikely to render on modern macOS) + try { return { mod: require("systray").default, isV2: false }; } catch (e) {} + if (runtimeDir) { + try { return { mod: require(path.join(runtimeDir, "systray")).default, isV2: false }; } catch (e) {} + } + return null; +} + +function chmodTrayBin(pkgName) { + // systray2's npm tarball occasionally lands without +x on the bundled Go + // binary (observed on macOS). spawn() then fails with EACCES. Best-effort + // chmod on every init avoids a hard-to-diagnose silent tray failure. + try { + const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime"); + const binName = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release"; + const candidates = [ + path.join(getRuntimeNodeModules(), pkgName, "traybin", binName), + path.join(__dirname, "..", "..", "..", "node_modules", pkgName, "traybin", binName) + ]; + for (const p of candidates) { + if (fs.existsSync(p)) fs.chmodSync(p, 0o755); + } + } catch (e) {} +} + +function initUnixTray(options) { + const { port } = options; + try { + const resolved = resolveSystray(); + if (!resolved) return null; + const { mod: SysTray, isV2 } = resolved; + + chmodTrayBin(isV2 ? "systray2" : "systray"); + + const autostartEnabled = getAutostartEnabled(); + const items = buildMenuItems(port, autostartEnabled); + + const menu = { + icon: getIconBase64(), + // The bundled icon.png is a full-color RGBA logo. Don't mark it as a + // template icon: macOS would then render it as a solid white square + // because template mode only uses the alpha channel. + isTemplateIcon: false, + title: "", + tooltip: `9Router - Port ${port}`, + items + }; + + trayInstance = new SysTray({ menu, debug: false, copyDir: true }); + isWinTray = false; + + trayInstance.onClick((action) => { + handleClick(action.seq_id, options, (newEnabled) => { + trayInstance.sendAction({ + type: "update-item", + item: { + title: newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start", + tooltip: "Run on OS startup", + enabled: true + }, + seq_id: MENU_INDEX.AUTOSTART + }); + }); + }); + + if (isV2) { + // systray2 exposes a ready() promise instead of onReady/onError. Surface + // failures (binary crash, EACCES, etc.) so users can see why the icon + // didn't appear instead of getting a misleading "running in tray" log. + trayInstance.ready().catch((err) => { + process.stderr.write(`[9router] tray failed to start: ${err && err.message ? err.message : err}\n`); + }); + } else { + trayInstance.onReady(() => {}); + trayInstance.onError(() => {}); + } + + return trayInstance; + } catch (err) { + process.stderr.write(`[9router] tray init error: ${err.message}\n`); + return null; + } +} + +/** + * Kill tray, wait Go binary fully exit (returns Promise). + * Critical for hide-to-tray: macOS must release NSStatusItem before bgProcess + * spawns a new tray, otherwise the new icon silently fails to register. + */ +function killTray() { + const instance = trayInstance; + const wasWin = isWinTray; + trayInstance = null; + if (!instance) return Promise.resolve(); + + if (wasWin) { + try { instance.kill(); } catch (e) {} + return Promise.resolve(); + } + + // Unix: get the Go tray child process handle. + let proc = null; + try { + proc = instance._process || (typeof instance.process === "function" ? instance.process() : null); + } catch (e) {} + + // Graceful shutdown: send {type:"exit"} via IPC so the Go binary can call + // systray.Quit() and release NSStatusItem. SIGKILL leaves a ghost icon on + // the macOS menubar until logout, causing duplicate icons after re-spawn. + const gracefulQuit = () => { try { instance.kill(true); } catch (e) {} }; + const closeIpc = () => { try { instance.kill(false); } catch (e) {} }; + + if (!proc || !proc.pid) { + gracefulQuit(); + closeIpc(); + return Promise.resolve(); + } + + return new Promise((resolve) => { + let done = false; + const finish = () => { if (done) return; done = true; closeIpc(); resolve(); }; + + proc.once("exit", finish); + gracefulQuit(); + + // Escalate: SIGTERM after 800ms, SIGKILL after 1600ms if still alive. + setTimeout(() => { try { process.kill(proc.pid, 0); proc.kill("SIGTERM"); } catch (e) {} }, 800); + setTimeout(() => { try { process.kill(proc.pid, 0); proc.kill("SIGKILL"); } catch (e) {} }, 1600); + + // Fallback poll in case "exit" never fires (detached child, pipe closed) + const deadline = Date.now() + 3000; + const poll = setInterval(() => { + try { process.kill(proc.pid, 0); } catch { clearInterval(poll); finish(); return; } + if (Date.now() > deadline) { clearInterval(poll); finish(); } + }, 50); + }); +} + +/** + * Open browser + */ +function openBrowser(url) { + const platform = process.platform; + let cmd; + + if (platform === "darwin") { + cmd = `open "${url}"`; + } else if (platform === "win32") { + cmd = `start "" "${url}"`; + } else { + cmd = `xdg-open "${url}"`; + } + + exec(cmd); +} + +module.exports = { + initTray, + killTray +}; diff --git a/cli/src/cli/tray/tray.ps1 b/cli/src/cli/tray/tray.ps1 new file mode 100644 index 0000000..bc55f39 --- /dev/null +++ b/cli/src/cli/tray/tray.ps1 @@ -0,0 +1,130 @@ +# 9Router tray icon for Windows using NotifyIcon +# IPC: stdin JSON commands, stdout JSON events +param([string]$IconPath, [string]$Tooltip) + +$ErrorActionPreference = "Stop" + +Add-Type @" +using System; +using System.Runtime.InteropServices; + +public static class WinDpiAwareness { + public static IntPtr PerMonitorAwareV2 { get { return new IntPtr(-4); } } + public static IntPtr PerMonitorAware { get { return new IntPtr(-3); } } + + [DllImport("user32.dll")] + public static extern bool SetProcessDpiAwarenessContext(IntPtr value); + + [DllImport("user32.dll")] + public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr value); + + [DllImport("shcore.dll")] + public static extern int SetProcessDpiAwareness(int value); + + [DllImport("user32.dll")] + public static extern bool SetProcessDPIAware(); +} +"@ + +function Enable-HighDpiAwareness { + $contexts = @( + [WinDpiAwareness]::PerMonitorAwareV2, + [WinDpiAwareness]::PerMonitorAware + ) + + foreach ($context in $contexts) { + try { + if ([WinDpiAwareness]::SetProcessDpiAwarenessContext($context)) { break } + } catch {} + } + + try { [WinDpiAwareness]::SetProcessDpiAwareness(2) | Out-Null } catch {} + try { [WinDpiAwareness]::SetProcessDPIAware() | Out-Null } catch {} + + foreach ($context in $contexts) { + try { + $previous = [WinDpiAwareness]::SetThreadDpiAwarenessContext($context) + if ($previous -ne [IntPtr]::Zero) { break } + } catch {} + } +} + +Enable-HighDpiAwareness + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +[Console]::InputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +[System.Windows.Forms.Application]::EnableVisualStyles() +[System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false) + +$script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon +$script:notifyIcon.Icon = New-Object System.Drawing.Icon($IconPath) +$script:notifyIcon.Text = $Tooltip +$script:notifyIcon.Visible = $true + +$script:menu = New-Object System.Windows.Forms.ContextMenuStrip +$script:notifyIcon.ContextMenuStrip = $script:menu +$script:items = @() + +function Write-Event($obj) { + $json = $obj | ConvertTo-Json -Compress + [Console]::Out.WriteLine($json) + [Console]::Out.Flush() +} + +function Add-MenuItem($index, $title, $enabled) { + $item = New-Object System.Windows.Forms.ToolStripMenuItem + $item.Text = $title + $item.Enabled = $enabled + $idx = $index + $item.Add_Click({ Write-Event @{ type = "click"; index = $idx } }.GetNewClosure()) + $script:menu.Items.Add($item) | Out-Null + $script:items += $item +} + +function Update-MenuItem($index, $title, $enabled) { + if ($index -lt $script:items.Count) { + $script:items[$index].Text = $title + $script:items[$index].Enabled = $enabled + } +} + +function Set-Tooltip($text) { + # NotifyIcon.Text max 63 chars + if ($text.Length -gt 63) { $text = $text.Substring(0, 63) } + $script:notifyIcon.Text = $text +} + +# Background reader thread polls stdin via timer on UI thread +$script:timer = New-Object System.Windows.Forms.Timer +$script:timer.Interval = 100 +$script:timer.Add_Tick({ + try { + while ([Console]::In.Peek() -ne -1) { + $line = [Console]::In.ReadLine() + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $cmd = $line | ConvertFrom-Json + switch ($cmd.action) { + "add-item" { Add-MenuItem $cmd.index $cmd.title $cmd.enabled } + "update-item" { Update-MenuItem $cmd.index $cmd.title $cmd.enabled } + "set-tooltip" { Set-Tooltip $cmd.text } + "ready" { Write-Event @{ type = "ready" } } + "kill" { + $script:notifyIcon.Visible = $false + $script:notifyIcon.Dispose() + [System.Windows.Forms.Application]::Exit() + } + } + } + } catch { + Write-Event @{ type = "error"; message = $_.Exception.Message } + } +}) +$script:timer.Start() + +Write-Event @{ type = "started" } +[System.Windows.Forms.Application]::Run() diff --git a/cli/src/cli/tray/trayWin.js b/cli/src/cli/tray/trayWin.js new file mode 100644 index 0000000..8c11280 --- /dev/null +++ b/cli/src/cli/tray/trayWin.js @@ -0,0 +1,89 @@ +const { spawn } = require("child_process"); +const path = require("path"); +const readline = require("readline"); + +// PowerShell-based tray for Windows (AV-safe, zero binary deps) + +let psProcess = null; +let clickHandler = null; + +/** + * Send JSON command to PowerShell tray process via stdin + */ +function sendCommand(cmd) { + if (psProcess && psProcess.stdin.writable) { + psProcess.stdin.write(`${JSON.stringify(cmd)}\n`, "utf8"); + } +} + +/** + * Initialize Windows tray using PowerShell NotifyIcon + * @param {Object} options - { iconPath, tooltip, items, onClick } + * items: [{ title, enabled }] + * @returns {Object|null} controller with sendAction/kill + */ +function initWinTray(options) { + const { iconPath, tooltip, items, onClick } = options; + clickHandler = onClick; + + const scriptPath = path.join(__dirname, "tray.ps1"); + + try { + psProcess = spawn( + "powershell.exe", + [ + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-WindowStyle", "Hidden", + "-InputFormat", "Text", + "-OutputFormat", "Text", + "-File", scriptPath, + "-IconPath", iconPath, + "-Tooltip", tooltip + ], + { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] } + ); + } catch (err) { + return null; + } + + const rl = readline.createInterface({ input: psProcess.stdout }); + rl.on("line", (line) => { + try { + const evt = JSON.parse(line); + if (evt.type === "click" && clickHandler) { + clickHandler(evt.index); + } + } catch (e) {} + }); + + psProcess.on("error", () => {}); + psProcess.stderr.on("data", () => {}); + + // Send initial menu items + items.forEach((item, index) => { + sendCommand({ action: "add-item", index, title: item.title, enabled: item.enabled }); + }); + + return { + updateItem(index, title, enabled) { + sendCommand({ action: "update-item", index, title, enabled }); + }, + setTooltip(text) { + sendCommand({ action: "set-tooltip", text }); + }, + kill() { + try { + sendCommand({ action: "kill" }); + } catch (e) {} + setTimeout(() => { + if (psProcess && !psProcess.killed) { + try { psProcess.kill(); } catch (e) {} + } + psProcess = null; + }, 300); + } + }; +} + +module.exports = { initWinTray }; diff --git a/cli/src/cli/utils/clipboard.js b/cli/src/cli/utils/clipboard.js new file mode 100644 index 0000000..c9cc5b5 --- /dev/null +++ b/cli/src/cli/utils/clipboard.js @@ -0,0 +1,30 @@ +const { execSync } = require("child_process"); + +/** + * Copy text to clipboard based on OS + * @param {string} text - Text to copy + * @returns {boolean} Success status + */ +function copyToClipboard(text) { + try { + const platform = process.platform; + + if (platform === "darwin") { + execSync("pbcopy", { input: text }); + } else if (platform === "win32") { + execSync("clip", { input: text }); + } else { + // Linux - try xclip first, then xsel + try { + execSync("xclip -selection clipboard", { input: text }); + } catch { + execSync("xsel --clipboard --input", { input: text }); + } + } + return true; + } catch (error) { + return false; + } +} + +module.exports = { copyToClipboard }; diff --git a/cli/src/cli/utils/display.js b/cli/src/cli/utils/display.js new file mode 100644 index 0000000..a68d830 --- /dev/null +++ b/cli/src/cli/utils/display.js @@ -0,0 +1,156 @@ +const { formatNumber } = require("./format"); + +// ANSI color codes +const COLORS = { + reset: "\x1b[0m", + success: "\x1b[32m", + error: "\x1b[31m", + warning: "\x1b[33m", + info: "\x1b[36m", + dim: "\x1b[2m", + bold: "\x1b[1m", + bright: "\x1b[1m", + cyan: "\x1b[36m" +}; + +// Box drawing characters +const BOX_CHARS = { + topLeft: "┌", + topRight: "┐", + bottomLeft: "└", + bottomRight: "┘", + horizontal: "─", + vertical: "│" +}; + +/** + * Draw a box with border around content + * @param {string} title - Box title + * @param {string} content - Content to display inside box + * @param {number} [width=60] - Box width + */ +function showBox(title, content, width = 60) { + const innerWidth = width - 4; + const lines = content.split("\n"); + + // Top border with title + const topBorder = BOX_CHARS.topLeft + BOX_CHARS.horizontal.repeat(2) + + ` ${title} ` + + BOX_CHARS.horizontal.repeat(Math.max(0, innerWidth - title.length - 3)) + + BOX_CHARS.topRight; + + console.log(topBorder); + + // Content lines + lines.forEach(line => { + const paddedLine = line.padEnd(innerWidth); + console.log(`${BOX_CHARS.vertical} ${paddedLine} ${BOX_CHARS.vertical}`); + }); + + // Bottom border + const bottomBorder = BOX_CHARS.bottomLeft + + BOX_CHARS.horizontal.repeat(innerWidth + 2) + + BOX_CHARS.bottomRight; + + console.log(bottomBorder); +} + +/** + * Display a menu with numbered items + * @param {string} title - Menu title + * @param {string[]} items - Array of menu items + * @param {string} [footer] - Optional footer text + */ +function showMenu(title, items, footer) { + console.log(`\n${COLORS.bold}${title}${COLORS.reset}`); + console.log(COLORS.dim + "─".repeat(title.length) + COLORS.reset); + + items.forEach((item, index) => { + console.log(` ${COLORS.info}${index + 1}.${COLORS.reset} ${item}`); + }); + + if (footer) { + console.log(`\n${COLORS.dim}${footer}${COLORS.reset}`); + } + console.log(); +} + +/** + * Display data in table format + * @param {string[]} headers - Array of column headers + * @param {Array>} rows - Array of row data + */ +function showTable(headers, rows) { + if (!headers.length || !rows.length) { + return; + } + + // Calculate column widths + const colWidths = headers.map((header, i) => { + const maxDataWidth = Math.max(...rows.map(row => String(row[i] || "").length)); + return Math.max(header.length, maxDataWidth); + }); + + // Print header + const headerRow = headers.map((h, i) => h.padEnd(colWidths[i])).join(" │ "); + console.log(COLORS.bold + headerRow + COLORS.reset); + + // Print separator + const separator = colWidths.map(w => "─".repeat(w)).join("─┼─"); + console.log(COLORS.dim + separator + COLORS.reset); + + // Print rows + rows.forEach(row => { + const rowStr = row.map((cell, i) => String(cell || "").padEnd(colWidths[i])).join(" │ "); + console.log(rowStr); + }); +} + +/** + * Show colored status message + * @param {string} message - Message to display + * @param {string} [type="info"] - Status type: success, error, warning, info + */ +function showStatus(message, type = "info") { + const symbols = { + success: "✓", + error: "✗", + warning: "⚠", + info: "ℹ" + }; + + const color = COLORS[type] || COLORS.info; + const symbol = symbols[type] || symbols.info; + + console.log(`${color}${symbol} ${message}${COLORS.reset}`); +} + +/** + * Clear the terminal screen + */ +function clearScreen() { + console.clear(); +} + +/** + * Show menu header with title and subtitle + * @param {string} title - Main title + * @param {string} subtitle - Optional subtitle + */ +function showHeader(title, subtitle) { + console.log(`\n${"=".repeat(60)}`); + console.log(` ${COLORS.bright}${COLORS.cyan}${title}${COLORS.reset}`); + if (subtitle) { + console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`); + } + console.log(`${"=".repeat(60)}\n`); +} + +module.exports = { + showBox, + showMenu, + showTable, + showStatus, + clearScreen, + showHeader +}; diff --git a/cli/src/cli/utils/endpoint.js b/cli/src/cli/utils/endpoint.js new file mode 100644 index 0000000..20226e6 --- /dev/null +++ b/cli/src/cli/utils/endpoint.js @@ -0,0 +1,32 @@ +const api = require("../api/client"); + +const COLORS = { + reset: "\x1b[0m", + green: "\x1b[32m" +}; + +/** + * Get endpoint URL based on tunnel status + * @param {number} port - Local server port + * @returns {Promise<{endpoint: string, tunnelEnabled: boolean}>} + */ +async function getEndpoint(port) { + const result = await api.getTunnelStatus(); + const tunnelEnabled = result.success && result.data?.enabled === true; + const publicUrl = result.success ? result.data?.publicUrl : ""; + + const endpoint = tunnelEnabled && publicUrl ? `${publicUrl}/v1` : `http://localhost:${port}/v1`; + return { endpoint, tunnelEnabled }; +} + +/** + * Get endpoint with color formatting + * @param {number} port - Local server port + * @returns {Promise} Colored endpoint string + */ +async function getEndpointColored(port) { + const { endpoint, tunnelEnabled } = await getEndpoint(port); + return tunnelEnabled ? `${COLORS.green}${endpoint}${COLORS.reset}` : endpoint; +} + +module.exports = { getEndpoint, getEndpointColored }; diff --git a/cli/src/cli/utils/format.js b/cli/src/cli/utils/format.js new file mode 100644 index 0000000..5bf2ea4 --- /dev/null +++ b/cli/src/cli/utils/format.js @@ -0,0 +1,125 @@ +/** + * Truncate text with ellipsis + * @param {string} text - Text to truncate + * @param {number} maxLength - Maximum length + * @returns {string} Truncated text + */ +function truncate(text, maxLength) { + if (!text || text.length <= maxLength) { + return text; + } + return text.substring(0, maxLength - 3) + "..."; +} + +/** + * Mask API key showing only first and last characters + * @param {string} key - API key to mask + * @returns {string} Masked key + */ +function maskKey(key) { + if (!key || key.length < 8) { + return "***"; + } + const firstChars = key.substring(0, 4); + const lastChars = key.substring(key.length - 4); + return `${firstChars}${"*".repeat(key.length - 8)}${lastChars}`; +} + +/** + * Format date to readable string + * @param {Date|string|number} date - Date to format + * @returns {string} Formatted date string + */ +function formatDate(date) { + const d = new Date(date); + if (isNaN(d.getTime())) { + return "Invalid Date"; + } + + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const hours = String(d.getHours()).padStart(2, "0"); + const minutes = String(d.getMinutes()).padStart(2, "0"); + const seconds = String(d.getSeconds()).padStart(2, "0"); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +/** + * Format number with commas + * @param {number} num - Number to format + * @returns {string} Formatted number + */ +function formatNumber(num) { + if (typeof num !== "number" || isNaN(num)) { + return "0"; + } + return num.toLocaleString("en-US"); +} + +/** + * Format bytes to human readable size + * @param {number} bytes - Bytes to format + * @returns {string} Formatted size string + */ +function formatBytes(bytes) { + if (typeof bytes !== "number" || isNaN(bytes) || bytes < 0) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; +} + +/** + * Get relative time string + * @param {Date|string|number} date - Date to compare + * @returns {string} Relative time string + */ +function getRelativeTime(date) { + const d = new Date(date); + if (isNaN(d.getTime())) { + return "Invalid Date"; + } + + const now = new Date(); + const diffMs = now - d; + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + const diffMonth = Math.floor(diffDay / 30); + const diffYear = Math.floor(diffDay / 365); + + if (diffSec < 60) { + return "just now"; + } else if (diffMin < 60) { + return `${diffMin} minute${diffMin > 1 ? "s" : ""} ago`; + } else if (diffHour < 24) { + return `${diffHour} hour${diffHour > 1 ? "s" : ""} ago`; + } else if (diffDay < 30) { + return `${diffDay} day${diffDay > 1 ? "s" : ""} ago`; + } else if (diffMonth < 12) { + return `${diffMonth} month${diffMonth > 1 ? "s" : ""} ago`; + } else { + return `${diffYear} year${diffYear > 1 ? "s" : ""} ago`; + } +} + +module.exports = { + truncate, + maskKey, + formatDate, + formatNumber, + formatBytes, + getRelativeTime +}; diff --git a/cli/src/cli/utils/input.js b/cli/src/cli/utils/input.js new file mode 100644 index 0000000..5761a2b --- /dev/null +++ b/cli/src/cli/utils/input.js @@ -0,0 +1,156 @@ +const readline = require("readline"); + +const COLORS = { + reset: "\x1b[0m", + bright: "\x1b[1m", + dim: "\x1b[2m", + underline: "\x1b[4m", + reverse: "\x1b[7m", + cyan: "\x1b[36m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + white: "\x1b[37m", + bgGreen: "\x1b[42m", + bgBlue: "\x1b[44m", + black: "\x1b[30m", + terracotta: "\x1b[38;2;217;119;87m", + bgTerracotta: "\x1b[48;2;217;119;87m" +}; + +// Prime stdin once globally. Toggling raw mode between menus adds latency on +// macOS, so we keep raw mode on for the whole TUI session. +let rawPrimed = false; +function primeRawOnce() { + if (rawPrimed || !process.stdin.isTTY) return; + try { + readline.emitKeypressEvents(process.stdin); + process.stdin.setRawMode(true); + process.stdin.setEncoding("utf8"); + process.stdin.resume(); + rawPrimed = true; + } catch {} +} + +function suspendRawFor(fn) { + // Temporarily drop raw mode so readline.question can buffer line input. + const wasPrimed = rawPrimed; + if (wasPrimed && process.stdin.isTTY) { + try { process.stdin.setRawMode(false); } catch {} + } + return fn().finally(() => { + if (wasPrimed && process.stdin.isTTY) { + try { process.stdin.setRawMode(true); } catch {} + process.stdin.resume(); + } + }); +} + +async function prompt(question) { + return suspendRawFor(() => new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(question, (answer) => { + rl.close(); + resolve((answer || "").trim()); + }); + })); +} + +async function select(question, options) { + console.log(question); + options.forEach((opt, i) => console.log(` ${i + 1}. ${opt}`)); + while (true) { + const answer = await prompt("\nSelect option (number): "); + const num = parseInt(answer, 10); + if (!isNaN(num) && num >= 1 && num <= options.length) return num - 1; + console.log(`Invalid selection. Please enter a number between 1 and ${options.length}`); + } +} + +async function confirm(question) { + while (true) { + const answer = await prompt(`${question} (y/n): `); + const lower = answer.toLowerCase(); + if (lower === "y" || lower === "yes") return true; + if (lower === "n" || lower === "no") return false; + console.log("Please answer 'y' or 'n'"); + } +} + +async function pause(message = "Press Enter to continue...") { + return suspendRawFor(() => new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(message, () => { rl.close(); resolve(); }); + })); +} + +/** + * Interactive arrow-key menu. Renders ★/☆ icons; selected line uses reverse+bright + * (no underline). Uses readline keypress + raw 'data' fallback to prevent + * arrow-key escape sequence leaks on macOS. + */ +async function selectMenu(title, items, defaultIndex = 0, subtitle = "", headerContent = "", breadcrumb = []) { + return new Promise((resolve) => { + let selectedIndex = defaultIndex; + let isActive = true; + + primeRawOnce(); + if (!process.stdin.isTTY) { resolve(-1); return; } + + const renderMenu = () => { + if (!isActive) return; + process.stdout.write("\x1b[2J\x1b[H"); + const width = Math.min(process.stdout.columns || 40, 40); + console.log(`\n${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`); + console.log(` ${COLORS.bright}${COLORS.terracotta}${title}${COLORS.reset}`); + if (subtitle) console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`); + console.log(`${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`); + if (breadcrumb.length > 0) console.log(` ${COLORS.dim}${breadcrumb.join(" > ")}${COLORS.reset}`); + console.log(); + if (headerContent) { console.log(headerContent); console.log(); } + + const isWin = process.platform === "win32"; + items.forEach((item, index) => { + const isSelected = index === selectedIndex; + const icon = isSelected ? (isWin ? ">" : "★") : (isWin ? " " : "☆"); + if (isSelected) { + console.log(` ${COLORS.reverse}${COLORS.bright}${icon} ${item.label}${COLORS.reset}`); + } else { + console.log(` ${icon} ${item.label}`); + } + }); + }; + + const cleanup = () => { + if (!isActive) return; + isActive = false; + process.stdin.removeListener("keypress", onKeypress); + }; + + const move = (delta) => { + selectedIndex = (selectedIndex + delta + items.length) % items.length; + renderMenu(); + }; + + const onKeypress = (_str, key) => { + if (!isActive || !key) return; + if (key.name === "up") return move(-1); + if (key.name === "down") return move(1); + if (key.name === "return") { cleanup(); resolve(selectedIndex); return; } + if (key.name === "escape") { cleanup(); resolve(-1); return; } + if (key.ctrl && key.name === "c") { cleanup(); process.exit(0); } + }; + + process.stdin.on("keypress", onKeypress); + renderMenu(); + }); +} + +module.exports = { + prompt, + select, + confirm, + pause, + selectMenu, + COLORS +}; diff --git a/cli/src/cli/utils/menuHelper.js b/cli/src/cli/utils/menuHelper.js new file mode 100644 index 0000000..41d2906 --- /dev/null +++ b/cli/src/cli/utils/menuHelper.js @@ -0,0 +1,156 @@ +const { selectMenu } = require("./input"); + +/** + * Show a menu with back button at top and handle selection + * @param {Object} config - Menu configuration + * @param {string} config.title - Menu title + * @param {string} config.headerContent - Optional header content + * @param {Array<{label: string, action: Function}>} config.items - Menu items with actions + * @param {string} config.backLabel - Back button label (default: "← Back") + * @param {number} config.defaultIndex - Default selected index (default: 0) + * @param {Function} config.refresh - Optional refresh function to call after each action + * @param {Array} config.breadcrumb - Optional breadcrumb path + * @returns {Promise} + */ +async function showMenuWithBack(config) { + const { + title, + headerContent = "", + items, + backLabel = "← Back", + defaultIndex = 0, + refresh = null, + breadcrumb = [] + } = config; + + while (true) { + // Call refresh if provided + let refreshedData = null; + if (refresh) { + refreshedData = await refresh(); + if (refreshedData === null) { + // Refresh failed, exit menu + return; + } + } + + // Build menu items with back at top + const menuItems = [ + { label: backLabel, icon: "☆" }, + ...items.map(item => ({ + label: typeof item.label === "function" ? item.label(refreshedData) : item.label, + icon: "☆" + })) + ]; + + // Resolve headerContent if it's a function + const resolvedHeader = typeof headerContent === "function" + ? await headerContent(refreshedData) + : headerContent; + + const selected = await selectMenu( + title, + menuItems, + defaultIndex, + "", + resolvedHeader, + breadcrumb + ); + + // Back or ESC + if (selected === -1 || selected === 0) { + return; + } + + // Execute action for selected item + const actionIndex = selected - 1; + const item = items[actionIndex]; + + if (item && item.action) { + const shouldContinue = await item.action(refreshedData); + // If action returns false, exit menu + if (shouldContinue === false) { + return; + } + } + } +} + +/** + * Show a list menu where items are fetched dynamically + * @param {Object} config - Menu configuration + * @param {string} config.title - Menu title + * @param {string} config.headerContent - Optional header content + * @param {Function} config.fetchItems - Async function to fetch items array + * @param {Function} config.formatItem - Function to format each item to {label, data} + * @param {Function} config.onSelect - Action when item is selected + * @param {Object} config.createAction - Optional create action {label, action} + * @param {string} config.backLabel - Back button label + * @param {Array} config.breadcrumb - Optional breadcrumb path + * @returns {Promise} + */ +async function showListMenu(config) { + const { + title, + headerContent = "", + fetchItems, + formatItem, + onSelect, + createAction = null, + backLabel = "← Back", + breadcrumb = [] + } = config; + + while (true) { + // Fetch items + const result = await fetchItems(); + if (!result) { + return; + } + + const items = result.items || []; + const metadata = result.metadata || {}; + + // Build menu items + const menuItems = [{ label: backLabel, icon: "☆" }]; + + if (createAction) { + menuItems.push({ label: createAction.label, icon: "☆" }); + } + + items.forEach(item => { + const formatted = formatItem(item); + menuItems.push({ label: formatted, icon: "☆" }); + }); + + const header = typeof headerContent === "function" + ? await headerContent(metadata) + : headerContent; + + const selected = await selectMenu(title, menuItems, 0, "", header, breadcrumb); + + // Back or ESC + if (selected === -1 || selected === 0) { + return; + } + + // Create action + if (createAction && selected === 1) { + await createAction.action(); + continue; + } + + // Select item + const offset = createAction ? 2 : 1; + const itemIndex = selected - offset; + + if (itemIndex >= 0 && itemIndex < items.length) { + await onSelect(items[itemIndex]); + } + } +} + +module.exports = { + showMenuWithBack, + showListMenu +}; diff --git a/cli/src/cli/utils/modelSelector.js b/cli/src/cli/utils/modelSelector.js new file mode 100644 index 0000000..438220d --- /dev/null +++ b/cli/src/cli/utils/modelSelector.js @@ -0,0 +1,136 @@ +const api = require("../api/client"); +const { prompt } = require("./input"); +const { clearScreen } = require("./display"); + +// Provider alias order: OAuth first, then API Key (matches ModelSelectModal) +const PROVIDER_ALIAS_ORDER = [ + "cc", "ag", "cx", "if", "qw", "gc", "gh", "kr", + "openrouter", "glm", "kimi", "minimax", "openai", "anthropic", "gemini" +]; + +// Alias to display name mapping +const PROVIDER_ALIAS_NAMES = { + cc: "Claude Code", + ag: "Antigravity", + cx: "OpenAI Codex", + if: "iFlow AI", + qw: "Qwen Code", + gc: "Gemini CLI", + gh: "GitHub Copilot", + kr: "Kiro AI", + openrouter: "OpenRouter", + glm: "GLM Coding", + kimi: "Kimi Coding", + minimax: "Minimax Coding", + openai: "OpenAI", + anthropic: "Anthropic", + gemini: "Gemini" +}; + +/** + * Get all available models grouped by provider + combos + * @returns {Promise<{combos: Array, groups: Object}>} + */ +async function getAvailableModelsGrouped() { + const result = await api.getAvailableModels(); + if (!result.success) return { combos: [], groups: {} }; + + const models = result.data?.data || []; + const combos = []; + const groups = {}; + + models.forEach(m => { + if (m.owned_by === "combo") { + combos.push(m.id); + } else { + const provider = m.owned_by; + if (!groups[provider]) { + groups[provider] = []; + } + groups[provider].push(m.id); + } + }); + + return { combos, groups }; +} + +/** + * Display model list and prompt for selection + * @param {string} title - Title to display + * @param {string} currentValue - Current selected value (optional) + * @param {Object} options - { excludeCombos?: boolean } + * @returns {Promise} Selected model ID or null if cancelled + */ +async function selectModelFromList(title, currentValue = "", options = {}) { + const { excludeCombos = false } = options; + const { combos: rawCombos, groups } = await getAvailableModelsGrouped(); + const combos = excludeCombos ? [] : rawCombos; + + const totalModels = combos.length + Object.values(groups).flat().length; + if (totalModels === 0) { + return null; + } + + // Build flat list for selection + const allModels = []; + + // Display + clearScreen(); + console.log(`\n🎯 ${title}`); + console.log("=".repeat(50)); + if (currentValue) { + console.log(`Current: ${currentValue}\n`); + } else { + console.log(); + } + + let idx = 1; + + // Combos first (skipped when excludeCombos is true) + if (combos.length > 0) { + console.log("[Combos]"); + combos.forEach(combo => { + console.log(` ${idx}. ${combo}`); + allModels.push(combo); + idx++; + }); + console.log(); + } + + // Provider groups in order (by alias) + const sortedProviders = Object.keys(groups).sort((a, b) => { + const idxA = PROVIDER_ALIAS_ORDER.indexOf(a); + const idxB = PROVIDER_ALIAS_ORDER.indexOf(b); + return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB); + }); + + sortedProviders.forEach(provider => { + const providerName = PROVIDER_ALIAS_NAMES[provider] || provider; + console.log(`[${providerName}]`); + groups[provider].forEach(model => { + console.log(` ${idx}. ${model}`); + allModels.push(model); + idx++; + }); + console.log(); + }); + + console.log(" 0. Cancel\n"); + + // Prompt for number input + const input = await prompt("Enter number: "); + const num = parseInt(input, 10); + + if (isNaN(num) || num === 0 || num < 0 || num > allModels.length) { + return null; + } + + return allModels[num - 1]; +} + +module.exports = { + selectModelFromList, + getAvailableModelsGrouped, + PROVIDER_ALIAS_ORDER, + PROVIDER_ALIAS_NAMES +}; diff --git a/custom-server.js b/custom-server.js new file mode 100644 index 0000000..6e39683 --- /dev/null +++ b/custom-server.js @@ -0,0 +1,32 @@ +const http = require("http"); + +const origCreate = http.createServer.bind(http); + +// Wrap Next standalone HTTP server: derive client IP from the TCP socket +// (unspoofable) and strip client-supplied forwarding headers so downstream +// rate-limiting keys on the real peer address instead of attacker-controlled XFF. +http.createServer = (...args) => { + const handler = args.find((a) => typeof a === "function"); + const rest = args.filter((a) => typeof a !== "function"); + if (!handler) return origCreate(...args); + const wrapped = (req, res) => { + const socketIp = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : ""; + const xff = req.headers["x-forwarded-for"]; + const xRealIp = req.headers["x-real-ip"]; + const viaProxy = !!(xff || xRealIp); + const isLoopbackProxy = socketIp === "127.0.0.1" || socketIp === "::1" || socketIp === "::ffff:127.0.0.1"; + // Trust forwarding headers only when the TCP peer is a local reverse proxy. + // Direct/public sockets remain keyed by the unspoofable peer address. + const proxyIp = xRealIp || (xff ? String(xff).split(",")[0].trim() : ""); + const ip = isLoopbackProxy && proxyIp ? proxyIp : socketIp; + delete req.headers["x-9r-real-ip"]; + delete req.headers["x-forwarded-for"]; + delete req.headers["x-9r-via-proxy"]; + req.headers["x-9r-real-ip"] = ip; + if (viaProxy) req.headers["x-9r-via-proxy"] = "1"; + return handler(req, res); + }; + return origCreate(...rest, wrapped); +}; + +require("./server.js"); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8331b26 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + 9router: + image: decolua/9router:latest + container_name: 9router + restart: always + ports: + - "20128:20128" + volumes: + - 9router-data:/app/data + env_file: + - .env + environment: + DATA_DIR: /app/data + PORT: "20128" + HOSTNAME: "0.0.0.0" + NODE_ENV: production + HEADROOM_URL: http://headroom:8787 + depends_on: + - headroom + + headroom: + image: ghcr.io/chopratejas/headroom:latest + container_name: headroom + restart: always + ports: + - "8787:8787" + +volumes: + 9router-data: + name: 9router-data diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..548c319 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,557 @@ +# 9Router Architecture + +_Last updated: 2026-02-06_ + +## Executive Summary + +9Router is a local AI routing gateway and dashboard built on Next.js. +It provides a single OpenAI-compatible endpoint (`/v1/*`) and routes traffic across multiple upstream providers with translation, fallback, token refresh, and usage tracking. + +Core capabilities: + +- OpenAI-compatible API surface for CLI/tools +- Request/response translation across provider formats +- Model combo fallback (multi-model sequence) +- Account-level fallback (multi-account per provider) +- OAuth + API-key provider connection management +- Local persistence for providers, keys, aliases, combos, settings, pricing +- Usage/cost tracking and request logging +- Optional cloud sync for multi-device/state sync + +Primary runtime model: + +- Next.js app routes under `src/app/api/*` implement both dashboard APIs and compatibility APIs +- A shared SSE/routing core in `src/sse/*` + `open-sse/*` handles provider execution, translation, streaming, fallback, and usage + +## Scope and Boundaries + +### In Scope + +- Local gateway runtime +- Dashboard management APIs +- Provider authentication and token refresh +- Request translation and SSE streaming +- Local state + usage persistence +- Optional cloud sync orchestration + +### Out of Scope + +- Cloud service implementation behind `NEXT_PUBLIC_CLOUD_URL` +- Provider SLA/control plane outside local process +- External CLI binaries themselves (Claude CLI, Codex CLI, etc.) + +## High-Level System Context + +```mermaid +flowchart LR + subgraph Clients[Developer Clients] + C1[Claude Code] + C2[Codex CLI] + C3[OpenClaw / Droid / Cline / Continue / Roo] + C4[Custom OpenAI-compatible clients] + BROWSER[Browser Dashboard] + end + + subgraph Router[9Router Local Process] + API[V1 Compatibility API\n/v1/*] + DASH[Dashboard + Management API\n/api/*] + CORE[SSE + Translation Core\nopen-sse + src/sse] + DB[(db.json)] + UDB[(usage.json + log.txt)] + end + + subgraph Upstreams[Upstream Providers] + P1[OAuth Providers\nClaude/Codex/Gemini/Qwen/iFlow/GitHub/Kiro/Cursor/Antigravity] + P2[API Key Providers\nOpenAI/Anthropic/OpenRouter/GLM/Kimi/MiniMax] + P3[Compatible Nodes\nOpenAI-compatible / Anthropic-compatible] + end + + subgraph Cloud[Optional Cloud Sync] + CLOUD[Cloud Sync Endpoint\nNEXT_PUBLIC_CLOUD_URL] + end + + C1 --> API + C2 --> API + C3 --> API + C4 --> API + BROWSER --> DASH + + API --> CORE + DASH --> DB + CORE --> DB + CORE --> UDB + + CORE --> P1 + CORE --> P2 + CORE --> P3 + + DASH --> CLOUD +``` + +## Core Runtime Components + +## 1) API and Routing Layer (Next.js App Routes) + +Main directories: + +- `src/app/api/v1/*` and `src/app/api/v1beta/*` for compatibility APIs +- `src/app/api/*` for management/configuration APIs +- Next rewrites in `next.config.mjs` map `/v1/*` to `/api/v1/*` + +Important compatibility routes: + +- `src/app/api/v1/chat/completions/route.js` +- `src/app/api/v1/messages/route.js` +- `src/app/api/v1/responses/route.js` +- `src/app/api/v1/models/route.js` +- `src/app/api/v1/messages/count_tokens/route.js` +- `src/app/api/v1beta/models/route.js` +- `src/app/api/v1beta/models/[...path]/route.js` + +Management domains: + +- Auth/settings: `src/app/api/auth/*`, `src/app/api/settings/*` +- Providers/connections: `src/app/api/providers*` +- Provider nodes: `src/app/api/provider-nodes*` +- OAuth: `src/app/api/oauth/*` +- Keys/aliases/combos/pricing: `src/app/api/keys*`, `src/app/api/models/alias`, `src/app/api/combos*`, `src/app/api/pricing` +- Usage: `src/app/api/usage/*` +- Sync/cloud: `src/app/api/sync/*`, `src/app/api/cloud/*` +- CLI tooling helpers: `src/app/api/cli-tools/*` + +## 2) SSE + Translation Core + +Main flow modules: + +- Entry: `src/sse/handlers/chat.js` +- Core orchestration: `open-sse/handlers/chatCore.js` +- Provider execution adapters: `open-sse/executors/*` +- Format detection/provider config: `open-sse/services/provider.js` +- Model parse/resolve: `src/sse/services/model.js`, `open-sse/services/model.js` +- Account fallback logic: `open-sse/services/accountFallback.js` +- Translation registry: `open-sse/translator/index.js` +- Stream transformations: `open-sse/utils/stream.js`, `open-sse/utils/streamHandler.js` +- Usage extraction/normalization: `open-sse/utils/usageTracking.js` + +## 3) Persistence Layer + +Primary state DB: + +- `src/lib/localDb.js` +- file: `${DATA_DIR}/db.json` (or `~/.9router/db.json` when `DATA_DIR` is unset) +- entities: providerConnections, providerNodes, modelAliases, combos, apiKeys, settings, pricing + +Usage DB: + +- `src/lib/usageDb.js` +- files: `~/.9router/usage.json`, `~/.9router/log.txt` +- note: currently independent from `DATA_DIR` + +## 4) Auth + Security Surfaces + +- Dashboard cookie auth: `src/proxy.js`, `src/app/api/auth/login/route.js` +- API key generation/verification: `src/shared/utils/apiKey.js` +- Provider secrets persisted in `providerConnections` entries +- Optional proxy support for upstream calls via env proxy variables (`open-sse/utils/proxyFetch.js`) + +## 5) Cloud Sync + +- Scheduler init: `src/lib/initCloudSync.js`, `src/shared/services/initializeCloudSync.js` +- Periodic task: `src/shared/services/cloudSyncScheduler.js` +- Control route: `src/app/api/sync/cloud/route.js` + +## Request Lifecycle (`/v1/chat/completions`) + +```mermaid +sequenceDiagram + autonumber + participant Client as CLI/SDK Client + participant Route as /api/v1/chat/completions + participant Chat as src/sse/handlers/chat + participant Core as open-sse/handlers/chatCore + participant Model as Model Resolver + participant Auth as Credential Selector + participant Exec as Provider Executor + participant Prov as Upstream Provider + participant Stream as Stream Translator + participant Usage as usageDb + + Client->>Route: POST /v1/chat/completions + Route->>Chat: handleChat(request) + Chat->>Model: parse/resolve model or combo + + alt Combo model + Chat->>Chat: iterate combo models (handleComboChat) + end + + Chat->>Auth: getProviderCredentials(provider) + Auth-->>Chat: active account + tokens/api key + + Chat->>Core: handleChatCore(body, modelInfo, credentials) + Core->>Core: detect source format + Core->>Core: translate request to target format + Core->>Exec: execute(provider, transformedBody) + Exec->>Prov: upstream API call + Prov-->>Exec: SSE/JSON response + Exec-->>Core: response + metadata + + alt 401/403 + Core->>Exec: refreshCredentials() + Exec-->>Core: updated tokens + Core->>Exec: retry request + end + + Core->>Stream: translate/normalize stream to client format + Stream-->>Client: SSE chunks / JSON response + + Stream->>Usage: extract usage + persist history/log +``` + +## Combo + Account Fallback Flow + +```mermaid +flowchart TD + A[Incoming model string] --> B{Is combo name?} + B -- Yes --> C[Load combo models sequence] + B -- No --> D[Single model path] + + C --> E[Try model N] + E --> F[Resolve provider/model] + D --> F + + F --> G[Select account credentials] + G --> H{Credentials available?} + H -- No --> I[Return provider unavailable] + H -- Yes --> J[Execute request] + + J --> K{Success?} + K -- Yes --> L[Return response] + K -- No --> M{Fallback-eligible error?} + + M -- No --> N[Return error] + M -- Yes --> O[Mark account unavailable cooldown] + O --> P{Another account for provider?} + P -- Yes --> G + P -- No --> Q{In combo with next model?} + Q -- Yes --> E + Q -- No --> R[Return all unavailable] +``` + +Fallback decisions are driven by `open-sse/services/accountFallback.js` using status codes and error-message heuristics. + +## OAuth Onboarding and Token Refresh Lifecycle + +```mermaid +sequenceDiagram + autonumber + participant UI as Dashboard UI + participant OAuth as /api/oauth/[provider]/[action] + participant ProvAuth as Provider Auth Server + participant DB as localDb + participant Test as /api/providers/[id]/test + participant Exec as Provider Executor + + UI->>OAuth: GET authorize or device-code + OAuth->>ProvAuth: create auth/device flow + ProvAuth-->>OAuth: auth URL or device code payload + OAuth-->>UI: flow data + + UI->>OAuth: POST exchange or poll + OAuth->>ProvAuth: token exchange/poll + ProvAuth-->>OAuth: access/refresh tokens + OAuth->>DB: createProviderConnection(oauth data) + OAuth-->>UI: success + connection id + + UI->>Test: POST /api/providers/[id]/test + Test->>Exec: validate credentials / optional refresh + Exec-->>Test: valid or refreshed token info + Test->>DB: update status/tokens/errors + Test-->>UI: validation result +``` + +Refresh during live traffic is executed inside `open-sse/handlers/chatCore.js` via executor `refreshCredentials()`. + +## Cloud Sync Lifecycle (Enable / Sync / Disable) + +```mermaid +sequenceDiagram + autonumber + participant UI as Endpoint Page UI + participant Sync as /api/sync/cloud + participant DB as localDb + participant Cloud as External Cloud Sync + participant Claude as ~/.claude/settings.json + + UI->>Sync: POST action=enable + Sync->>DB: set cloudEnabled=true + Sync->>DB: ensure API key exists + Sync->>Cloud: POST /sync/{machineId} (providers/aliases/combos/keys) + Cloud-->>Sync: sync result + Sync->>Cloud: GET /{machineId}/v1/verify + Sync-->>UI: enabled + verification status + + UI->>Sync: POST action=sync + Sync->>Cloud: POST /sync/{machineId} + Cloud-->>Sync: remote data + Sync->>DB: update newer local tokens/status + Sync-->>UI: synced + + UI->>Sync: POST action=disable + Sync->>DB: set cloudEnabled=false + Sync->>Cloud: DELETE /sync/{machineId} + Sync->>Claude: switch ANTHROPIC_BASE_URL back to local (if needed) + Sync-->>UI: disabled +``` + +Periodic sync is triggered by `CloudSyncScheduler` when cloud is enabled. + +## Data Model and Storage Map + +```mermaid +erDiagram + SETTINGS ||--o{ PROVIDER_CONNECTION : controls + PROVIDER_NODE ||--o{ PROVIDER_CONNECTION : backs_compatible_provider + PROVIDER_CONNECTION ||--o{ USAGE_ENTRY : emits_usage + + SETTINGS { + boolean cloudEnabled + number stickyRoundRobinLimit + boolean requireLogin + string password_hash + } + + PROVIDER_CONNECTION { + string id + string provider + string authType + string name + number priority + boolean isActive + string apiKey + string accessToken + string refreshToken + string expiresAt + string testStatus + string lastError + string rateLimitedUntil + json providerSpecificData + } + + PROVIDER_NODE { + string id + string type + string name + string prefix + string apiType + string baseUrl + } + + MODEL_ALIAS { + string alias + string targetModel + } + + COMBO { + string id + string name + string[] models + } + + API_KEY { + string id + string name + string key + string machineId + boolean isActive + } + + USAGE_ENTRY { + string provider + string model + number prompt_tokens + number completion_tokens + string connectionId + string timestamp + } +``` + +Physical storage files: + +- main state: `${DATA_DIR}/db.json` (or `~/.9router/db.json`) +- usage stats: `~/.9router/usage.json` +- request log lines: `~/.9router/log.txt` +- optional translator/request debug sessions: `/logs/...` + +## Deployment Topology + +```mermaid +flowchart LR + subgraph LocalHost[Developer Host] + CLI[CLI Tools] + Browser[Dashboard Browser] + end + + subgraph ContainerOrProcess[9Router Runtime] + Next[Next.js Server\nPORT=20128] + Core[SSE Core + Executors] + MainDB[(db.json)] + UsageDB[(usage.json/log.txt)] + end + + subgraph External[External Services] + Providers[AI Providers] + SyncCloud[Cloud Sync Service] + end + + CLI --> Next + Browser --> Next + Next --> Core + Next --> MainDB + Core --> MainDB + Core --> UsageDB + Core --> Providers + Next --> SyncCloud +``` + +## Module Mapping (Decision-Critical) + +### Route and API Modules + +- `src/app/api/v1/*`, `src/app/api/v1beta/*`: compatibility APIs +- `src/app/api/providers*`: provider CRUD, validation, testing +- `src/app/api/provider-nodes*`: custom compatible node management +- `src/app/api/oauth/*`: OAuth/device-code flows +- `src/app/api/keys*`: local API key lifecycle +- `src/app/api/models/alias`: alias management +- `src/app/api/combos*`: fallback combo management +- `src/app/api/pricing`: pricing overrides for cost calculation +- `src/app/api/usage/*`: usage and logs APIs +- `src/app/api/sync/*` + `src/app/api/cloud/*`: cloud sync and cloud-facing helpers +- `src/app/api/cli-tools/*`: local CLI config writers/checkers + +### Routing and Execution Core + +- `src/sse/handlers/chat.js`: request parse, combo handling, account selection loop +- `open-sse/handlers/chatCore.js`: translation, executor dispatch, retry/refresh handling, stream setup +- `open-sse/executors/*`: provider-specific network and format behavior + +### Translation Registry and Format Converters + +- `open-sse/translator/index.js`: translator registry and orchestration +- Request translators: `open-sse/translator/request/*` +- Response translators: `open-sse/translator/response/*` +- Format constants: `open-sse/translator/formats.js` + +### Persistence + +- `src/lib/localDb.js`: persistent config/state +- `src/lib/usageDb.js`: usage history and rolling request logs + +## Provider Executor Coverage + +Specialized executors: + +- `antigravity` +- `gemini-cli` +- `github` +- `kiro` +- `codex` +- `cursor` + +Default executor path: + +- all other providers (including compatible node providers) use `open-sse/executors/default.js` + +## Format Translation Coverage + +Detected source formats include: + +- `openai` +- `openai-responses` +- `claude` +- `gemini` + +Target formats include: + +- OpenAI chat/Responses +- Claude +- Gemini/Gemini-CLI/Antigravity envelope +- Kiro +- Cursor + +Translations are selected dynamically based on source payload shape and provider target format. + +## Failure Modes and Resilience + +## 1) Account/Provider Availability + +- provider account cooldown on transient/rate/auth errors +- account fallback before failing request +- combo model fallback when current model/provider path is exhausted + +## 2) Token Expiry + +- pre-check and refresh with retry for refreshable providers +- 401/403 retry after refresh attempt in core path + +## 3) Stream Safety + +- disconnect-aware stream controller +- translation stream with end-of-stream flush and `[DONE]` handling +- usage estimation fallback when provider usage metadata is missing + +## 4) Cloud Sync Degradation + +- sync errors are surfaced but local runtime continues +- scheduler has retry-capable logic, but periodic execution currently calls single-attempt sync by default + +## 5) Data Integrity + +- DB shape migration/repair for missing keys +- corrupt JSON reset safeguards for localDb and usageDb + +## Observability and Operational Signals + +Runtime visibility sources: + +- console logs from `src/sse/utils/logger.js` +- per-request usage aggregates in `usage.json` +- textual request status log in `log.txt` +- optional deep request/translation logs under `logs/` when `ENABLE_REQUEST_LOGS=true` +- dashboard usage endpoints (`/api/usage/*`) for UI consumption + +## Security-Sensitive Boundaries + +- JWT secret (`JWT_SECRET`) secures dashboard session cookie verification/signing +- Initial password fallback (`INITIAL_PASSWORD`, default `123456`) must be overridden in real deployments +- API key HMAC secret (`API_KEY_SECRET`) secures generated local API key format +- Provider secrets (API keys/tokens) are persisted in local DB and should be protected at filesystem level +- Cloud sync endpoints rely on API key auth + machine id semantics + +## Environment and Runtime Matrix + +Environment variables actively used by code: + +- App/auth: `JWT_SECRET`, `INITIAL_PASSWORD` +- Storage: `DATA_DIR` +- Security hashing: `API_KEY_SECRET`, `MACHINE_ID_SALT` +- Logging: `ENABLE_REQUEST_LOGS` +- Sync/cloud URLing: `NEXT_PUBLIC_BASE_URL`, `NEXT_PUBLIC_CLOUD_URL` +- Outbound proxy: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` and lowercase variants +- Platform/runtime helpers (not app-specific config): `APPDATA`, `NODE_ENV`, `PORT`, `HOSTNAME` + +## Known Architectural Notes + +1. `usageDb` currently stores under `~/.9router` and does not follow `DATA_DIR`. +2. `/api/v1/route.js` returns a static model list and is not the main models source used by `/v1/models`. +3. Request logger writes full headers/body when enabled; treat log directory as sensitive. +4. Cloud behavior depends on correct `NEXT_PUBLIC_BASE_URL` and cloud endpoint reachability. + +## Operational Verification Checklist + +- Build from source: `cd /root/dev/9router && npm run build` +- Build Docker image: `cd /root/dev/9router && docker build -t 9router .` +- Start service and verify: +- `GET /api/settings` +- `GET /api/v1/models` +- CLI target base URL should be `http://:20128/v1` when `PORT=20128` diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..f443835 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,16 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; + +const eslintConfig = defineConfig([ + ...nextVitals, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/gitbook/.gitignore b/gitbook/.gitignore new file mode 100644 index 0000000..d972524 --- /dev/null +++ b/gitbook/.gitignore @@ -0,0 +1,44 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Testing +coverage/ + +# Next.js +.next/ +out/ +build/ +dist/ + +# Production +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Misc +*.tsbuildinfo + +# Wrangler +.wrangler/ +.dev.vars diff --git a/gitbook/app/[lang]/[...slug]/page.js b/gitbook/app/[lang]/[...slug]/page.js new file mode 100644 index 0000000..792856d --- /dev/null +++ b/gitbook/app/[lang]/[...slug]/page.js @@ -0,0 +1,36 @@ +import DocsLayout from "@/components/DocsLayout"; +import DocsContent from "@/components/DocsContent"; +import { extractHeadings } from "@/utils/markdown"; +import { loadContent, getAllSlugs } from "@/lib/content"; +import { LANG_CODES, isValidLang, DEFAULT_LANG } from "@/constants/languages"; +import { notFound } from "next/navigation"; + +export const dynamicParams = false; + +export async function generateStaticParams() { + // Build params for every (lang × slug) combination based on default language slugs. + const slugs = getAllSlugs(DEFAULT_LANG); + const params = []; + for (const lang of LANG_CODES) { + for (const slug of slugs) { + params.push({ lang, slug }); + } + } + return params; +} + +export default async function DocPage({ params }) { + const { lang, slug } = await params; + if (!isValidLang(lang)) notFound(); + + const content = loadContent(lang, slug); + if (!content) notFound(); + + const headings = extractHeadings(content); + + return ( + + + + ); +} diff --git a/gitbook/app/[lang]/page.js b/gitbook/app/[lang]/page.js new file mode 100644 index 0000000..4da007c --- /dev/null +++ b/gitbook/app/[lang]/page.js @@ -0,0 +1,26 @@ +import DocsLayout from "@/components/DocsLayout"; +import DocsContent from "@/components/DocsContent"; +import { extractHeadings } from "@/utils/markdown"; +import { loadContent } from "@/lib/content"; +import { LANG_CODES, isValidLang } from "@/constants/languages"; +import { notFound } from "next/navigation"; + +export const dynamicParams = false; + +export async function generateStaticParams() { + return LANG_CODES.map(lang => ({ lang })); +} + +export default async function LangHomePage({ params }) { + const { lang } = await params; + if (!isValidLang(lang)) notFound(); + + const content = loadContent(lang, "index") || "# 9Router Documentation\n\nContent coming soon..."; + const headings = extractHeadings(content); + + return ( + + + + ); +} diff --git a/gitbook/app/globals.css b/gitbook/app/globals.css new file mode 100644 index 0000000..8ac76c1 --- /dev/null +++ b/gitbook/app/globals.css @@ -0,0 +1,190 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap'); +@import "tailwindcss"; + +/* Base styles */ +* { + box-sizing: border-box; +} + +body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + margin: 0; + padding: 0; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #CBD5E1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #E68A6E; +} + +/* Code highlighting */ +pre { + background: #F1F5F9 !important; + border-radius: 8px; + padding: 1rem; + overflow-x: auto; +} + +code { + font-family: 'Monaco', 'Menlo', 'Courier New', monospace; + font-size: 0.875rem; + line-height: 1.5; +} + +/* Inline code */ +:not(pre) > code { + background: #F1F5F9; + color: #E68A6E; + padding: 0.125rem 0.375rem; + border-radius: 4px; + font-size: 0.875em; +} + +/* Markdown content styles */ +.markdown-content h1 { + font-size: 2.5rem; + font-weight: 800; + color: #E68A6E; + /* margin-top: 2rem; */ + margin-bottom: 1rem; + line-height: 1.2; + display: flex; + align-items: center; +} + +.markdown-content h1 svg { + width: 2.5rem; + height: 2.5rem; + flex-shrink: 0; +} + +.markdown-content h2 { + font-size: 2rem; + font-weight: 700; + color: #000000; + margin-top: 2rem; + margin-bottom: 1rem; + line-height: 1.3; + border-bottom: 1px solid #E5E7EB; + padding-bottom: 0.5rem; +} + +.markdown-content h3 { + font-size: 1.5rem; + font-weight: 600; + color: #000000; + margin-top: 1.5rem; + margin-bottom: 0.75rem; + line-height: 1.4; +} + +.markdown-content p { + font-size: 1.125rem; + line-height: 1.75; + margin-bottom: 1rem; + color: #6B7280; +} + +.markdown-content ul, +.markdown-content ol { + margin-left: 1.5rem; + margin-bottom: 1rem; +} + +.markdown-content li { + font-size: 1.125rem; + line-height: 1.75; + margin-bottom: 0.5rem; + color: #6B7280; +} + +.markdown-content a { + color: #E68A6E; + text-decoration: underline; + transition: opacity 0.2s; +} + +.markdown-content a:hover { + opacity: 0.8; +} + +.markdown-content blockquote { + border-left: 4px solid #E68A6E; + padding-left: 1rem; + margin: 1rem 0; + font-style: italic; + color: #6B7280; +} + +.markdown-content strong { + font-weight: 600; + color: #000000; +} + +/* Smooth scroll */ +html { + scroll-behavior: smooth; + scroll-padding-top: 5rem; /* Offset for sticky header (64px + padding) */ +} + +/* Heading anchor offset for sticky header */ +.markdown-content h1, +.markdown-content h2, +.markdown-content h3 { + scroll-margin-top: 5rem; +} + +/* Mobile menu overlay */ +.mobile-menu-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 40; + animation: fadeIn 0.2s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.mobile-menu-drawer { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 280px; + background: white; + z-index: 50; + animation: slideInLeft 0.3s ease-out; + overflow-y: auto; +} + +@keyframes slideInLeft { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} diff --git a/gitbook/app/layout.js b/gitbook/app/layout.js new file mode 100644 index 0000000..aa49925 --- /dev/null +++ b/gitbook/app/layout.js @@ -0,0 +1,21 @@ +import { DOCS_CONFIG } from "@/constants/docsConfig"; +import "./globals.css"; + +export const metadata = { + title: DOCS_CONFIG.title, + description: DOCS_CONFIG.description, +}; + +export default function RootLayout({ children }) { + return ( + + + + + + + {children} + + + ); +} diff --git a/gitbook/app/page.js b/gitbook/app/page.js new file mode 100644 index 0000000..71df826 --- /dev/null +++ b/gitbook/app/page.js @@ -0,0 +1,26 @@ +import { DEFAULT_LANG } from "@/constants/languages"; + +// Static-friendly redirect to default language (meta refresh + client script) +export const metadata = { + title: "Redirecting...", + other: { + "http-equiv:refresh": `0; url=/${DEFAULT_LANG}/` + } +}; + +export default function HomePage() { + const target = `/${DEFAULT_LANG}/`; + return ( + <> + + +`); + + // Call callback with params + onCallback(params); + } else { + res.writeHead(404); + res.end("Not found"); + } + }); + + // Listen on fixed port or find available port + const portToUse = fixedPort || 0; + server.listen(portToUse, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ + server, + port, + close: () => server.close(), + }); + }); + + server.on("error", (err) => { + if (err.code === "EADDRINUSE" && fixedPort) { + reject(new Error(`Port ${fixedPort} is already in use. Please close other applications using this port.`)); + } else { + reject(err); + } + }); + }); +} + +/** + * Wait for callback with timeout + * @param {number} timeoutMs - Timeout in milliseconds + * @returns {Promise} - Callback params + */ +export function waitForCallback(timeoutMs = 300000) { + return new Promise((resolve, reject) => { + let resolved = false; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + reject(new Error("Authentication timeout")); + } + }, timeoutMs); + + const onCallback = (params) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + resolve(params); + } + }; + + // Return the callback function + resolve.__onCallback = onCallback; + }); +} + +// Singleton proxy server for Codex OAuth callback on fixed port +let codexProxyServer = null; +let codexProxyTimeout = null; + +const CODEX_PROXY_TIMEOUT_MS = 300000; // 5 minutes +const CODEX_PORT = CODEX_CONFIG.fixedPort; + +// Pending exchange sessions keyed by state — used by server-side exchange mode +const pendingExchanges = new Map(); + +/** + * Register a pending exchange session for server-side mode. + * Modal client calls this before opening popup. + */ +export function registerCodexSession({ state, codeVerifier, redirectUri }) { + if (!state || !codeVerifier || !redirectUri) return false; + pendingExchanges.set(state, { + codeVerifier, + redirectUri, + status: "pending", + createdAt: Date.now(), + }); + return true; +} + +/** + * Read session status (modal polls this). + */ +export function getCodexSessionStatus(state) { + return pendingExchanges.get(state) || null; +} + +/** + * Clear a session (called after modal consumes status). + */ +export function clearCodexSession(state) { + pendingExchanges.delete(state); +} + +function escapeHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function renderCodexResultPage(success, message) { + const color = success ? "#22c55e" : "#ef4444"; + const icon = success ? "✓" : "✗"; + const title = success ? "Authentication Successful" : "Authentication Failed"; + const safeMessage = escapeHtml(message); + return ` +${title} + +
${icon}

${title}

${safeMessage}

Closing in 3s...

+ +
`; +} + +/** + * Start Codex proxy on fixed port 1455. + * Mode A (server-side): if any session was registered, proxy auto-exchanges + saves DB. + * Mode B (channel fallback): if no session, proxy 302 redirects to app port for legacy channel-based flow. + */ +export function startCodexProxy(appPort) { + return new Promise((resolve) => { + if (codexProxyServer) { + resolve({ success: true }); + return; + } + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + + if (url.pathname !== "/callback" && url.pathname !== "/auth/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const errorParam = url.searchParams.get("error"); + const session = state ? pendingExchanges.get(state) : null; + + // Mode A: server-side exchange (session registered) + if (session) { + try { + if (errorParam) { + throw new Error(url.searchParams.get("error_description") || errorParam); + } + if (!code) throw new Error("No authorization code received"); + + // Lazy import to avoid circular deps + const { exchangeTokens } = await import("../providers.js"); + const { createProviderConnection } = await import("@/models"); + + const tokenData = await exchangeTokens( + "codex", + code, + session.redirectUri, + session.codeVerifier, + state + ); + const connection = await createProviderConnection({ + provider: "codex", + authType: "oauth", + ...tokenData, + expiresAt: tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + + session.status = "done"; + session.connectionId = connection.id; + session.email = connection.email; + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(true, "You can close this window.")); + } catch (err) { + session.status = "error"; + session.error = err.message; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, err.message)); + } finally { + stopCodexProxy(); + } + return; + } + + // Mode B: legacy channel fallback — 302 redirect to app /callback + const redirectUrl = `http://localhost:${appPort}/callback${url.search}`; + res.writeHead(302, { Location: redirectUrl }); + res.end(); + stopCodexProxy(); + }); + + server.listen(CODEX_PORT, "127.0.0.1", () => { + codexProxyServer = server; + codexProxyTimeout = setTimeout(() => stopCodexProxy(), CODEX_PROXY_TIMEOUT_MS); + resolve({ success: true }); + }); + + server.on("error", (err) => { + if (err.code === "EADDRINUSE") { + resolve({ success: false, reason: "port_busy" }); + } else { + resolve({ success: false, reason: err.message }); + } + }); + }); +} + +/** + * Stop the Codex proxy server and cleanup + */ +export function stopCodexProxy() { + if (codexProxyTimeout) { + clearTimeout(codexProxyTimeout); + codexProxyTimeout = null; + } + if (codexProxyServer) { + codexProxyServer.close(); + codexProxyServer = null; + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// xAI fixed-port proxy on 127.0.0.1:56121 +// Same shape as the Codex proxy. Kept as a parallel implementation rather than +// generalizing the Codex one to keep the codex hot-path byte-equivalent. +// ─────────────────────────────────────────────────────────────────────────── + +let xaiProxyServer = null; +let xaiProxyTimeout = null; +const XAI_PROXY_TIMEOUT_MS = 300000; // 5 minutes +const XAI_PROXY_PORT = 56121; +const xaiPendingExchanges = new Map(); + +export function registerXaiSession({ state, codeVerifier, redirectUri }) { + if (!state || !codeVerifier || !redirectUri) return false; + xaiPendingExchanges.set(state, { + codeVerifier, + redirectUri, + status: "pending", + createdAt: Date.now(), + }); + return true; +} + +export function getXaiSessionStatus(state) { + return xaiPendingExchanges.get(state) || null; +} + +export function clearXaiSession(state) { + xaiPendingExchanges.delete(state); +} + +function renderXaiResultPage(success, message) { + return renderCodexResultPage(success, message); +} + +/** + * Start xAI proxy on fixed port 56121. + * Mode A (server-side): if any session was registered, proxy auto-exchanges + saves DB. + * Mode B (channel fallback): if no session, proxy 302 redirects to app port. + */ +export function startXaiProxy(appPort) { + return new Promise((resolve) => { + if (xaiProxyServer) { + resolve({ success: true }); + return; + } + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + if (url.pathname !== "/callback" && url.pathname !== "/auth/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const errorParam = url.searchParams.get("error"); + const session = state ? xaiPendingExchanges.get(state) : null; + + // Mode A: server-side exchange + if (session) { + try { + if (errorParam) { + throw new Error(url.searchParams.get("error_description") || errorParam); + } + if (!code) throw new Error("No authorization code received"); + + const { exchangeTokens } = await import("../providers.js"); + const { createProviderConnection } = await import("@/models"); + + const tokenData = await exchangeTokens( + "xai", + code, + session.redirectUri, + session.codeVerifier, + state + ); + const connection = await createProviderConnection({ + provider: "xai", + authType: "oauth", + ...tokenData, + expiresAt: tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + + session.status = "done"; + session.connectionId = connection.id; + session.email = connection.email; + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderXaiResultPage(true, "You can close this window.")); + } catch (err) { + session.status = "error"; + session.error = err.message; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderXaiResultPage(false, err.message)); + } finally { + stopXaiProxy(); + } + return; + } + + // Mode B: legacy fallback redirect + const redirectUrl = `http://localhost:${appPort}/callback${url.search}`; + res.writeHead(302, { Location: redirectUrl }); + res.end(); + stopXaiProxy(); + }); + + server.listen(XAI_PROXY_PORT, "127.0.0.1", () => { + xaiProxyServer = server; + xaiProxyTimeout = setTimeout(() => stopXaiProxy(), XAI_PROXY_TIMEOUT_MS); + resolve({ success: true }); + }); + + server.on("error", (err) => { + if (err.code === "EADDRINUSE") { + resolve({ success: false, reason: "port_busy" }); + } else { + resolve({ success: false, reason: err.message }); + } + }); + }); +} + +export function stopXaiProxy() { + if (xaiProxyTimeout) { + clearTimeout(xaiProxyTimeout); + xaiProxyTimeout = null; + } + if (xaiProxyServer) { + xaiProxyServer.close(); + xaiProxyServer = null; + } +} + diff --git a/src/lib/oauth/utils/ui.js b/src/lib/oauth/utils/ui.js new file mode 100644 index 0000000..a81ff48 --- /dev/null +++ b/src/lib/oauth/utils/ui.js @@ -0,0 +1,48 @@ +import chalk from "chalk"; +import ora from "ora"; + +/** + * UI Helper Functions + */ + +export function success(message) { + console.log(chalk.green(`\n✓ ${message}\n`)); +} + +export function error(message) { + console.log(chalk.red(`\n✗ ${message}\n`)); +} + +export function info(message) { + console.log(chalk.blue(`\n${message}\n`)); +} + +export function warn(message) { + console.log(chalk.yellow(`\n⚠ ${message}\n`)); +} + +export function gray(message) { + console.log(chalk.gray(message)); +} + +export function spinner(text) { + return ora(text); +} + +export function printSection(title) { + console.log(chalk.blue(`\n${title}\n`)); +} + +export function printKeyValue(key, value, isSuccess = false) { + const color = isSuccess ? chalk.green : chalk.gray; + console.log(color(` ${key}: ${value}`)); +} + +export function printList(items, isSuccess = false) { + const symbol = isSuccess ? "✓" : "✗"; + const color = isSuccess ? chalk.green : chalk.gray; + items.forEach((item) => { + console.log(color(` ${symbol} ${item}`)); + }); +} + diff --git a/src/lib/providerNormalization.js b/src/lib/providerNormalization.js new file mode 100644 index 0000000..8eb2a7e --- /dev/null +++ b/src/lib/providerNormalization.js @@ -0,0 +1,45 @@ +import { AI_PROVIDERS } from "../shared/constants/providers.js"; + +/** + * Detect xAI Grok models by id pattern (grok-*, Grok_*, etc). + * @param {string} modelId + * @returns {boolean} + */ +export function isXaiModel(modelId) { + return typeof modelId === "string" && /^grok[-_]/i.test(modelId.trim()); +} + +export function normalizeProviderId(provider) { + if (typeof provider !== "string") return provider; + + const trimmed = provider.trim(); + if (AI_PROVIDERS[trimmed]) return trimmed; + + const slug = trimmed.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + if (AI_PROVIDERS[slug]) return slug; + + const providerByName = Object.values(AI_PROVIDERS).find( + (entry) => entry.name?.toLowerCase() === trimmed.toLowerCase() + ); + return providerByName?.id || trimmed; +} + +export function normalizeProviderSpecificData(provider, body = {}, providerSpecificData = null) { + const next = providerSpecificData && typeof providerSpecificData === "object" + ? { ...providerSpecificData } + : {}; + + if (provider === "ollama-local") { + const baseUrl = ( + next.baseUrl || + body.baseUrl || + body.baseURL || + body.ollamaHostUrl || + "" + ).trim(); + + if (baseUrl) next.baseUrl = baseUrl; + } + + return Object.keys(next).length > 0 ? next : null; +} diff --git a/src/lib/pxpipe/events.js b/src/lib/pxpipe/events.js new file mode 100644 index 0000000..b1bb880 --- /dev/null +++ b/src/lib/pxpipe/events.js @@ -0,0 +1,125 @@ +import fs from "fs"; +import path from "path"; +import { PXPIPE_DIR } from "./install.js"; + +const EVENTS_FILE = path.join(PXPIPE_DIR, "events.jsonl"); +const ROTATED_FILE = path.join(PXPIPE_DIR, "events.jsonl.1"); +const MAX_FILE_BYTES = 5 * 1024 * 1024; +const DAY_MS = 24 * 60 * 60 * 1000; + +function ensureDir() { + if (!fs.existsSync(PXPIPE_DIR)) fs.mkdirSync(PXPIPE_DIR, { recursive: true }); +} + +// Fire-and-forget: stats must never break the request path. +export function appendPxpipeEvent(event) { + try { + ensureDir(); + try { + const stat = fs.statSync(EVENTS_FILE); + if (stat.size > MAX_FILE_BYTES) fs.renameSync(EVENTS_FILE, ROTATED_FILE); + } catch { /* no file yet */ } + fs.appendFile(EVENTS_FILE, JSON.stringify({ ts: Date.now(), ...event }) + "\n", () => {}); + } catch { /* ignore */ } +} + +export function readPxpipeEvents({ sinceMs = null, limit = null } = {}) { + const events = []; + for (const file of [ROTATED_FILE, EVENTS_FILE]) { + try { + if (!fs.existsSync(file)) continue; + for (const line of fs.readFileSync(file, "utf8").split("\n")) { + if (!line) continue; + try { + const ev = JSON.parse(line); + if (sinceMs && ev.ts < sinceMs) continue; + events.push(ev); + } catch { /* skip corrupt line */ } + } + } catch { /* ignore */ } + } + events.sort((a, b) => a.ts - b.ts); + return limit ? events.slice(-limit) : events; +} + +function emptyTotals() { + return { + requests: 0, compressed: 0, bypassed: 0, errors: 0, + tokensBeforeEst: 0, tokensAfterEst: 0, tokensSavedEst: 0, savedPct: 0, + imagesGenerated: 0, compressionTimeMs: 0, avgCompressionMs: 0, + }; +} + +function accumulate(totals, ev) { + totals.requests++; + if (ev.applied) { + totals.compressed++; + totals.tokensBeforeEst += ev.tokensBeforeEst || 0; + totals.tokensAfterEst += ev.tokensAfterEst || 0; + totals.tokensSavedEst += ev.tokensSavedEst || 0; + totals.imagesGenerated += ev.imageCount || 0; + totals.compressionTimeMs += ev.durationMs || 0; + } else if (ev.reason === "transform_error" || ev.reason === "timeout") { + totals.errors++; + } else { + totals.bypassed++; + } +} + +function finalize(totals) { + totals.savedPct = totals.tokensBeforeEst > 0 + ? +((totals.tokensSavedEst / totals.tokensBeforeEst) * 100).toFixed(2) + : 0; + totals.avgCompressionMs = totals.compressed > 0 + ? Math.round(totals.compressionTimeMs / totals.compressed) + : 0; + return totals; +} + +// Aggregated stats for the dashboard: all-time + windowed totals, a daily +// tokens-saved timeline (last `timelineDays`), and the most recent events. +export function getPxpipeStats({ timelineDays = 30, recentLimit = 100 } = {}) { + const events = readPxpipeEvents(); + const now = Date.now(); + const startOfToday = new Date(new Date(now).setHours(0, 0, 0, 0)).getTime(); + + const windows = { + all: emptyTotals(), + today: emptyTotals(), + yesterday: emptyTotals(), + last7d: emptyTotals(), + last30d: emptyTotals(), + }; + + const timeline = new Map(); + for (let i = timelineDays - 1; i >= 0; i--) { + const day = new Date(startOfToday - i * DAY_MS); + timeline.set(day.toISOString().slice(0, 10), { date: day.toISOString().slice(0, 10), tokensSavedEst: 0, compressed: 0, requests: 0 }); + } + + for (const ev of events) { + accumulate(windows.all, ev); + if (ev.ts >= startOfToday) accumulate(windows.today, ev); + else if (ev.ts >= startOfToday - DAY_MS) accumulate(windows.yesterday, ev); + if (ev.ts >= now - 7 * DAY_MS) accumulate(windows.last7d, ev); + if (ev.ts >= now - 30 * DAY_MS) accumulate(windows.last30d, ev); + + const key = new Date(ev.ts).toISOString().slice(0, 10); + const bucket = timeline.get(key); + if (bucket) { + bucket.requests++; + if (ev.applied) { + bucket.compressed++; + bucket.tokensSavedEst += ev.tokensSavedEst || 0; + } + } + } + + for (const w of Object.values(windows)) finalize(w); + + return { + windows, + timeline: [...timeline.values()], + recent: events.slice(-recentLimit).reverse(), + }; +} diff --git a/src/lib/pxpipe/install.js b/src/lib/pxpipe/install.js new file mode 100644 index 0000000..cdf5a8c --- /dev/null +++ b/src/lib/pxpipe/install.js @@ -0,0 +1,123 @@ +import fs from "fs"; +import path from "path"; +import { spawn, execSync } from "child_process"; +import { DATA_DIR } from "@/lib/dataDir.js"; + +export const PXPIPE_DIR = path.join(DATA_DIR, "pxpipe"); +export const PXPIPE_PACKAGE = "pxpipe-proxy"; +const INSTALL_LOG = path.join(PXPIPE_DIR, "install.log"); +const INSTALL_TIMEOUT_MS = 5 * 60 * 1000; + +const IS_WIN = process.platform === "win32"; +const NPM_CMD = IS_WIN ? "npm.cmd" : "npm"; + +// Same PATH extension trick as headroom/detect.js: packaged/launchd environments +// often miss the Node bin dirs. +const EXTRA_BINS = IS_WIN + ? [`${process.env.ProgramFiles || ""}\\nodejs`, `${process.env.APPDATA || ""}\\npm`] + : ["/usr/local/bin", "/opt/homebrew/bin", `${process.env.HOME || ""}/.local/bin`, "/usr/bin", "/bin"]; +const EXTENDED_PATH = [...EXTRA_BINS, process.env.PATH || ""].filter(Boolean).join(path.delimiter); + +let installInFlight = null; + +function ensureDir() { + if (!fs.existsSync(PXPIPE_DIR)) fs.mkdirSync(PXPIPE_DIR, { recursive: true }); +} + +export function packageRoot() { + return path.join(PXPIPE_DIR, "node_modules", PXPIPE_PACKAGE); +} + +export function libraryEntry() { + return path.join(packageRoot(), "dist", "core", "library.js"); +} + +export function findNpm() { + try { + const out = execSync(`${IS_WIN ? "where" : "which"} npm`, { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH }, + }).toString().trim(); + return out ? out.split(/\r?\n/)[0].trim() : null; + } catch { + return null; + } +} + +// { installed, version, path } — installed means the library entry exists on disk. +export function getInstallInfo() { + try { + const pkgJson = path.join(packageRoot(), "package.json"); + if (!fs.existsSync(pkgJson) || !fs.existsSync(libraryEntry())) { + return { installed: false, version: null, path: null }; + } + const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf8")); + return { installed: true, version: pkg.version || null, path: packageRoot() }; + } catch { + return { installed: false, version: null, path: null }; + } +} + +export function isInstalling() { + return installInFlight !== null; +} + +// Install (or repair by reinstalling) pxpipe-proxy into DATA_DIR/pxpipe. +// Serialized: concurrent calls await the same run. +export function installPxpipe() { + if (installInFlight) return installInFlight; + installInFlight = runInstall().finally(() => { installInFlight = null; }); + return installInFlight; +} + +async function runInstall() { + const npm = findNpm(); + if (!npm) { + const err = new Error("npm not found on PATH — Node.js/npm is required to install PXPIPE"); + err.code = "NPM_NOT_FOUND"; + throw err; + } + + ensureDir(); + const pkgJson = path.join(PXPIPE_DIR, "package.json"); + if (!fs.existsSync(pkgJson)) { + fs.writeFileSync(pkgJson, JSON.stringify({ name: "9router-pxpipe-host", private: true }, null, 2)); + } + + const outFd = fs.openSync(INSTALL_LOG, "a"); + fs.writeSync(outFd, `\n[${new Date().toISOString()}] npm install ${PXPIPE_PACKAGE}@latest\n`); + + await new Promise((resolve, reject) => { + const child = spawn(npm, ["install", `${PXPIPE_PACKAGE}@latest`, "--no-audit", "--no-fund", "--omit=dev"], { + cwd: PXPIPE_DIR, + stdio: ["ignore", outFd, outFd], + windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH }, + }); + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error("npm install timed out after 5 minutes — see install.log")); + }, INSTALL_TIMEOUT_MS); + child.once("error", (e) => { clearTimeout(timer); reject(e); }); + child.once("exit", (code) => { + clearTimeout(timer); + if (code === 0) resolve(); + else reject(new Error(`npm install exited with code ${code} — see install.log`)); + }); + }).finally(() => fs.closeSync(outFd)); + + const info = getInstallInfo(); + if (!info.installed) throw new Error("install finished but package is missing — see install.log"); + return info; +} + +export function getInstallLogTail(maxLines = 200) { + try { + if (!fs.existsSync(INSTALL_LOG)) return ""; + const lines = fs.readFileSync(INSTALL_LOG, "utf8").split(/\r?\n/).filter(Boolean); + return lines.slice(-maxLines).join("\n"); + } catch { + return ""; + } +} diff --git a/src/lib/pxpipe/loader.js b/src/lib/pxpipe/loader.js new file mode 100644 index 0000000..fbd418f --- /dev/null +++ b/src/lib/pxpipe/loader.js @@ -0,0 +1,70 @@ +import { pathToFileURL } from "url"; +import { getInstallInfo, libraryEntry } from "./install.js"; + +// Module cache: pxpipe is loaded once per process ("started") and dropped on +// "stop". In library mode start/stop govern the in-process module, not a daemon. +let cached = null; // { module, version, loadedAt } +let loadPromise = null; + +export function getLoadedInfo() { + return cached ? { loaded: true, version: cached.version, loadedAt: cached.loadedAt } : { loaded: false }; +} + +export async function loadPxpipe() { + if (cached) return cached; + if (loadPromise) return loadPromise; + loadPromise = doLoad().finally(() => { loadPromise = null; }); + return loadPromise; +} + +async function doLoad() { + const info = getInstallInfo(); + if (!info.installed) { + const err = new Error("PXPIPE is not installed"); + err.code = "NOT_INSTALLED"; + throw err; + } + // Cache-bust per version so Repair/upgrade takes effect without a server restart. + const url = `${pathToFileURL(libraryEntry()).href}?v=${encodeURIComponent(info.version || "0")}`; + const mod = await import(/* webpackIgnore: true */ url); + if (typeof mod.transformAnthropicMessages !== "function") { + throw new Error("installed pxpipe package does not export transformAnthropicMessages"); + } + cached = { module: mod, version: info.version, loadedAt: Date.now() }; + return cached; +} + +export function unloadPxpipe() { + const wasLoaded = !!cached; + cached = null; + return wasLoaded; +} + +// Transform function for the request pipeline; null when unavailable (fail-open). +// autoLoad controls whether a cold cache triggers a load (first request warms it). +export async function getTransform({ autoLoad = true } = {}) { + try { + if (!cached && !autoLoad) return null; + const { module: mod } = await loadPxpipe(); + return mod.transformAnthropicMessages; + } catch { + return null; + } +} + +// Health self-test: run a tiny synthetic Claude request through the transformer. +// A healthy module parses it and answers with a machine-readable reason. +export async function selfTest() { + const startedAt = Date.now(); + const { module: mod } = await loadPxpipe(); + const body = new TextEncoder().encode(JSON.stringify({ + model: "claude-fable-5", + max_tokens: 16, + messages: [{ role: "user", content: "ping" }], + })); + const result = await mod.transformAnthropicMessages({ body, model: "claude-fable-5" }); + if (!result || typeof result.applied !== "boolean" || !(result.body instanceof Uint8Array)) { + throw new Error("transform returned an unexpected shape"); + } + return { ok: true, reason: result.reason, durationMs: Date.now() - startedAt }; +} diff --git a/src/lib/pxpipe/service.js b/src/lib/pxpipe/service.js new file mode 100644 index 0000000..d117c82 --- /dev/null +++ b/src/lib/pxpipe/service.js @@ -0,0 +1,49 @@ +import { getInstallInfo, isInstalling, findNpm } from "./install.js"; +import { getLoadedInfo, loadPxpipe, selfTest } from "./loader.js"; + +// Aggregate status for the Token Saver card and /api/pxpipe/status. +// "running" in library mode = module loaded into this process. +export function getPxpipeStatus() { + const install = getInstallInfo(); + const loaded = getLoadedInfo(); + return { + installed: install.installed, + installing: isInstalling(), + version: install.version, + path: install.path, + running: loaded.loaded, + loadedAt: loaded.loadedAt || null, + uptimeMs: loaded.loaded ? Date.now() - loaded.loadedAt : 0, + npmAvailable: !!findNpm(), + mode: "library", // in-process transform, not an external proxy + }; +} + +// PRD health checklist, adapted to library mode: installed? → module loads +// (the "executable found / port listening" equivalent) → test request transforms. +export async function runHealthCheck() { + const checks = []; + const fail = (error) => ({ healthy: false, checks, error }); + + const install = getInstallInfo(); + checks.push({ id: "installed", label: "PXPIPE installed", ok: install.installed, detail: install.version ? `v${install.version}` : null }); + if (!install.installed) return fail("pxpipe not installed"); + + try { + await loadPxpipe(); + checks.push({ id: "module", label: "Transform module loads", ok: true, detail: `v${install.version}` }); + } catch (e) { + checks.push({ id: "module", label: "Transform module loads", ok: false, detail: e.message }); + return fail(`Cannot load module: ${e.message}`); + } + + try { + const test = await selfTest(); + checks.push({ id: "transform", label: "Test request transforms", ok: true, detail: `${test.durationMs}ms (${test.reason})` }); + } catch (e) { + checks.push({ id: "transform", label: "Test request transforms", ok: false, detail: e.message }); + return fail(`Self-test failed: ${e.message}`); + } + + return { healthy: true, checks, error: null }; +} diff --git a/src/lib/qoder/constants.js b/src/lib/qoder/constants.js new file mode 100644 index 0000000..bd1fc98 --- /dev/null +++ b/src/lib/qoder/constants.js @@ -0,0 +1,2 @@ +// Re-export: qoder constants moved to open-sse/shared/qoder (open-sse self-contained, docs 00 §1b). +export * from "../../../open-sse/shared/qoder/constants.js"; diff --git a/src/lib/qoder/cosy.js b/src/lib/qoder/cosy.js new file mode 100644 index 0000000..02b9c33 --- /dev/null +++ b/src/lib/qoder/cosy.js @@ -0,0 +1,2 @@ +// Re-export: qoder cosy helpers moved to open-sse/shared/qoder (docs 00 §1b). +export * from "../../../open-sse/shared/qoder/cosy.js"; diff --git a/src/lib/qoder/encoding.js b/src/lib/qoder/encoding.js new file mode 100644 index 0000000..a96fb9a --- /dev/null +++ b/src/lib/qoder/encoding.js @@ -0,0 +1,2 @@ +// Re-export: qoder encoding moved to open-sse/shared/qoder (docs 00 §1b). +export * from "../../../open-sse/shared/qoder/encoding.js"; diff --git a/src/lib/requestDetailsDb.js b/src/lib/requestDetailsDb.js new file mode 100644 index 0000000..0daaa8a --- /dev/null +++ b/src/lib/requestDetailsDb.js @@ -0,0 +1,4 @@ +// Shim → re-export from new SQLite-based DB layer (src/lib/db/) +export { + saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders, +} from "@/lib/db/index.js"; diff --git a/src/lib/tunnel/cloudflare/cloudflared.js b/src/lib/tunnel/cloudflare/cloudflared.js new file mode 100644 index 0000000..38e4bf7 --- /dev/null +++ b/src/lib/tunnel/cloudflare/cloudflared.js @@ -0,0 +1,449 @@ +import fs from "fs"; +import path from "path"; +import https from "https"; +import os from "os"; +import { execSync, spawn } from "child_process"; +import { savePid, loadPid, clearPid } from "./pid.js"; +import { DATA_DIR } from "@/lib/dataDir.js"; + +const BIN_DIR = path.join(DATA_DIR, "bin"); +const BINARY_NAME = "cloudflared"; +const IS_WINDOWS = os.platform() === "win32"; +const BIN_NAME = IS_WINDOWS ? `${BINARY_NAME}.exe` : BINARY_NAME; +const BIN_PATH = path.join(BIN_DIR, BIN_NAME); +const POWERSHELL_HIDDEN_COMMAND = "powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command"; +const DEFAULT_QUICK_TUNNEL_PROTOCOL = "http2"; +const QUICK_TUNNEL_PROTOCOLS = new Set(["http2", "quic", "auto"]); + +const GITHUB_BASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download"; + +const PLATFORM_MAPPINGS = { + darwin: { + x64: "cloudflared-darwin-amd64.tgz", + arm64: "cloudflared-darwin-arm64.tgz" + }, + win32: { + x64: "cloudflared-windows-amd64.exe", + ia32: "cloudflared-windows-386.exe", + arm64: "cloudflared-windows-386.exe" + }, + linux: { + x64: "cloudflared-linux-amd64", + arm64: "cloudflared-linux-arm64" + } +}; + +// Fallback order: prefer smallest/most-compatible binary per platform +const PLATFORM_FALLBACK = { + darwin: "cloudflared-darwin-amd64.tgz", + win32: "cloudflared-windows-386.exe", + linux: "cloudflared-linux-amd64" +}; + +function getDownloadUrl() { + const platform = os.platform(); + const arch = os.arch(); + + const platformMapping = PLATFORM_MAPPINGS[platform]; + if (!platformMapping) { + throw new Error(`Unsupported platform: ${platform}`); + } + + const binaryName = platformMapping[arch] || PLATFORM_FALLBACK[platform]; + return `${GITHUB_BASE_URL}/${binaryName}`; +} + +// Download state — shared so status API can read it +const dlState = { downloading: false, progress: 0 }; + +export function getDownloadStatus() { + return { downloading: dlState.downloading, progress: dlState.progress }; +} + +function downloadFile(url, dest) { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(dest); + + https.get(url, (response) => { + if ([301, 302, 303, 307, 308].includes(response.statusCode)) { + file.close(); + fs.unlinkSync(dest); + downloadFile(response.headers.location, dest).then(resolve).catch(reject); + return; + } + + if (response.statusCode !== 200) { + file.close(); + fs.unlinkSync(dest); + reject(new Error(`Download failed with status ${response.statusCode}`)); + return; + } + + const totalBytes = parseInt(response.headers["content-length"], 10) || 0; + let receivedBytes = 0; + dlState.downloading = true; + dlState.progress = 0; + + response.on("data", (chunk) => { + receivedBytes += chunk.length; + if (totalBytes > 0) dlState.progress = Math.round((receivedBytes / totalBytes) * 100); + }); + + response.pipe(file); + + file.on("finish", () => { + dlState.downloading = false; + dlState.progress = 100; + file.close(() => resolve(dest)); + }); + + file.on("error", (err) => { + dlState.downloading = false; + dlState.progress = 0; + file.close(); + fs.unlinkSync(dest); + reject(err); + }); + }).on("error", (err) => { + dlState.downloading = false; + dlState.progress = 0; + file.close(); + if (fs.existsSync(dest)) fs.unlinkSync(dest); + reject(err); + }); + }); +} + +const MIN_BINARY_SIZE = 1024 * 1024; // 1MB - cloudflared is ~30MB+ + +// Validate binary is executable on current platform and not truncated +function isValidBinary(filePath) { + try { + const stat = fs.statSync(filePath); + if (stat.size < MIN_BINARY_SIZE) return false; + const fd = fs.openSync(filePath, "r"); + const buf = Buffer.alloc(4); + fs.readSync(fd, buf, 0, 4, 0); + fs.closeSync(fd); + const magic = buf.toString("hex"); + if (IS_WINDOWS) return magic.startsWith("4d5a"); // PE (MZ) + if (os.platform() === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); + return magic.startsWith("7f454c46"); // ELF (Linux) + } catch { + return false; + } +} + +let downloadPromise = null; + +export async function ensureCloudflared() { + if (downloadPromise) return downloadPromise; + downloadPromise = _ensureCloudflared().finally(() => { downloadPromise = null; }); + return downloadPromise; +} + +async function _ensureCloudflared() { + if (!fs.existsSync(BIN_DIR)) { + fs.mkdirSync(BIN_DIR, { recursive: true }); + } + + // Clean up incomplete downloads from previous runs + const tmpPath = `${BIN_PATH}.tmp`; + if (fs.existsSync(tmpPath)) { + try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } + } + + if (fs.existsSync(BIN_PATH)) { + if (!isValidBinary(BIN_PATH)) { + console.log("[cloudflared] Invalid binary detected, re-downloading..."); + fs.unlinkSync(BIN_PATH); + } else { + if (!IS_WINDOWS) fs.chmodSync(BIN_PATH, "755"); + return BIN_PATH; + } + } + + const url = getDownloadUrl(); + const isArchive = url.endsWith(".tgz"); + const downloadDest = isArchive ? path.join(BIN_DIR, "cloudflared.tgz.tmp") : tmpPath; + + await downloadFile(url, downloadDest); + + if (isArchive) { + execSync(`tar -xzf "${downloadDest}" -C "${BIN_DIR}"`, { stdio: "pipe", windowsHide: true }); + fs.unlinkSync(downloadDest); + } else { + fs.renameSync(downloadDest, BIN_PATH); + } + + if (!IS_WINDOWS) { + fs.chmodSync(BIN_PATH, "755"); + } + + return BIN_PATH; +} + +let cloudflaredProcess = null; +let unexpectedExitHandler = null; +let intentionalKill = false; // suppress unexpected-exit callback during deliberate kill + +/** Register a callback to be called when cloudflared exits unexpectedly after connecting */ +export function setUnexpectedExitHandler(handler) { + unexpectedExitHandler = handler; +} + +export async function spawnCloudflared(tunnelToken) { + const binaryPath = await ensureCloudflared(); + + const child = spawn(binaryPath, ["tunnel", "run", "--dns-resolver-addrs", "1.1.1.1:53", "--token", tunnelToken], { + detached: false, + windowsHide: true, + cwd: os.tmpdir(), + stdio: ["ignore", "pipe", "pipe"] + }); + + cloudflaredProcess = child; + savePid(child.pid); + + return new Promise((resolve, reject) => { + let connectionCount = 0; + let resolved = false; + const timeout = setTimeout(() => { + resolved = true; + resolve(child); + }, 90000); + + const handleLog = (data) => { + const msg = data.toString(); + // Count exact occurrences in this chunk (each chunk may contain multiple lines) + const matches = msg.match(/Registered tunnel connection/g); + if (matches) { + connectionCount += matches.length; + if (connectionCount >= 4 && !resolved) { + resolved = true; + clearTimeout(timeout); + resolve(child); + } + } + }; + + child.stdout.on("data", handleLog); + child.stderr.on("data", handleLog); + + child.on("error", (err) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + reject(err); + } + }); + + child.on("exit", (code, signal) => { + cloudflaredProcess = null; + clearPid(); + const wasConnected = resolved; // true = already connected successfully + if (!resolved) { + resolved = true; + clearTimeout(timeout); + // Collect stderr output for better error diagnosis + let stderrOutput = ""; + if (child.stderr && !child.stderr.destroyed) { + // Try to read any buffered stderr (may not have all output but helps with common errors) + stderrOutput = " Check cloudflared logs for details."; + } + if (code === 1) { + // Common exit code 1 issues: invalid token, auth failure, network issues + reject(new Error(`cloudflared exited with code ${code}${stderrOutput} Ensure your tunnel token is valid and network is reachable.`)); + } else if (code === 2) { + reject(new Error(`cloudflared exited with code ${code}${stderrOutput} Check if required arguments are correct.`)); + } else { + reject(new Error(`cloudflared exited with code ${code}${stderrOutput}`)); + } + return; + } + // Watchdog (initializeApp) handles recovery — no auto-reconnect here + if (intentionalKill) { intentionalKill = false; return; } + if (wasConnected && unexpectedExitHandler) unexpectedExitHandler(); + }); + }); +} + +/** + * Spawn cloudflared quick tunnel (no account needed) + * Returns the generated trycloudflare.com URL + */ +export async function spawnQuickTunnel(localPort, onUrlUpdate) { + const binaryPath = await ensureCloudflared(); + + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloudflared-quick-")); + const configPath = path.join(configDir, "config.yml"); + // Avoid using default ~/.cloudflared/config.yml, which can conflict with quick tunnel behavior. + fs.writeFileSync(configPath, "# quick-tunnel config placeholder\n", "utf8"); + + let isCleaned = false; + const cleanup = () => { + if (isCleaned) return; + isCleaned = true; + try { + fs.rmSync(configDir, { recursive: true, force: true }); + } catch (e) { /* ignore */ } + }; + + const requestedProtocol = String(process.env.TUNNEL_TRANSPORT_PROTOCOL || process.env.CLOUDFLARED_PROTOCOL || DEFAULT_QUICK_TUNNEL_PROTOCOL).trim().toLowerCase(); + const tunnelProtocol = QUICK_TUNNEL_PROTOCOLS.has(requestedProtocol) ? requestedProtocol : DEFAULT_QUICK_TUNNEL_PROTOCOL; + const child = spawn(binaryPath, ["tunnel", "--url", `http://127.0.0.1:${localPort}`, "--config", configPath, "--no-autoupdate", "--retries", "99"], { + detached: false, + windowsHide: true, + cwd: os.tmpdir(), + env: { + ...process.env, + TUNNEL_TRANSPORT_PROTOCOL: tunnelProtocol, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + cloudflaredProcess = child; + savePid(child.pid); + + return new Promise((resolve, reject) => { + let resolved = false; + // Keep a small tail of raw cloudflared logs to surface real failure causes + let logTail = ""; + + function getQuickTunnelUrlFromLog(message) { + // cloudflared logs may contain "api.trycloudflare.com" as well, + // but that is NOT the quick-tunnel endpoint we need. + const regex = /https:\/\/([a-z0-9-]+)\.trycloudflare\.com/gi; + const candidates = []; + + for (const match of message.matchAll(regex)) { + const host = match[1]; + if (host === "api") continue; + candidates.push(`https://${host}.trycloudflare.com`); + } + + if (!candidates.length) return null; + return candidates[candidates.length - 1]; + } + + const timeout = setTimeout(() => { + if (resolved) return; + resolved = true; + cleanup(); + reject(new Error(`Quick tunnel timed out. Last log: ${logTail.slice(-800) || "(empty)"}`)); + }, 90000); + + let lastUrl = null; + + const handleLog = (data) => { + const msg = data.toString(); + logTail = (logTail + msg).slice(-4000); + const tunnelUrl = getQuickTunnelUrlFromLog(msg); + if (!tunnelUrl) return; + + if (!resolved) { + // First URL — resolve the promise, do NOT call onUrlUpdate (caller handles initial register) + resolved = true; + lastUrl = tunnelUrl; + clearTimeout(timeout); + cleanup(); + console.log(`[Tunnel] cloudflared URL: ${tunnelUrl}`); + resolve({ child, tunnelUrl }); + return; + } + + // URL changed after initial connect — notify caller to re-register + if (tunnelUrl !== lastUrl) { + console.log(`[Tunnel] cloudflared URL changed: ${tunnelUrl}`); + lastUrl = tunnelUrl; + if (onUrlUpdate) onUrlUpdate(tunnelUrl); + } + }; + + child.stdout.on("data", handleLog); + child.stderr.on("data", handleLog); + + child.on("error", (err) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + cleanup(); + reject(err); + }); + + child.on("exit", (code, signal) => { + cloudflaredProcess = null; + clearPid(); + // Deliberate kill (restart/disable) — exit silently, no error noise + if (intentionalKill) { + intentionalKill = false; + clearTimeout(timeout); + cleanup(); + if (!resolved) { resolved = true; reject(new Error("cloudflared killed")); } + return; + } + console.log(`[Tunnel] cloudflared exit code=${code} signal=${signal}`); + if (!resolved) { + resolved = true; + clearTimeout(timeout); + cleanup(); + const tail = logTail.slice(-600).trim() || "(empty)"; + if (code === 1) { + reject(new Error(`cloudflared quick tunnel exited (code 1). Common causes: (1) outbound port 7844 (TCP/UDP) blocked, (2) TryCloudflare service issue, (3) cannot reach 127.0.0.1:${localPort}, (4) protocol (http2/quic) blocked by network. Last log: ${tail}`)); + } else if (code === 2) { + reject(new Error(`cloudflared exited (code 2). Bad arguments. Last log: ${tail}`)); + } else { + reject(new Error(`cloudflared exited (code ${code}). Last log: ${tail}`)); + } + return; + } + if (unexpectedExitHandler) unexpectedExitHandler(); + cleanup(); + }); + }); +} + +// Kill cloudflared processes whose command line targets the given port (any host). +// Boundary check ensures :20128 doesn't match :201280 or :202128. +function killCloudflaredByPort(port) { + if (!port) return; + try { + if (IS_WINDOWS) { + const psCmd = `Get-CimInstance Win32_Process -Filter \\"Name='cloudflared.exe'\\" | Where-Object { $_.CommandLine -match ':${port}(\\D|$)' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`; + execSync(`${POWERSHELL_HIDDEN_COMMAND} "${psCmd}"`, { stdio: "ignore", windowsHide: true }); + } else { + execSync(`pkill -f "cloudflared.*:${port}([^0-9]|$)" 2>/dev/null || true`, { stdio: "ignore", windowsHide: true }); + } + } catch (e) { /* ignore */ } +} + +export function killCloudflared(localPort) { + intentionalKill = true; + if (cloudflaredProcess) { + try { + cloudflaredProcess.kill(); + } catch (e) { /* ignore */ } + cloudflaredProcess = null; + } + + const pid = loadPid(); + if (pid) { + try { + process.kill(pid); + } catch (e) { /* ignore */ } + clearPid(); + } + + killCloudflaredByPort(localPort); +} + +export function isCloudflaredRunning() { + const pid = loadPid(); + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch (e) { + return false; + } +} diff --git a/src/lib/tunnel/cloudflare/config.js b/src/lib/tunnel/cloudflare/config.js new file mode 100644 index 0000000..2cc3f9d --- /dev/null +++ b/src/lib/tunnel/cloudflare/config.js @@ -0,0 +1,9 @@ +// Cloudflare quick tunnel: DNS propagates fast, short timeouts OK +export const HEALTH_CHECK = { + intervalMs: 2000, + timeoutMs: 60000, + fetchTimeoutMs: 5000, + dnsTimeoutMs: 2000, +}; + +export const WORKER_URL = process.env.TUNNEL_WORKER_URL || "https://abc-tunnel.us"; diff --git a/src/lib/tunnel/cloudflare/healthCheck.js b/src/lib/tunnel/cloudflare/healthCheck.js new file mode 100644 index 0000000..428b351 --- /dev/null +++ b/src/lib/tunnel/cloudflare/healthCheck.js @@ -0,0 +1,29 @@ +import { resolveDns } from "../shared/dnsResolver.js"; +import { HEALTH_CHECK } from "./config.js"; + +export async function probeUrlAlive(url) { + if (!url) return false; + let hostname; + try { hostname = new URL(url).hostname; } catch { return false; } + + if (!await resolveDns(hostname, HEALTH_CHECK.dnsTimeoutMs)) return false; + + try { + const res = await fetch(`${url}/api/health`, { + signal: AbortSignal.timeout(HEALTH_CHECK.fetchTimeoutMs), + }); + return res.ok; + } catch { + return false; + } +} + +export async function waitForHealth(url, cancelToken = { cancelled: false }) { + const start = Date.now(); + while (Date.now() - start < HEALTH_CHECK.timeoutMs) { + if (cancelToken.cancelled) throw new Error("cancelled"); + if (await probeUrlAlive(url)) return true; + await new Promise((r) => setTimeout(r, HEALTH_CHECK.intervalMs)); + } + throw new Error(`Health check timeout after ${HEALTH_CHECK.timeoutMs}ms`); +} diff --git a/src/lib/tunnel/cloudflare/manager.js b/src/lib/tunnel/cloudflare/manager.js new file mode 100644 index 0000000..e15b47b --- /dev/null +++ b/src/lib/tunnel/cloudflare/manager.js @@ -0,0 +1,151 @@ +import { loadState, saveState, generateShortId } from "../shared/state.js"; +import { spawnQuickTunnel, killCloudflared, isCloudflaredRunning, setUnexpectedExitHandler } from "./cloudflared.js"; +import { clearPid } from "./pid.js"; +import { waitForHealth, probeUrlAlive } from "./healthCheck.js"; +import { WORKER_URL } from "./config.js"; +import { getSettings, updateSettings } from "@/lib/localDb"; + +const svc = { + cancelToken: { cancelled: false }, + spawnInProgress: false, + lastRestartAt: 0, + activeLocalPort: null, +}; + +export function getTunnelService() { return svc; } +export function isTunnelManuallyDisabled() { return svc.cancelToken.cancelled; } +export function isTunnelReconnecting() { return svc.spawnInProgress; } + +let onUnexpectedExit = null; +export function setTunnelUnexpectedExitCallback(cb) { onUnexpectedExit = cb; } + +async function registerTunnelUrl(shortId, tunnelUrl) { + await fetch(`${WORKER_URL}/api/tunnel/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ shortId, tunnelUrl }) + }); +} + +function throwIfCancelled(token) { + if (token.cancelled) throw new Error("tunnel cancelled"); +} + +export async function enableTunnel(localPort = 20128) { + console.log(`[Tunnel] enable start (port=${localPort})`); + svc.cancelToken = { cancelled: false }; + svc.activeLocalPort = localPort; + svc.spawnInProgress = true; + const token = svc.cancelToken; + + try { + if (isCloudflaredRunning()) { + const existing = loadState(); + if (existing?.tunnelUrl && existing?.shortId) { + const publicUrl = `https://r${existing.shortId}.abc-tunnel.us`; + // Reuse only if BOTH direct + public URL alive (avoid stale socket after network change) + const [directOk, publicOk] = await Promise.all([ + probeUrlAlive(existing.tunnelUrl), + probeUrlAlive(publicUrl), + ]); + if (directOk && publicOk) { + console.log(`[Tunnel] already running, reuse: ${existing.tunnelUrl}`); + return { success: true, tunnelUrl: existing.tunnelUrl, shortId: existing.shortId, publicUrl, alreadyRunning: true }; + } + console.log(`[Tunnel] stale (direct=${directOk} public=${publicOk}), respawn`); + } + } + + killCloudflared(localPort); + console.log("[Tunnel] killed existing cloudflared"); + throwIfCancelled(token); + + const existing = loadState(); + const shortId = existing?.shortId || generateShortId(); + + const onUrlUpdate = async (url) => { + if (token.cancelled) return; + console.log(`[Tunnel] url updated: ${url}`); + await registerTunnelUrl(shortId, url); + saveState({ shortId, tunnelUrl: url }); + await updateSettings({ tunnelEnabled: true, tunnelUrl: url }); + }; + + // Register exit handler BEFORE spawn so it fires even on early exit + setUnexpectedExitHandler(() => { + console.warn("[Tunnel] cloudflared exited unexpectedly, scheduling respawn"); + if (onUnexpectedExit) onUnexpectedExit(); + }); + + const { tunnelUrl } = await spawnQuickTunnel(localPort, onUrlUpdate); + console.log(`[Tunnel] spawned: ${tunnelUrl}`); + throwIfCancelled(token); + + const publicUrl = `https://r${shortId}.abc-tunnel.us`; + await registerTunnelUrl(shortId, tunnelUrl); + saveState({ shortId, tunnelUrl }); + await updateSettings({ tunnelEnabled: true, tunnelUrl }); + console.log(`[Tunnel] registered shortId=${shortId} publicUrl=${publicUrl}`); + + // Verify publicUrl first (worker route is reliable; direct *.trycloudflare.com DNS may lag) + await waitForHealth(publicUrl, token); + console.log("[Tunnel] public URL healthy"); + // Direct tunnel probe is best-effort: DNS for *.trycloudflare.com can be slow/blocked + if (!(await probeUrlAlive(tunnelUrl))) { + console.warn("[Tunnel] direct URL not reachable yet, continuing via publicUrl"); + } else { + console.log("[Tunnel] direct URL healthy"); + } + + console.log("[Tunnel] enable success"); + return { success: true, tunnelUrl, shortId, publicUrl }; + } catch (e) { + // Suppress noise when spawn was deliberately killed (restart/disable superseded it) + if (!/cloudflared killed|tunnel cancelled/.test(e.message)) { + console.error(`[Tunnel] enable error: ${e.message}`); + } + throw e; + } finally { + svc.spawnInProgress = false; + } +} + +export async function disableTunnel() { + console.log("[Tunnel] disable"); + // Abort any in-flight enable so it cannot resurrect state after we clear it + svc.cancelToken.cancelled = true; + setUnexpectedExitHandler(null); + + try { killCloudflared(svc.activeLocalPort); } catch (e) { console.warn(`[Tunnel] kill warn: ${e.message}`); } + clearPid(); + + const state = loadState(); + if (state) saveState({ shortId: state.shortId, tunnelUrl: null }); + + await updateSettings({ tunnelEnabled: false, tunnelUrl: "" }); + // Force-clear flags so a subsequent enable is not blocked by a stuck spawnInProgress + svc.spawnInProgress = false; + svc.activeLocalPort = null; + return { success: true }; +} + +export async function getTunnelStatus() { + const settings = await getSettings(); + const settingsEnabled = settings.tunnelEnabled === true; + const state = loadState(); + const shortId = state?.shortId || ""; + const publicUrl = shortId ? `https://r${shortId}.abc-tunnel.us` : ""; + const tunnelUrl = state?.tunnelUrl || ""; + + // Lazy: skip PID probe entirely when user disabled tunnel + const running = settingsEnabled ? isCloudflaredRunning() : false; + + return { + enabled: settingsEnabled && running, + settingsEnabled, + tunnelUrl, + shortId, + publicUrl, + running + }; +} diff --git a/src/lib/tunnel/cloudflare/pid.js b/src/lib/tunnel/cloudflare/pid.js new file mode 100644 index 0000000..919837c --- /dev/null +++ b/src/lib/tunnel/cloudflare/pid.js @@ -0,0 +1,23 @@ +import fs from "fs"; +import path from "path"; +import { TUNNEL_DIR, ensureTunnelDir } from "../shared/state.js"; + +const PID_FILE = path.join(TUNNEL_DIR, "cloudflared.pid"); + +export function savePid(pid) { + ensureTunnelDir(); + fs.writeFileSync(PID_FILE, pid.toString()); +} + +export function loadPid() { + try { + if (fs.existsSync(PID_FILE)) return parseInt(fs.readFileSync(PID_FILE, "utf8")); + } catch { /* ignore */ } + return null; +} + +export function clearPid() { + try { + if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); + } catch { /* ignore */ } +} diff --git a/src/lib/tunnel/index.js b/src/lib/tunnel/index.js new file mode 100644 index 0000000..3ffab37 --- /dev/null +++ b/src/lib/tunnel/index.js @@ -0,0 +1,53 @@ +// Cloudflare service +export { + enableTunnel, + disableTunnel, + getTunnelStatus, + isTunnelManuallyDisabled, + isTunnelReconnecting, + getTunnelService, + setTunnelUnexpectedExitCallback, +} from "./cloudflare/manager.js"; +export { + killCloudflared, + isCloudflaredRunning, + ensureCloudflared, + getDownloadStatus, +} from "./cloudflare/cloudflared.js"; +export { probeUrlAlive as probeCloudflareAlive } from "./cloudflare/healthCheck.js"; + +// Tailscale service +export { + enableTailscale, + disableTailscale, + getTailscaleStatus, + isTailscaleReconnecting, + getTailscaleService, +} from "./tailscale/manager.js"; +export { + isTailscaleInstalled, + isTailscaleRunning, + isTailscaleRunningStrict, + isTailscaleLoggedIn, + isTailscaleLoggedInStrict, + isSystemDaemonRunning, + isDaemonAlive, + startFunnel, + getTailscaleBin, + installTailscale, + startLogin, + startDaemonWithPassword, + TAILSCALE_SOCKET, +} from "./tailscale/tailscale.js"; +export { probeUrlAlive as probeTailscaleAlive } from "./tailscale/healthCheck.js"; + +// Shared +export { loadState, generateShortId } from "./shared/state.js"; +export { checkInternet } from "./shared/internetCheck.js"; +export { + RESTART_COOLDOWN_MS, + NETWORK_SETTLE_MS, + WATCHDOG_INTERVAL_MS, + NETWORK_CHECK_INTERVAL_MS, + VIRTUAL_IFACE_REGEX, +} from "./shared/watchdogConfig.js"; diff --git a/src/lib/tunnel/shared/dnsResolver.js b/src/lib/tunnel/shared/dnsResolver.js new file mode 100644 index 0000000..e8fde7d --- /dev/null +++ b/src/lib/tunnel/shared/dnsResolver.js @@ -0,0 +1,17 @@ +import dns from "dns"; + +// Force public DNS to bypass OS negative cache (mDNSResponder holds NXDOMAIN) +const resolver = new dns.promises.Resolver(); +resolver.setServers(["1.1.1.1", "1.0.0.1", "8.8.8.8"]); + +// Try custom public DNS first, fall back to OS resolver +// (Cloudflare DNS may not resolve all hostnames, e.g. *.ts.net) +export async function resolveDns(hostname, timeoutMs) { + const tryResolver = (fn) => Promise.race([ + fn(), + new Promise((_, rej) => setTimeout(() => rej(new Error("dns timeout")), timeoutMs)), + ]).then(() => true).catch(() => false); + + if (await tryResolver(() => resolver.resolve4(hostname))) return true; + return tryResolver(() => dns.promises.resolve4(hostname)); +} diff --git a/src/lib/tunnel/shared/internetCheck.js b/src/lib/tunnel/shared/internetCheck.js new file mode 100644 index 0000000..756640d --- /dev/null +++ b/src/lib/tunnel/shared/internetCheck.js @@ -0,0 +1,26 @@ +import net from "net"; + +const INTERNET_CHECK = { + host: "1.1.1.1", + port: 443, + timeoutMs: 3000, +}; + +export function checkInternet() { + return new Promise((resolve) => { + const socket = new net.Socket(); + let done = false; + const finish = (ok) => { + if (done) return; + done = true; + try { socket.destroy(); } catch { /* ignore */ } + resolve(ok); + }; + socket.setTimeout(INTERNET_CHECK.timeoutMs); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + try { socket.connect(INTERNET_CHECK.port, INTERNET_CHECK.host); } + catch { finish(false); } + }); +} diff --git a/src/lib/tunnel/shared/state.js b/src/lib/tunnel/shared/state.js new file mode 100644 index 0000000..6a16181 --- /dev/null +++ b/src/lib/tunnel/shared/state.js @@ -0,0 +1,41 @@ +import fs from "fs"; +import path from "path"; +import { DATA_DIR } from "@/lib/dataDir.js"; + +const TUNNEL_DIR = path.join(DATA_DIR, "tunnel"); +const STATE_FILE = path.join(TUNNEL_DIR, "state.json"); + +const SHORT_ID_LENGTH = 6; +const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789"; + +export function ensureTunnelDir() { + if (!fs.existsSync(TUNNEL_DIR)) fs.mkdirSync(TUNNEL_DIR, { recursive: true }); +} + +export function loadState() { + try { + if (fs.existsSync(STATE_FILE)) return JSON.parse(fs.readFileSync(STATE_FILE, "utf8")); + } catch { /* ignore corrupt state */ } + return null; +} + +export function saveState(state) { + ensureTunnelDir(); + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); +} + +export function clearState() { + try { + if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE); + } catch { /* ignore */ } +} + +export function generateShortId() { + let result = ""; + for (let i = 0; i < SHORT_ID_LENGTH; i++) { + result += SHORT_ID_CHARS.charAt(Math.floor(Math.random() * SHORT_ID_CHARS.length)); + } + return result; +} + +export { TUNNEL_DIR }; diff --git a/src/lib/tunnel/shared/watchdogConfig.js b/src/lib/tunnel/shared/watchdogConfig.js new file mode 100644 index 0000000..0274997 --- /dev/null +++ b/src/lib/tunnel/shared/watchdogConfig.js @@ -0,0 +1,8 @@ +// Watchdog + network monitor timings (shared by both services) +export const RESTART_COOLDOWN_MS = 120000; +export const NETWORK_SETTLE_MS = 2500; +export const WATCHDOG_INTERVAL_MS = 60000; +export const NETWORK_CHECK_INTERVAL_MS = 5000; + +// Skip virtual/transient interfaces (tailscale utun, AirDrop awdl, bridges) that flap and cause false netchange +export const VIRTUAL_IFACE_REGEX = /^(utun|awdl|llw|anpi|bridge|gif|stf|ipsec|ap|tun|tap|vmnet|veth|docker)/i; diff --git a/src/lib/tunnel/tailscale/config.js b/src/lib/tunnel/tailscale/config.js new file mode 100644 index 0000000..3195ad6 --- /dev/null +++ b/src/lib/tunnel/tailscale/config.js @@ -0,0 +1,7 @@ +// Tailscale Funnel: cert provisioning + *.ts.net DNS propagation slower → longer timeouts +export const HEALTH_CHECK = { + intervalMs: 2000, + timeoutMs: 180000, + fetchTimeoutMs: 8000, + dnsTimeoutMs: 3000, +}; diff --git a/src/lib/tunnel/tailscale/healthCheck.js b/src/lib/tunnel/tailscale/healthCheck.js new file mode 100644 index 0000000..428b351 --- /dev/null +++ b/src/lib/tunnel/tailscale/healthCheck.js @@ -0,0 +1,29 @@ +import { resolveDns } from "../shared/dnsResolver.js"; +import { HEALTH_CHECK } from "./config.js"; + +export async function probeUrlAlive(url) { + if (!url) return false; + let hostname; + try { hostname = new URL(url).hostname; } catch { return false; } + + if (!await resolveDns(hostname, HEALTH_CHECK.dnsTimeoutMs)) return false; + + try { + const res = await fetch(`${url}/api/health`, { + signal: AbortSignal.timeout(HEALTH_CHECK.fetchTimeoutMs), + }); + return res.ok; + } catch { + return false; + } +} + +export async function waitForHealth(url, cancelToken = { cancelled: false }) { + const start = Date.now(); + while (Date.now() - start < HEALTH_CHECK.timeoutMs) { + if (cancelToken.cancelled) throw new Error("cancelled"); + if (await probeUrlAlive(url)) return true; + await new Promise((r) => setTimeout(r, HEALTH_CHECK.intervalMs)); + } + throw new Error(`Health check timeout after ${HEALTH_CHECK.timeoutMs}ms`); +} diff --git a/src/lib/tunnel/tailscale/manager.js b/src/lib/tunnel/tailscale/manager.js new file mode 100644 index 0000000..7fe40e7 --- /dev/null +++ b/src/lib/tunnel/tailscale/manager.js @@ -0,0 +1,129 @@ +import { loadState, generateShortId } from "../shared/state.js"; +import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, isTailscaleLoggedInStrict, startLogin, startDaemonWithPassword, provisionCert } from "./tailscale.js"; +import { waitForHealth } from "./healthCheck.js"; +import { getSettings, updateSettings } from "@/lib/localDb"; +import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager"; + +initDbHooks(getSettings, updateSettings); + +const svc = { + cancelToken: { cancelled: false }, + spawnInProgress: false, + lastRestartAt: 0, + activeLocalPort: null, +}; + +export function getTailscaleService() { return svc; } +export function isTailscaleReconnecting() { return svc.spawnInProgress; } + +function throwIfCancelled(token) { + if (token.cancelled) throw new Error("tailscale cancelled"); +} + +export async function enableTailscale(localPort = 20128) { + console.log(`[Tailscale] enable start (port=${localPort})`); + svc.cancelToken = { cancelled: false }; + svc.activeLocalPort = localPort; + svc.spawnInProgress = true; + const token = svc.cancelToken; + + try { + const sudoPass = getCachedPassword() || await loadEncryptedPassword() || ""; + await startDaemonWithPassword(sudoPass); + console.log("[Tailscale] daemon ready"); + throwIfCancelled(token); + + const existing = loadState(); + const shortId = existing?.shortId || generateShortId(); + const tsHostname = shortId; + + const loggedIn = await isTailscaleLoggedInStrict(); + console.log(`[Tailscale] loggedIn=${loggedIn}`); + if (!loggedIn) { + const loginResult = await startLogin(tsHostname); + if (loginResult.authUrl) { + console.log(`[Tailscale] needs login, authUrl=${loginResult.authUrl}`); + return { success: false, needsLogin: true, authUrl: loginResult.authUrl }; + } + console.log("[Tailscale] login resolved alreadyLoggedIn"); + } + throwIfCancelled(token); + + stopFunnel(); + let result; + try { + console.log("[Tailscale] starting funnel"); + result = await startFunnel(localPort); + } catch (e) { + console.error(`[Tailscale] funnel error: ${e.message}`); + // Daemon not logged in / not ready → auto-trigger login flow so user stays in-app + if (/NoState|unexpected state|not logged in|Logged ?out|NeedsLogin/i.test(e.message || "")) { + console.log("[Tailscale] retry via startLogin"); + const loginResult = await startLogin(tsHostname); + if (loginResult.authUrl) return { success: false, needsLogin: true, authUrl: loginResult.authUrl }; + } + throw e; + } + throwIfCancelled(token); + + if (result.funnelNotEnabled) { + console.log(`[Tailscale] funnel not enabled, enableUrl=${result.enableUrl}`); + return { success: false, funnelNotEnabled: true, enableUrl: result.enableUrl }; + } + + // Strict probe: bypass cache so we don't false-negative on first invocation + if (!(await isTailscaleLoggedInStrict()) || !(await isTailscaleRunningStrict())) { + console.error("[Tailscale] strict probe failed (device removed?)"); + stopFunnel(); + return { success: false, error: "Tailscale not connected. Device may have been removed. Please re-login." }; + } + + await updateSettings({ tailscaleEnabled: true, tailscaleUrl: result.tunnelUrl }); + console.log(`[Tailscale] funnel up: ${result.tunnelUrl}`); + + // Provision TLS cert so Funnel can serve HTTPS (non-fatal if fails) + const hostname = new URL(result.tunnelUrl).hostname; + await provisionCert(hostname); + + // Verify funnel serves /api/health — timeout is non-fatal (DNS may still be propagating) + let reachableNow = false; + try { + await waitForHealth(result.tunnelUrl, token); + reachableNow = true; + } catch (he) { + if (!he.message.startsWith("Health check timeout")) throw he; + console.warn(`[Tailscale] health check timed out, will retry via watchdog`); + } + console.log(`[Tailscale] enable success (reachable=${reachableNow})`); + return { success: true, tunnelUrl: result.tunnelUrl }; + } catch (e) { + console.error(`[Tailscale] enable error: ${e.message}`); + throw e; + } finally { + svc.spawnInProgress = false; + } +} + +export async function disableTailscale() { + console.log("[Tailscale] disable"); + svc.cancelToken.cancelled = true; + stopFunnel(); + await updateSettings({ tailscaleEnabled: false, tailscaleUrl: "" }); + return { success: true }; +} + +export async function getTailscaleStatus() { + const settings = await getSettings(); + const settingsEnabled = settings.tailscaleEnabled === true; + const tunnelUrl = settings.tailscaleUrl || ""; + // Skip probes entirely when disabled; check login before running (device removed = not logged in) + const loggedIn = settingsEnabled ? isTailscaleLoggedIn() : false; + const running = loggedIn ? isTailscaleRunning() : false; + return { + enabled: settingsEnabled && running, + settingsEnabled, + tunnelUrl, + running, + loggedIn + }; +} diff --git a/src/lib/tunnel/tailscale/tailscale.js b/src/lib/tunnel/tailscale/tailscale.js new file mode 100644 index 0000000..467ad28 --- /dev/null +++ b/src/lib/tunnel/tailscale/tailscale.js @@ -0,0 +1,859 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; +import crypto from "crypto"; +import { execSync, exec, spawn } from "child_process"; +import { promisify } from "util"; +import { execWithPassword } from "@/mitm/dns/dnsConfig"; +import { DATA_DIR } from "@/lib/dataDir.js"; + +const execAsync = promisify(exec); + +const BIN_DIR = path.join(DATA_DIR, "bin"); +const IS_MAC = os.platform() === "darwin"; +const IS_LINUX = os.platform() === "linux"; +const IS_WINDOWS = os.platform() === "win32"; +const TAILSCALE_BIN = path.join(BIN_DIR, IS_WINDOWS ? "tailscale.exe" : "tailscale"); + +// Custom socket for userspace-networking mode (no root required) +const TAILSCALE_DIR = path.join(DATA_DIR, "tailscale"); +export const TAILSCALE_SOCKET = path.join(TAILSCALE_DIR, "tailscaled.sock"); +const SOCKET_FLAG = IS_WINDOWS ? [] : ["--socket", TAILSCALE_SOCKET]; + +// System daemon socket (sudo install: apt/snap/systemd) — read-only status detection +const SYSTEM_TAILSCALE_SOCKET = IS_WINDOWS ? null : "/var/run/tailscale/tailscaled.sock"; +const SYSTEM_SOCKET_FLAG = SYSTEM_TAILSCALE_SOCKET ? ["--socket", SYSTEM_TAILSCALE_SOCKET] : []; + +// Well-known Windows install path +const WINDOWS_TAILSCALE_BIN = "C:\\Program Files\\Tailscale\\tailscale.exe"; + +// Common Unix install paths to probe synchronously (system tailscale) +const UNIX_TAILSCALE_CANDIDATES = [ + "/usr/local/bin/tailscale", + "/opt/homebrew/bin/tailscale", + "/usr/sbin/tailscale", // apt package on Debian/Ubuntu + "/usr/bin/tailscale", + "/snap/bin/tailscale", // Snap package +]; + +// ─── Cache + background refresh (avoid blocking event loop on dead daemon) ── +const PROBE_TTL_MS = 10000; +const PROBE_TIMEOUT_MS = 1500; + +const binCache = { value: undefined, fetchedAt: 0, refreshing: false }; +const runningCache = { value: false, fetchedAt: 0, refreshing: false }; +const loggedInCache = { value: false, fetchedAt: 0, refreshing: false }; +const funnelUrlCache = { value: null, port: null, fetchedAt: 0, refreshing: false }; + +function fallbackBin() { + if (fs.existsSync(TAILSCALE_BIN)) return TAILSCALE_BIN; + if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALE_BIN)) return WINDOWS_TAILSCALE_BIN; + if (!IS_WINDOWS) return UNIX_TAILSCALE_CANDIDATES.find((p) => fs.existsSync(p)) || null; + return null; +} + +function bgRefreshBin() { + if (binCache.refreshing) return; + binCache.refreshing = true; + const cmd = IS_WINDOWS ? "where tailscale 2>nul" : "which tailscale 2>/dev/null"; + execAsync(cmd, { windowsHide: true, timeout: PROBE_TIMEOUT_MS, env: { ...process.env, PATH: EXTENDED_PATH } }) + .then(({ stdout }) => { + const sys = stdout.trim(); + binCache.value = sys || fallbackBin(); + }) + .catch(() => { binCache.value = fallbackBin(); }) + .finally(() => { + binCache.fetchedAt = Date.now(); + binCache.refreshing = false; + }); +} + +// Sync getter: returns cached value, triggers background refresh if stale +export function getTailscaleBin() { + if (Date.now() - binCache.fetchedAt > PROBE_TTL_MS) bgRefreshBin(); + // First call: synchronously probe common install paths (no exec, no event-loop block) + if (binCache.value === undefined) { + if (fs.existsSync(TAILSCALE_BIN)) binCache.value = TAILSCALE_BIN; + else if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALE_BIN)) binCache.value = WINDOWS_TAILSCALE_BIN; + else if (!IS_WINDOWS) { + const found = UNIX_TAILSCALE_CANDIDATES.find((p) => fs.existsSync(p)); + binCache.value = found || null; + } else binCache.value = null; + } + return binCache.value; +} + +export function isTailscaleInstalled() { + return getTailscaleBin() !== null; +} + +/** Build tailscale CLI args with custom socket (no root needed) */ +function tsArgs(...args) { + return [...SOCKET_FLAG, ...args]; +} + +// Async strict probe: authoritative, awaitable (never blocks event loop). Updates cache. +export async function isTailscaleLoggedInStrict() { + const bin = getTailscaleBin(); + if (!bin) return false; + try { + const { stdout } = await execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { + windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH }, + timeout: 5000 + }); + const json = JSON.parse(stdout); + // BackendState=Running + Self.Online=true → device still exists in tailnet + const loggedIn = json.BackendState === "Running" && json.Self?.Online === true; + loggedInCache.value = loggedIn; + loggedInCache.fetchedAt = Date.now(); + return loggedIn; + } catch { + return false; + } +} + +function bgRefreshLoggedIn() { + if (loggedInCache.refreshing) return; + const bin = getTailscaleBin(); + if (!bin) { + loggedInCache.value = false; + loggedInCache.fetchedAt = Date.now(); + return; + } + loggedInCache.refreshing = true; + // Dual-socket aware: probe custom socket first, then system socket + probeStatusAsync(bin) + .then((json) => { + loggedInCache.value = !!json && json.BackendState === "Running" && json.Self?.Online === true; + }) + .catch(() => { loggedInCache.value = false; }) + .finally(() => { + loggedInCache.fetchedAt = Date.now(); + loggedInCache.refreshing = false; + }); +} + +// Probe `status --json` over custom then system socket. Resolves parsed JSON or null. Never blocks event loop. +async function probeStatusAsync(bin) { + for (const socketArgs of [SOCKET_FLAG, SYSTEM_SOCKET_FLAG]) { + try { + const { stdout } = await execAsync(`"${bin}" ${socketArgs.join(" ")} status --json`, { + windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: PROBE_TIMEOUT_MS, + }); + return JSON.parse(stdout); + } catch { /* try next socket */ } + } + return null; +} + +// Sync getter: never blocks; returns last known state, refreshes in background +export function isTailscaleLoggedIn() { + if (Date.now() - loggedInCache.fetchedAt > PROBE_TTL_MS) bgRefreshLoggedIn(); + return loggedInCache.value; +} + +function bgRefreshRunning() { + if (runningCache.refreshing) return; + const bin = getTailscaleBin(); + if (!bin) { + runningCache.value = false; + runningCache.fetchedAt = Date.now(); + return; + } + runningCache.refreshing = true; + execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json`, { windowsHide: true, timeout: PROBE_TIMEOUT_MS }) + .then(({ stdout }) => { + try { + const json = JSON.parse(stdout); + runningCache.value = Object.keys(json.AllowFunnel || {}).length > 0; + } catch { runningCache.value = false; } + }) + .catch(() => { runningCache.value = false; }) + .finally(() => { + runningCache.fetchedAt = Date.now(); + runningCache.refreshing = false; + }); +} + +// Sync getter: never blocks; returns last known state, refreshes in background +export function isTailscaleRunning() { + if (Date.now() - runningCache.fetchedAt > PROBE_TTL_MS) bgRefreshRunning(); + return runningCache.value; +} + +// Async strict probe for hot user-initiated paths (enable/connect flow). +// Awaitable, never blocks event loop; updates cache as a side effect. +export async function isTailscaleRunningStrict() { + const bin = getTailscaleBin(); + if (!bin) return false; + try { + const { stdout } = await execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json`, { + windowsHide: true, + timeout: PROBE_TIMEOUT_MS, + }); + const json = JSON.parse(stdout); + const running = Object.keys(json.AllowFunnel || {}).length > 0; + runningCache.value = running; + runningCache.fetchedAt = Date.now(); + return running; + } catch { + return false; + } +} + +// Check if a system-level tailscaled is running (uses system socket, not 9Router's custom one). +export function isSystemDaemonRunning() { + if (IS_WINDOWS || !SYSTEM_TAILSCALE_SOCKET || !fs.existsSync(SYSTEM_TAILSCALE_SOCKET)) return false; + const bin = getTailscaleBin(); + if (!bin) return false; + try { + const out = execSync(`"${bin}" ${SYSTEM_SOCKET_FLAG.join(" ")} status --json`, { + encoding: "utf8", windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: PROBE_TIMEOUT_MS, + }); + return JSON.parse(out).BackendState === "Running"; + } catch { + return false; + } +} + +function bgRefreshFunnelUrl(port) { + if (funnelUrlCache.refreshing) return; + const bin = getTailscaleBin(); + if (!bin) return; + funnelUrlCache.refreshing = true; + execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { windowsHide: true, timeout: PROBE_TIMEOUT_MS }) + .then(({ stdout }) => { + try { + const json = JSON.parse(stdout); + const dnsName = json.Self?.DNSName?.replace(/\.$/, ""); + funnelUrlCache.value = dnsName ? `https://${dnsName}` : null; + } catch { /* keep prev */ } + }) + .catch(() => { /* keep prev */ }) + .finally(() => { + funnelUrlCache.port = port; + funnelUrlCache.fetchedAt = Date.now(); + funnelUrlCache.refreshing = false; + }); +} + +/** Get actual funnel URL from Self.DNSName (sync, authoritative — avoids hostname-conflict suffix). */ +function getActualFunnelUrl() { + const bin = getTailscaleBin(); + if (!bin) return null; + try { + const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { + encoding: "utf8", + windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH }, + timeout: 5000, + }); + const json = JSON.parse(out); + const dnsName = json.Self?.DNSName?.replace(/\.$/, ""); + return dnsName ? `https://${dnsName}` : null; + } catch { return null; } +} + +/** Get funnel URL from tailscale status (cached, non-blocking) */ +export function getTailscaleFunnelUrl(port) { + if (Date.now() - funnelUrlCache.fetchedAt > PROBE_TTL_MS || funnelUrlCache.port !== port) { + bgRefreshFunnelUrl(port); + } + return funnelUrlCache.value; +} + +/** + * Install tailscale. + * - macOS + brew: brew install tailscale (no sudo needed) + * - macOS no brew: download .pkg then sudo installer -pkg + * - Linux: fetch install.sh, pipe to sudo -S sh via stdin + * - Windows: download MSI via UAC-elevated PowerShell + */ +export async function installTailscale(sudoPassword, hostname, onProgress) { + const log = onProgress || (() => {}); + if (IS_WINDOWS) { + await installTailscaleWindows(log); + return { success: true }; + } + if (IS_MAC) await installTailscaleMac(sudoPassword, log); + else await installTailscaleLinux(sudoPassword, log); + + log("Starting daemon..."); + await startDaemonWithPassword(sudoPassword); + log("Logging in..."); + return startLogin(hostname); +} + +const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/sbin:/usr/bin:/bin:/snap/bin:${process.env.PATH || ""}`; + +function hasBrew() { + try { execSync("which brew", { stdio: "ignore", windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH } }); return true; } catch { return false; } +} + +async function installTailscaleMac(sudoPassword, log) { + if (hasBrew()) { + log("Installing via Homebrew..."); + await new Promise((resolve, reject) => { + const child = spawn("brew", ["install", "tailscale"], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH } + }); + child.stdout.on("data", (d) => { + const line = d.toString().trim(); + if (line) log(line); + }); + child.stderr.on("data", (d) => { + const line = d.toString().trim(); + if (line) log(line); + }); + child.on("close", (c) => { + if (c === 0) resolve(); + else reject(new Error(`brew install failed (code ${c})`)); + }); + child.on("error", reject); + }); + return; + } + + // No brew: download .pkg and install via sudo installer + const pkgUrl = "https://pkgs.tailscale.com/stable/tailscale-latest.pkg"; + const pkgPath = path.join(os.tmpdir(), "tailscale.pkg"); + + log("Downloading Tailscale package..."); + await new Promise((resolve, reject) => { + const child = spawn("curl", ["-fL", "--progress-bar", pkgUrl, "-o", pkgPath], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + child.stderr.on("data", (d) => { + const line = d.toString().trim(); + if (line) log(line); + }); + child.on("close", (c) => { + if (c === 0) resolve(); + else reject(new Error("Download failed")); + }); + child.on("error", reject); + }); + + log("Installing package..."); + await new Promise((resolve, reject) => { + const child = spawn("sudo", ["-S", "installer", "-pkg", pkgPath, "-target", "/"], { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true + }); + let stderr = ""; + child.stderr.on("data", (d) => { stderr += d.toString(); }); + child.stdout.on("data", (d) => { + const line = d.toString().trim(); + if (line) log(line); + }); + child.on("close", (c) => { + try { execSync(`rm -f ${pkgPath}`, { stdio: "ignore", windowsHide: true }); } catch { /* ignore */ } + if (c === 0) resolve(); + else { + const msg = (stderr.includes("incorrect password") || stderr.includes("Sorry")) + ? "Wrong sudo password" + : stderr || `Exit code ${c}`; + reject(new Error(msg)); + } + }); + child.on("error", reject); + child.stdin.write(`${sudoPassword}\n`); + child.stdin.end(); + }); +} + +async function installTailscaleLinux(sudoPassword, log) { + // Reject password containing newline → prevents stdin command injection + if (typeof sudoPassword !== "string" || sudoPassword.includes("\n")) { + throw new Error("Invalid sudo password"); + } + log("Downloading install script..."); + return new Promise((resolve, reject) => { + const curlChild = spawn("curl", ["-fsSL", "https://tailscale.com/install.sh"], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + let scriptContent = ""; + let curlErr = ""; + curlChild.stdout.on("data", (d) => { scriptContent += d.toString(); }); + curlChild.stderr.on("data", (d) => { curlErr += d.toString(); }); + curlChild.on("exit", (code) => { + if (code !== 0) return reject(new Error(`Failed to download install script: ${curlErr}`)); + log("Running install script..."); + // Persist script to temp file → exec by path (NOT via stdin) → sh never reads attacker-controlled stdin + const tmpScript = path.join(os.tmpdir(), `tailscale-install-${crypto.randomBytes(8).toString("hex")}.sh`); + try { + fs.writeFileSync(tmpScript, scriptContent, { mode: 0o700 }); + } catch (e) { + return reject(new Error(`Failed to write install script: ${e.message}`)); + } + const cleanup = () => { try { fs.unlinkSync(tmpScript); } catch {} }; + const child = spawn("sudo", ["-S", "sh", tmpScript], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); + let stderr = ""; + child.stdout.on("data", (d) => { + const line = d.toString().trim(); + if (line) log(line); + }); + child.stderr.on("data", (d) => { stderr += d.toString(); }); + child.on("close", (c) => { + cleanup(); + if (c === 0) resolve(); + else { + const msg = (stderr.includes("incorrect password") || stderr.includes("Sorry")) + ? "Wrong sudo password" + : stderr || `Exit code ${c}`; + reject(new Error(msg)); + } + }); + child.on("error", (e) => { cleanup(); reject(e); }); + child.stdin.write(`${sudoPassword}\n`); + child.stdin.end(); + }); + curlChild.on("error", reject); + }); +} + +async function installTailscaleWindows(log) { + const msiUrl = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi"; + const msiPath = path.join(os.tmpdir(), "tailscale-setup.msi"); + + // Download MSI via curl.exe (built-in on Win10+) — no PowerShell window, streams progress + log("Downloading Tailscale installer..."); + await new Promise((resolve, reject) => { + const child = spawn("curl.exe", ["-L", "-#", "-o", msiPath, msiUrl], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + // curl outputs progress to stderr with -# flag + let lastPct = ""; + child.stderr.on("data", (d) => { + const text = d.toString(); + const match = text.match(/(\d+\.\d)%/); + if (match && match[1] !== lastPct) { + lastPct = match[1]; + log(`Downloading... ${lastPct}%`); + } + }); + child.on("close", (c) => c === 0 ? resolve() : reject(new Error("Download failed"))); + child.on("error", reject); + }); + + // Install MSI with UAC elevation via PowerShell Start-Process -Verb RunAs + log("Installing Tailscale (UAC prompt may appear)..."); + await new Promise((resolve, reject) => { + const args = `'/i','${msiPath}','TS_NOLAUNCH=true','/quiet','/norestart'`; + const child = spawn("powershell", [ + "-NoProfile", "-NonInteractive", "-Command", + `Start-Process msiexec -ArgumentList ${args} -Verb RunAs -Wait` + ], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); + child.stderr.on("data", (d) => { const l = d.toString().trim(); if (l) log(l); }); + child.on("close", (c) => { + try { fs.unlinkSync(msiPath); } catch { /* ignore */ } + c === 0 ? resolve() : reject(new Error(`msiexec failed (code ${c})`)); + }); + child.on("error", reject); + }); + + // Verify tailscale.exe exists after install + log("Verifying installation..."); + const maxWait = 10000; + const start = Date.now(); + while (Date.now() - start < maxWait) { + if (fs.existsSync(WINDOWS_TAILSCALE_BIN)) { + log("Installation complete."); + return; + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error("Installation finished but tailscale.exe not found"); +} + +// Self-heal: if state dir/files were previously created by root (e.g. legacy sudo daemon), +// reclaim ownership recursively so the user-mode daemon can read/write state files. +async function ensureUserOwnedDir(dir) { + try { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + return; + } + const uid = process.getuid(); + const gid = process.getgid(); + + // Walk dir + all entries to find any non-user-owned items + const needsChown = (() => { + const stack = [dir]; + while (stack.length) { + const cur = stack.pop(); + try { + const st = fs.statSync(cur); + if (st.uid !== uid) return true; + if (st.isDirectory()) { + for (const name of fs.readdirSync(cur)) stack.push(path.join(cur, name)); + } + } catch { /* ignore */ } + } + return false; + })(); + + if (!needsChown) return; + + // Try direct chown first (works if already owned). Fallback to passwordless sudo. + try { + execSync(`chown -R ${uid}:${gid} "${dir}"`, { stdio: "ignore", timeout: 3000 }); + } catch { + try { execSync(`sudo -n chown -R ${uid}:${gid} "${dir}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ } + } + } catch { /* ignore */ } +} + +/** Check if running daemon uses TUN mode (Funnel TLS requires TUN). */ +function isDaemonTunMode() { + try { + const ps = execSync(`pgrep -af "tailscaled.*${TAILSCALE_SOCKET}"`, { encoding: "utf8", timeout: 2000 }).trim(); + if (!ps) return null; + return !ps.includes("--tun=userspace-networking"); + } catch { return null; } +} + +/** Daemon process alive (independent of funnel state) — mirrors cloudflared PID check semantic. */ +export function isDaemonAlive() { + return isDaemonTunMode() !== null; +} + +/** + * Start tailscaled. + * - With sudoPassword: TUN mode (root) → Funnel TLS works + * - Without: userspace-networking fallback (no sudo, but Funnel TLS unstable) + * State always lives in ~/.9router/tailscale/ via --statedir. + */ +export async function startDaemonWithPassword(sudoPassword) { + if (IS_WINDOWS) { + // Windows: tailscale runs as a Windows Service. Start it then poll BackendState + // until daemon finishes init (avoids "NoState" errors when calling funnel/up too early). + const bin = getTailscaleBin(); + console.log("[Tailscale] win: net start Tailscale"); + try { execSync("net start Tailscale", { stdio: "ignore", windowsHide: true, timeout: 10000 }); } + catch { /* may need admin, or already running */ } + if (!bin) return; + // Poll up to ~10s for backend to leave NoState + for (let i = 0; i < 20; i++) { + try { + const out = execSync(`"${bin}" status --json`, { encoding: "utf8", windowsHide: true, timeout: 2000 }); + const j = JSON.parse(out); + if (j.BackendState && j.BackendState !== "NoState") { + console.log(`[Tailscale] win: BackendState=${j.BackendState} after ${i*500}ms`); + return; + } + } catch { /* daemon not ready */ } + await new Promise((r) => setTimeout(r, 500)); + } + console.log("[Tailscale] win: BackendState still NoState after poll"); + return; + } + + const currentMode = isDaemonTunMode(); // true=TUN, false=userspace, null=not running + // No password but a healthy TUN daemon already runs → keep TUN, never downgrade-kill it. + const wantTun = sudoPassword ? true : currentMode === true; + + // Daemon already running in correct mode → reuse + if (currentMode !== null && currentMode === wantTun) { + try { + const bin = getTailscaleBin() || "tailscale"; + execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { + stdio: "ignore", windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH }, timeout: 3000 + }); + return; + } catch { /* unresponsive, restart below */ } + } + + // Mode mismatch or unresponsive → kill all daemons on our socket + try { execSync(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ } + if (sudoPassword) { + try { await execWithPassword(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, sudoPassword); } catch { /* ignore */ } + } else { + try { execSync(`sudo -n pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ } + } + await new Promise((r) => setTimeout(r, 1500)); + + // Reclaim folder ownership (previous root daemon may have locked it) + await ensureUserOwnedDir(TAILSCALE_DIR); + + const tailscaledBin = IS_MAC ? "/usr/local/bin/tailscaled" : "tailscaled"; + const daemonArgs = [ + `--socket=${TAILSCALE_SOCKET}`, + `--statedir=${TAILSCALE_DIR}`, + ]; + if (!wantTun) daemonArgs.push("--tun=userspace-networking"); + + if (wantTun) { + // TUN mode: spawn via sudo, password via stdin. Detached so it survives parent exit. + const child = spawn("sudo", ["-S", tailscaledBin, ...daemonArgs], { + detached: true, + stdio: ["pipe", "ignore", "ignore"], + cwd: os.tmpdir(), + env: { ...process.env, PATH: EXTENDED_PATH }, + }); + child.stdin.write(`${sudoPassword}\n`); + child.stdin.end(); + child.unref(); + } else { + const child = spawn(tailscaledBin, daemonArgs, { + detached: true, + stdio: "ignore", + cwd: os.tmpdir(), + env: { ...process.env, PATH: EXTENDED_PATH }, + }); + child.unref(); + } + + // Wait for socket ready + await new Promise((r) => setTimeout(r, 3000)); +} + +/** Best-effort: ensure daemon running (used for login flow) */ +function ensureDaemon() { + startDaemonWithPassword("").catch(() => {}); +} + +/** Read AuthURL from `tailscale status --json` (Win exposes it there, not stdout). */ +function getAuthUrlFromStatus() { + const bin = getTailscaleBin(); + if (!bin) return null; + try { + const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { + encoding: "utf8", windowsHide: true, timeout: 2000 + }); + const j = JSON.parse(out); + if (j.AuthURL) return j.AuthURL; + return null; + } catch { return null; } +} + +/** + * Run `tailscale up` and capture the auth URL for browser login. + * Resolves with { authUrl } or { alreadyLoggedIn: true }. + * On Windows, AuthURL comes from `status --json` (not stdout) — must poll status. + */ +export function startLogin(hostname) { + const bin = getTailscaleBin(); + if (!bin) return Promise.reject(new Error("Tailscale not installed")); + + return new Promise((resolve, reject) => { + // Ensure daemon is running (best-effort, no sudo) + ensureDaemon(); + + // Check if already logged in + if (isTailscaleLoggedIn()) { + resolve({ alreadyLoggedIn: true }); + return; + } + + const args = tsArgs("up", "--accept-routes"); + if (hostname) args.push(`--hostname=${hostname}`); + const child = spawn(bin, args, { + stdio: ["ignore", "pipe", "pipe"], + detached: true, + windowsHide: true + }); + + let resolved = false; + let output = ""; + + const parseAuthUrl = (text) => { + const match = text.match(/https:\/\/login\.tailscale\.com\/a\/[a-zA-Z0-9]+/); + return match ? match[0] : null; + }; + + const finishWithUrl = (url, source) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + clearInterval(statusPoll); + console.log(`[Tailscale] login authUrl detected (${source})`); + child.unref(); + resolve({ authUrl: url }); + }; + + // Poll status --json every 500ms — Windows exposes AuthURL only there + const statusPoll = setInterval(() => { + if (resolved) return; + const url = getAuthUrlFromStatus(); + if (url) finishWithUrl(url, "status"); + }, 500); + + const timeout = setTimeout(() => { + if (resolved) return; + resolved = true; + clearInterval(statusPoll); + child.unref(); + const url = parseAuthUrl(output) || getAuthUrlFromStatus(); + if (url) resolve({ authUrl: url }); + else reject(new Error("tailscale up timed out without auth URL")); + }, 15000); + + const handleData = (data) => { + output += data.toString(); + const url = parseAuthUrl(output); + if (url) finishWithUrl(url, "stdout"); + }; + + child.stdout.on("data", handleData); + child.stderr.on("data", handleData); + + child.on("error", (err) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + clearInterval(statusPoll); + console.error(`[Tailscale] login spawn error: ${err.message}`); + reject(err); + }); + + child.on("exit", (code) => { + if (resolved) return; + console.log(`[Tailscale] login exit code=${code}`); + // Don't trust exit code alone — Win `tailscale up` exits 0 even when not logged in. + // Let status poll continue until AuthURL appears or timeout. + const url = parseAuthUrl(output) || getAuthUrlFromStatus(); + if (url) { + finishWithUrl(url, "exit"); + return; + } + // Only resolve alreadyLoggedIn if status confirms BackendState=Running + if (isTailscaleLoggedIn()) { + resolved = true; + clearTimeout(timeout); + clearInterval(statusPoll); + resolve({ alreadyLoggedIn: true }); + return; + } + // Otherwise keep polling — daemon may publish AuthURL shortly after exit + }); + }); +} + +/** Start tailscale funnel for the given port */ +export async function startFunnel(port) { + const bin = getTailscaleBin(); + if (!bin) throw new Error("Tailscale not installed"); + + // Reset any existing funnel + try { execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel --bg reset`, { stdio: "ignore", windowsHide: true }); } catch (e) { /* ignore */ } + + return new Promise((resolve, reject) => { + const child = spawn(bin, tsArgs("funnel", "--bg", `${port}`), { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + + let resolved = false; + let output = ""; + + const timeout = setTimeout(() => { + if (resolved) return; + resolved = true; + // --bg exits after setup, read actual hostname from status + const url = getActualFunnelUrl() || getTailscaleFunnelUrl(port); + if (url) resolve({ tunnelUrl: url }); + else reject(new Error(`Tailscale funnel timed out: ${output.trim() || "no output"}`)); + }, 30000); + + // Always resolve via Self.DNSName to get the real hostname (avoids -1 suffix from conflicts) + const parseFunnelUrl = () => getActualFunnelUrl(); + + let funnelNotEnabled = false; + + const handleData = (data) => { + output += data.toString(); + + if (output.includes("Funnel is not enabled")) funnelNotEnabled = true; + + // Wait for the enable URL to arrive in a later chunk + if (funnelNotEnabled && !resolved) { + const enableMatch = output.match(/https:\/\/login\.tailscale\.com\/[^\s]+/); + if (enableMatch) { + resolved = true; + clearTimeout(timeout); + child.kill(); + resolve({ funnelNotEnabled: true, enableUrl: enableMatch[0] }); + return; + } + } + + const url = parseFunnelUrl(); + if (url && !resolved) { + resolved = true; + clearTimeout(timeout); + resolve({ tunnelUrl: url }); + } + }; + + child.stdout.on("data", handleData); + child.stderr.on("data", handleData); + + child.on("exit", (code) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + console.log(`[Tailscale] funnel exit code=${code} output="${output.trim().slice(0, 200)}"`); + const url = parseFunnelUrl() || getTailscaleFunnelUrl(port); + if (url) resolve({ tunnelUrl: url }); + else reject(new Error(`tailscale funnel failed (code ${code}): ${output.trim()}`)); + }); + + child.on("error", (err) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + reject(err); + }); + }); +} + +/** Provision TLS cert for funnel domain (required before Funnel serves HTTPS). Best-effort. */ +export async function provisionCert(hostname) { + const bin = getTailscaleBin(); + if (!bin || !hostname) return; + const certsDir = path.join(TAILSCALE_DIR, "certs"); + fs.mkdirSync(certsDir, { recursive: true }); + const certFile = path.join(certsDir, `${hostname}.crt`); + const keyFile = path.join(certsDir, `${hostname}.key`); + try { + await execAsync( + `"${bin}" ${SOCKET_FLAG.join(" ")} cert --cert-file "${certFile}" --key-file "${keyFile}" "${hostname}"`, + { windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: 30000 } + ); + console.log(`[Tailscale] cert provisioned for ${hostname}`); + } catch (e) { + console.warn(`[Tailscale] cert provision failed (non-fatal): ${e.message}`); + } +} + +/** Stop tailscale funnel */ +export function stopFunnel() { + const bin = getTailscaleBin(); + if (!bin) return; + try { execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel --bg reset`, { stdio: "ignore", windowsHide: true }); } catch (e) { /* ignore */ } +} + +/** Kill tailscaled daemon (runs as root, needs sudo) */ +export async function stopDaemon(sudoPassword) { + // Try non-sudo first + try { execSync("pkill -x tailscaled", { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { /* ignore */ } + + // Check if still alive + try { execSync("pgrep -x tailscaled", { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { return; } // Dead, done + + // Kill with sudo password + if (!IS_WINDOWS) { + try { await execWithPassword("pkill -x tailscaled", sudoPassword || ""); } catch { /* ignore */ } + } + + // Cleanup socket + try { if (fs.existsSync(TAILSCALE_SOCKET)) fs.unlinkSync(TAILSCALE_SOCKET); } catch { /* ignore */ } +} diff --git a/src/lib/updater/updater.js b/src/lib/updater/updater.js new file mode 100644 index 0000000..0ad48af --- /dev/null +++ b/src/lib/updater/updater.js @@ -0,0 +1,235 @@ +// Standalone detached updater process. +// Spawns `npm i -g @latest`, exposes progress via tiny HTTP server. +// Survives after parent Next server exits (detached + unref by spawner). + +const { spawn } = require("child_process"); +const http = require("http"); +const net = require("net"); +const path = require("path"); +const fs = require("fs"); +const os = require("os"); + +const packageName = process.env.UPDATER_PKG_NAME || "9router"; +const port = parseInt(process.env.UPDATER_PORT || "20129", 10); +const tailLines = parseInt(process.env.UPDATER_TAIL_LINES || "8", 10); +const maxRetries = parseInt(process.env.UPDATER_RETRIES || "3", 10); +const retryDelayMs = parseInt(process.env.UPDATER_RETRY_DELAY_MS || "5000", 10); +const lingerMs = parseInt(process.env.UPDATER_LINGER_MS || "30000", 10); +const waitMinMs = parseInt(process.env.UPDATER_WAIT_MIN_MS || "3000", 10); +const waitMaxMs = parseInt(process.env.UPDATER_WAIT_MAX_MS || "15000", 10); +const waitCheckMs = parseInt(process.env.UPDATER_WAIT_CHECK_MS || "500", 10); +const appPort = parseInt(process.env.UPDATER_APP_PORT || "20128", 10); + +// Data directory (match mitm/paths.js logic) +function getDataDir() { + if (process.env.DATA_DIR) return process.env.DATA_DIR; + if (process.platform === "win32") { + return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "9router"); + } + return path.join(os.homedir(), ".9router"); +} +const updateDir = path.join(getDataDir(), "update"); +try { fs.mkdirSync(updateDir, { recursive: true }); } catch { /* best effort */ } +const statusFile = path.join(updateDir, "status.json"); +const logFile = path.join(updateDir, "install.log"); + +const state = { + phase: "starting", + packageName, + startedAt: Date.now(), + finishedAt: null, + attempt: 0, + maxRetries, + done: false, + success: false, + exitCode: null, + error: null, + logTail: [], +}; + +function pushLog(line) { + const trimmed = line.replace(/\r?\n$/, ""); + if (!trimmed) return; + state.logTail.push(trimmed); + if (state.logTail.length > tailLines) state.logTail = state.logTail.slice(-tailLines); + try { fs.appendFileSync(logFile, `${trimmed}\n`); } catch { /* best effort */ } +} + +function persistStatus() { + try { fs.writeFileSync(statusFile, JSON.stringify(state, null, 2)); } catch { /* best effort */ } +} + +function setPhase(phase) { + state.phase = phase; + persistStatus(); +} + +// HTTP server exposing status (browser polls this while Next server is dead) +const server = http.createServer((req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Cache-Control", "no-store"); + if (req.url === "/update/status" || req.url === "/") { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(state)); + return; + } + res.statusCode = 404; + res.end("not found"); +}); + +server.on("error", (e) => { + state.error = `status server error: ${e.message}`; + persistStatus(); +}); + +server.listen(port, "127.0.0.1", () => { + persistStatus(); + waitForAppExit().then(runInstall); +}); + +// Check if app port is still being listened on (= app server still alive) +function isAppPortBusy() { + return new Promise((resolve) => { + const socket = new net.Socket(); + const done = (busy) => { + socket.destroy(); + resolve(busy); + }; + socket.setTimeout(300); + socket.once("connect", () => done(true)); + socket.once("timeout", () => done(false)); + socket.once("error", () => done(false)); + socket.connect(appPort, "127.0.0.1"); + }); +} + +// Wait for app process to fully exit before running npm (avoids Windows file-lock) +async function waitForAppExit() { + setPhase("waitingForExit"); + pushLog(`[updater] waiting for app to exit (min ${Math.round(waitMinMs / 1000)}s)...`); + + // Hard minimum delay: OS needs time to release file handles + await sleep(waitMinMs); + + // Poll app port until free or max timeout + const deadline = Date.now() + (waitMaxMs - waitMinMs); + while (Date.now() < deadline) { + const busy = await isAppPortBusy(); + if (!busy) { + pushLog(`[updater] app port :${appPort} is free, proceeding`); + return; + } + await sleep(waitCheckMs); + } + pushLog(`[updater] timeout waiting for app, proceeding anyway`); +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +function runInstall() { + state.attempt += 1; + setPhase("installing"); + pushLog(`[updater] attempt ${state.attempt}/${maxRetries} — npm i -g ${packageName} --prefer-online`); + + const isWin = process.platform === "win32"; + const cmd = isWin ? "npm.cmd" : "npm"; + const args = ["i", "-g", packageName, "--prefer-online"]; + + const child = spawn(cmd, args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + shell: isWin, + }); + + child.stdout.on("data", (buf) => { + buf.toString().split(/\r?\n/).forEach(pushLog); + persistStatus(); + }); + child.stderr.on("data", (buf) => { + buf.toString().split(/\r?\n/).forEach(pushLog); + persistStatus(); + }); + + child.on("error", (e) => { + pushLog(`[updater] spawn error: ${e.message}`); + finalize(false, null, e.message); + }); + + child.on("close", (code) => { + pushLog(`[updater] npm exited with code ${code}`); + if (code === 0) { + finalize(true, code, null); + return; + } + if (state.attempt < maxRetries) { + pushLog(`[updater] retrying in ${Math.round(retryDelayMs / 1000)}s...`); + setTimeout(runInstall, retryDelayMs); + return; + } + finalize(false, code, `Install failed after ${maxRetries} attempts`); + }); +} + +function openBrowser(url) { + const platform = process.platform; + const cmd = platform === "darwin" ? `open "${url}"` + : platform === "win32" ? `start "" "${url}"` + : `xdg-open "${url}"`; + try { spawn(cmd, { shell: true, detached: true, stdio: "ignore" }).unref(); } catch { /* ignore */ } +} + +// Wait until app port is listening (server alive again), then open dashboard +async function waitForAppAndOpenBrowser() { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + const busy = await isAppPortBusy(); + if (busy) { + openBrowser(`http://localhost:${appPort}/dashboard`); + pushLog(`[updater] app ready, opened dashboard`); + return; + } + await sleep(1000); + } + pushLog(`[updater] app not responding within 30s, skip browser open`); +} + +function relaunchApp() { + if (process.env.UPDATER_RELAUNCH !== "1") return; + const cmd = process.env.UPDATER_RELAUNCH_CMD; + if (!cmd) return; + let args = []; + try { args = JSON.parse(process.env.UPDATER_RELAUNCH_ARGS || "[]"); } catch { /* noop */ } + const isWin = process.platform === "win32"; + try { + const child = spawn(cmd, args, { + detached: true, + stdio: "ignore", + windowsHide: true, + shell: isWin, + env: { ...process.env, UPDATER_RELAUNCH: "", UPDATER_RELAUNCH_CMD: "", UPDATER_RELAUNCH_ARGS: "" }, + }); + child.unref(); + pushLog(`[updater] relaunched: ${cmd} ${args.join(" ")} (pid=${child.pid})`); + // Wait for new app to come up, then auto-open browser so user sees the result + waitForAppAndOpenBrowser(); + } catch (e) { + pushLog(`[updater] relaunch failed: ${e.message}`); + } +} + +function finalize(success, exitCode, error) { + state.done = true; + state.success = success; + state.exitCode = exitCode; + state.error = error; + state.finishedAt = Date.now(); + setPhase(success ? "done" : "error"); + if (success) relaunchApp(); + // Linger so browser can poll final status, then exit & close the port + setTimeout(() => { + try { server.close(); } catch { /* ignore */ } + process.exit(success ? 0 : 1); + }, lingerMs); +} diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js new file mode 100644 index 0000000..d56d58a --- /dev/null +++ b/src/lib/usageDb.js @@ -0,0 +1,7 @@ +// Shim → re-export from new SQLite-based DB layer (src/lib/db/) +export { + statsEmitter, trackPendingRequest, getActiveRequests, + saveRequestUsage, getUsageHistory, getUsageStats, getChartData, + appendRequestLog, getRecentLogs, + saveRequestDetail, getRequestDetails, getRequestDetailById, +} from "@/lib/db/index.js"; diff --git a/src/mitm/antigravityIdeVersion.js b/src/mitm/antigravityIdeVersion.js new file mode 100644 index 0000000..1b9458d --- /dev/null +++ b/src/mitm/antigravityIdeVersion.js @@ -0,0 +1,50 @@ +"use strict"; + +// Rewrite Antigravity IDE markers so upstream AG 2.x backend accepts the request. +// User-Agent header (antigravity/) and body.metadata.ideVersion are forced +// to a known-good IDE version. Hardcoded MVP — toggle/version configurable later. + +const ANTIGRAVITY_IDE_VERSION = "1.23.2"; +const ANTIGRAVITY_IDE_VERSION_OVERRIDE_ENABLED = true; + +function shouldRewriteMetadata(metadata) { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return false; + if (String(metadata.ideName || "").toLowerCase() === "antigravity") return true; + if (String(metadata.ideType || "").toUpperCase() === "ANTIGRAVITY") return true; + return Object.prototype.hasOwnProperty.call(metadata, "ideVersion"); +} + +function rewriteAntigravityUserAgent(userAgent, version) { + if (typeof userAgent !== "string" || !userAgent.includes("antigravity/")) return userAgent; + return userAgent.replace(/antigravity\/[^\s]+/, `antigravity/${version}`); +} + +function applyAntigravityIdeVersionOverride(bodyBuffer, headers) { + if (!ANTIGRAVITY_IDE_VERSION_OVERRIDE_ENABLED) { + return { bodyBuffer, headers, applied: false, version: ANTIGRAVITY_IDE_VERSION }; + } + + const nextHeaders = { ...headers }; + const nextUserAgent = rewriteAntigravityUserAgent(nextHeaders["user-agent"], ANTIGRAVITY_IDE_VERSION); + const userAgentChanged = nextUserAgent !== nextHeaders["user-agent"]; + if (userAgentChanged) nextHeaders["user-agent"] = nextUserAgent; + + try { + const parsed = JSON.parse(bodyBuffer.toString()); + if (!shouldRewriteMetadata(parsed?.metadata)) { + return { bodyBuffer, headers: nextHeaders, applied: userAgentChanged, version: ANTIGRAVITY_IDE_VERSION }; + } + + parsed.metadata.ideVersion = ANTIGRAVITY_IDE_VERSION; + const nextBodyBuffer = Buffer.from(JSON.stringify(parsed)); + return { bodyBuffer: nextBodyBuffer, headers: nextHeaders, applied: true, version: ANTIGRAVITY_IDE_VERSION }; + } catch { + return { bodyBuffer, headers: nextHeaders, applied: userAgentChanged, version: ANTIGRAVITY_IDE_VERSION }; + } +} + +module.exports = { + ANTIGRAVITY_IDE_VERSION, + applyAntigravityIdeVersionOverride, + rewriteAntigravityUserAgent, +}; diff --git a/src/mitm/cert/generate.js b/src/mitm/cert/generate.js new file mode 100644 index 0000000..e85f9dc --- /dev/null +++ b/src/mitm/cert/generate.js @@ -0,0 +1,32 @@ +const path = require("path"); +const fs = require("fs"); +const { MITM_DIR } = require("../paths"); +const { generateRootCA, loadRootCA, generateLeafCert } = require("./rootCA"); + +/** + * Generate Root CA certificate (one-time setup) + * This replaces the old static wildcard cert approach + */ +function generateCert() { + return generateRootCA(); +} + +/** + * Get certificate for a specific domain (dynamic generation) + * Used by SNICallback in server.js + */ +function getCertForDomain(domain) { + try { + const rootCA = loadRootCA(); + const leafCert = generateLeafCert(domain, rootCA); + return { + key: leafCert.key, + cert: leafCert.cert + }; + } catch (error) { + console.error(`Failed to generate cert for ${domain}:`, error.message); + return null; + } +} + +module.exports = { generateCert, getCertForDomain }; diff --git a/src/mitm/cert/install.js b/src/mitm/cert/install.js new file mode 100644 index 0000000..f8f8fe7 --- /dev/null +++ b/src/mitm/cert/install.js @@ -0,0 +1,269 @@ +const fs = require("fs"); +const crypto = require("crypto"); +const { exec } = require("child_process"); +const { execWithPassword, isSudoAvailable } = require("../dns/dnsConfig.js"); +const { runElevatedPowerShell, quotePs } = require("../winElevated.js"); +const { log, err } = require("../logger"); + +const IS_WIN = process.platform === "win32"; +const IS_MAC = process.platform === "darwin"; +const LINUX_CERT_PATHS = [ + // Debian / Ubuntu + { dir: "/usr/local/share/ca-certificates", cmd: "update-ca-certificates" }, + // Arch Linux / CachyOS / Manjaro + { dir: "/etc/ca-certificates/trust-source/anchors", cmd: "update-ca-trust" }, + // Fedora / RHEL / CentOS + { dir: "/etc/pki/ca-trust/source/anchors", cmd: "update-ca-trust" }, + // openSUSE + { dir: "/etc/pki/trust/anchors", cmd: "update-ca-certificates" } +]; + +function getLinuxCertConfig() { + for (const config of LINUX_CERT_PATHS) { + if (fs.existsSync(config.dir)) { + return config; + } + } + // Fallback to Debian default if none exist + return LINUX_CERT_PATHS[0]; +} +const ROOT_CA_CN = "9Router MITM Root CA"; + +// Get SHA1 fingerprint from cert file using Node.js crypto +function getCertFingerprint(certPath) { + const pem = fs.readFileSync(certPath, "utf-8"); + const der = Buffer.from(pem.replace(/-----[^-]+-----/g, "").replace(/\s/g, ""), "base64"); + return crypto.createHash("sha1").update(der).digest("hex").toUpperCase().match(/.{2}/g).join(":"); +} + +/** + * Check if certificate is already installed in system store + */ +async function checkCertInstalled(certPath) { + if (IS_WIN) return checkCertInstalledWindows(certPath); + if (IS_MAC) return checkCertInstalledMac(certPath); + return checkCertInstalledLinux(); +} + +function checkCertInstalledMac(certPath) { + return new Promise((resolve) => { + try { + const fingerprint = getCertFingerprint(certPath).replace(/:/g, ""); + // Verify exact cert bytes match — same CN with different fingerprint = stale cert + exec(`security find-certificate -a -c "${ROOT_CA_CN}" -Z /Library/Keychains/System.keychain 2>/dev/null`, { windowsHide: true }, (error, stdout) => { + if (error || !stdout) return resolve(false); + const match = new RegExp(`SHA-1 hash:\\s*${fingerprint}`, "i").test(stdout); + if (!match) return resolve(false); + // Cert exists with matching fingerprint — confirm trust policy + exec(`security verify-cert -c "${certPath}" -p ssl -k /Library/Keychains/System.keychain 2>/dev/null`, { windowsHide: true }, (err2) => { + resolve(!err2); + }); + }); + } catch { + resolve(false); + } + }); +} + +function checkCertInstalledWindows(certPath) { + return new Promise((resolve) => { + // Check by SHA1 fingerprint — detects stale cert with same CN but different key + let fingerprint; + try { + fingerprint = getCertFingerprint(certPath).replace(/:/g, ""); + } catch { + return resolve(false); + } + exec(`certutil -store Root ${fingerprint}`, { windowsHide: true }, (error) => { + resolve(!error); + }); + }); +} + +/** + * Install SSL certificate to system trust store + */ +async function installCert(sudoPassword, certPath) { + if (!fs.existsSync(certPath)) { + throw new Error(`Certificate file not found: ${certPath}`); + } + + const isInstalled = await checkCertInstalled(certPath); + if (isInstalled) { + log("🔐 Cert: already trusted ✅"); + return; + } + + if (IS_WIN) { + await installCertWindows(certPath); + } else if (IS_MAC) { + await installCertMac(sudoPassword, certPath); + } else { + await installCertLinux(sudoPassword, certPath); + } +} + +async function installCertMac(sudoPassword, certPath) { + // Remove all old certs with same name first to avoid duplicate/stale cert conflict + const deleteOld = `security delete-certificate -c "9Router MITM Root CA" /Library/Keychains/System.keychain 2>/dev/null || true`; + const install = `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`; + try { + await execWithPassword(`${deleteOld} && ${install}`, sudoPassword); + log("🔐 Cert: ✅ installed to system keychain"); + } catch (error) { + const msg = error.message?.includes("canceled") ? "User canceled authorization" : "Certificate install failed"; + throw new Error(msg); + } +} + +async function installCertWindows(certPath) { + // Auto-elevate via UAC popup if not admin (zero popup if already admin). + // Delete any stale cert with same CN before adding to avoid duplicates. + const script = ` + certutil -delstore Root ${quotePs(ROOT_CA_CN)} 2>$null | Out-Null + $exit = & certutil -addstore Root ${quotePs(certPath)} 2>&1 + if ($LASTEXITCODE -ne 0) { throw "certutil exit $LASTEXITCODE" } + `; + try { + await runElevatedPowerShell(script); + log("🔐 Cert: ✅ installed to Windows Root store"); + } catch (e) { + throw new Error(`Failed to install certificate: ${e.message}`); + } +} + +/** + * Uninstall SSL certificate from system store + */ +async function uninstallCert(sudoPassword, certPath) { + const isInstalled = await checkCertInstalled(certPath); + if (!isInstalled) { + log("🔐 Cert: not found in system store"); + return; + } + + if (IS_WIN) { + await uninstallCertWindows(); + } else if (IS_MAC) { + await uninstallCertMac(sudoPassword, certPath); + } else { + await uninstallCertLinux(sudoPassword); + } +} + +async function uninstallCertMac(sudoPassword, certPath) { + const fingerprint = getCertFingerprint(certPath).replace(/:/g, ""); + const command = `security delete-certificate -Z "${fingerprint}" /Library/Keychains/System.keychain`; + try { + await execWithPassword(command, sudoPassword); + log("🔐 Cert: ✅ uninstalled from system keychain"); + } catch (err) { + throw new Error("Failed to uninstall certificate"); + } +} + +async function uninstallCertWindows() { + // Auto-elevate via UAC popup if not admin + const script = `certutil -delstore Root ${quotePs(ROOT_CA_CN)}`; + try { + await runElevatedPowerShell(script); + log("🔐 Cert: ✅ uninstalled from Windows Root store"); + } catch (e) { + throw new Error(`Failed to uninstall certificate: ${e.message}`); + } +} + +function checkCertInstalledLinux() { + const config = getLinuxCertConfig(); + const certFile = `${config.dir}/9router-root-ca.crt`; + return Promise.resolve(fs.existsSync(certFile)); +} + +async function updateNssDatabases(certPath, action = 'add') { + const certName = "9Router MITM Root CA"; + + const script = ` + if ! command -v certutil &> /dev/null; then + exit 0 + fi + + DIRS="$HOME/.pki/nssdb $HOME/snap/chromium/current/.pki/nssdb" + + if [ -d "$HOME/.mozilla/firefox" ]; then + for profile in "$HOME"/.mozilla/firefox/*/; do + if [ -f "\${profile}cert9.db" ] || [ -f "\${profile}cert8.db" ]; then + DIRS="$DIRS $profile" + fi + done + fi + + if [ -d "$HOME/snap/firefox/common/.mozilla/firefox" ]; then + for profile in "$HOME"/snap/firefox/common/.mozilla/firefox/*/; do + if [ -f "\${profile}cert9.db" ] || [ -f "\${profile}cert8.db" ]; then + DIRS="$DIRS $profile" + fi + done + fi + + for db in $DIRS; do + if [ -d "$db" ]; then + if [ "${action}" = "add" ]; then + certutil -d sql:"$db" -A -t "C,," -n "${certName}" -i "${certPath}" 2>/dev/null || \\ + certutil -d "$db" -A -t "C,," -n "${certName}" -i "${certPath}" 2>/dev/null || true + else + certutil -d sql:"$db" -D -n "${certName}" 2>/dev/null || \\ + certutil -d "$db" -D -n "${certName}" 2>/dev/null || true + fi + fi + done + `; + + return new Promise((resolve) => { + exec(script, { shell: "/bin/bash" }, () => resolve()); + }); +} + +async function installCertLinux(sudoPassword, certPath) { + if (!isSudoAvailable()) { + log(`🔐 Cert: cannot install to system store without sudo — trust this file on clients: ${certPath}`); + // Still try to update user NSS DBs even if no sudo! + await updateNssDatabases(certPath, 'add'); + return; + } + + const config = getLinuxCertConfig(); + const destFile = `${config.dir}/9router-root-ca.crt`; + + // Copy to the discovered directory and execute the specific update command + const cmd = `cp "${certPath}" "${destFile}" && (${config.cmd} 2>/dev/null || true)`; + + try { + await execWithPassword(cmd, sudoPassword); + await updateNssDatabases(certPath, 'add'); + log(`🔐 Cert: ✅ installed to Linux trust store (${config.dir}) and user browser databases`); + } catch (error) { + throw new Error(`Certificate install failed: ${error.message}`); + } +} + +async function uninstallCertLinux(sudoPassword) { + // Always try to uninstall from user DBs even without sudo + await updateNssDatabases(null, 'delete'); + + if (!isSudoAvailable()) { + return; + } + + const config = getLinuxCertConfig(); + const destFile = `${config.dir}/9router-root-ca.crt`; + const cmd = `rm -f "${destFile}" && (${config.cmd} 2>/dev/null || true)`; + + try { + await execWithPassword(cmd, sudoPassword); + log("🔐 Cert: ✅ uninstalled from Linux trust store and user browser databases"); + } catch (error) { + throw new Error("Failed to uninstall certificate"); + } +} + +module.exports = { installCert, uninstallCert, checkCertInstalled }; diff --git a/src/mitm/cert/rootCA.js b/src/mitm/cert/rootCA.js new file mode 100644 index 0000000..3f033f0 --- /dev/null +++ b/src/mitm/cert/rootCA.js @@ -0,0 +1,173 @@ +const path = require("path"); +const fs = require("fs"); +const forge = require("node-forge"); +const { MITM_DIR } = require("../paths"); + +const ROOT_CA_KEY_PATH = path.join(MITM_DIR, "rootCA.key"); +const ROOT_CA_CERT_PATH = path.join(MITM_DIR, "rootCA.crt"); + +/** + * Check if cert file is expired or expiring within 30 days + */ +function isCertExpired(certPath) { + try { + const cert = forge.pki.certificateFromPem(fs.readFileSync(certPath, "utf8")); + const expiryThreshold = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + return cert.validity.notAfter < expiryThreshold; + } catch { + return true; // treat unreadable cert as expired + } +} + +/** + * Generate Root CA certificate (only once, auto-regenerate if expired) + * This Root CA will sign all dynamic leaf certificates + */ +function generateRootCA() { + const exists = fs.existsSync(ROOT_CA_KEY_PATH) && fs.existsSync(ROOT_CA_CERT_PATH); + if (exists && !isCertExpired(ROOT_CA_CERT_PATH)) { + console.log("✅ Root CA already exists"); + return { key: ROOT_CA_KEY_PATH, cert: ROOT_CA_CERT_PATH }; + } + if (exists) { + console.log("🔐 Root CA expired or expiring soon — regenerating..."); + try { fs.unlinkSync(ROOT_CA_KEY_PATH); } catch { /* ignore */ } + try { fs.unlinkSync(ROOT_CA_CERT_PATH); } catch { /* ignore */ } + } + + if (!fs.existsSync(MITM_DIR)) { + fs.mkdirSync(MITM_DIR, { recursive: true }); + } + + console.log("🔐 Generating Root CA certificate..."); + + // Generate RSA key pair + const keys = forge.pki.rsa.generateKeyPair(2048); + + // Create Root CA certificate + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = "01"; + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10); + + const attrs = [ + { name: "commonName", value: "9Router MITM Root CA" }, + { name: "organizationName", value: "9Router" }, + { name: "countryName", value: "US" } + ]; + + cert.setSubject(attrs); + cert.setIssuer(attrs); // Self-signed + + cert.setExtensions([ + { + name: "basicConstraints", + cA: true, + critical: true + }, + { + name: "keyUsage", + keyCertSign: true, + cRLSign: true, + critical: true + }, + { + name: "subjectKeyIdentifier" + } + ]); + + // Self-sign the certificate + cert.sign(keys.privateKey, forge.md.sha256.create()); + + // Save to disk + const privateKeyPem = forge.pki.privateKeyToPem(keys.privateKey); + const certPem = forge.pki.certificateToPem(cert); + + fs.writeFileSync(ROOT_CA_KEY_PATH, privateKeyPem); + fs.writeFileSync(ROOT_CA_CERT_PATH, certPem); + + console.log("✅ Root CA generated successfully"); + return { key: ROOT_CA_KEY_PATH, cert: ROOT_CA_CERT_PATH }; +} + +/** + * Load Root CA from disk + */ +function loadRootCA() { + if (!fs.existsSync(ROOT_CA_KEY_PATH) || !fs.existsSync(ROOT_CA_CERT_PATH)) { + throw new Error("Root CA not found. Generate it first."); + } + + const keyPem = fs.readFileSync(ROOT_CA_KEY_PATH, "utf8"); + const certPem = fs.readFileSync(ROOT_CA_CERT_PATH, "utf8"); + + return { + key: forge.pki.privateKeyFromPem(keyPem), + cert: forge.pki.certificateFromPem(certPem) + }; +} + +/** + * Generate leaf certificate for a specific domain, signed by Root CA + */ +function generateLeafCert(domain, rootCA) { + // Generate key pair for leaf cert + const keys = forge.pki.rsa.generateKeyPair(2048); + + // Create leaf certificate + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = Math.floor(Math.random() * 1000000).toString(); + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1); + + cert.setSubject([ + { name: "commonName", value: domain } + ]); + + cert.setIssuer(rootCA.cert.subject.attributes); + + cert.setExtensions([ + { + name: "basicConstraints", + cA: false + }, + { + name: "keyUsage", + digitalSignature: true, + keyEncipherment: true + }, + { + name: "extKeyUsage", + serverAuth: true, + clientAuth: true + }, + { + name: "subjectAltName", + altNames: [ + { type: 2, value: domain }, // DNS + { type: 2, value: `*.${domain}` } // Wildcard + ] + } + ]); + + // Sign with Root CA + cert.sign(rootCA.key, forge.md.sha256.create()); + + return { + key: forge.pki.privateKeyToPem(keys.privateKey), + cert: forge.pki.certificateToPem(cert) + }; +} + +module.exports = { + generateRootCA, + loadRootCA, + generateLeafCert, + isCertExpired, + ROOT_CA_CERT_PATH, + ROOT_CA_KEY_PATH +}; diff --git a/src/mitm/config.js b/src/mitm/config.js new file mode 100644 index 0000000..e109ec9 --- /dev/null +++ b/src/mitm/config.js @@ -0,0 +1,89 @@ +// All intercepted domains + URL patterns per tool + +const fs = require("fs"); + +const IS_DEV = process.env.NODE_ENV === "development"; + +// Resolve lsof absolute path — packaged apps / sudo secure_path may strip /usr/sbin from PATH +const LSOF_BIN = (() => { + if (process.platform === "win32") return null; + for (const p of ["/usr/sbin/lsof", "/usr/bin/lsof", "/sbin/lsof"]) { + try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* try next */ } + } + return "lsof"; // last-resort fallback (depends on PATH) +})(); + +const TARGET_HOSTS = [ + "daily-cloudcode-pa.googleapis.com", + "cloudcode-pa.googleapis.com", + "api.individual.githubcopilot.com", + "q.us-east-1.amazonaws.com", + "codewhisperer.us-east-1.amazonaws.com", + "runtime.us-east-1.kiro.dev", + "api2.cursor.sh", +]; + +const URL_PATTERNS = { + antigravity: [":generateContent", ":streamGenerateContent"], + copilot: ["/chat/completions", "/v1/messages", "/responses"], + kiro: ["/generateAssistantResponse"], + cursor: ["/BidiAppend", "/RunSSE", "/RunPoll", "/Run"], +}; + +// Synonym map: rawModel from request → canonical alias key in mitmAlias DB +const MODEL_SYNONYMS = { + antigravity: { + "gemini-default": "gemini-3.5-flash-low", + "gemini-3.5-flash-high": "gemini-3-flash-agent", + "gemini-3.5-flash-medium": "gemini-3.5-flash-low", + "gemini-3.5-flash-extra-low": "gemini-3.5-flash-extra-low", + "gemini-3.1-pro-high": "gemini-pro-agent", + "gemini-3-pro-high": "gemini-pro-agent", + "gemini-3-pro-low": "gemini-3.1-pro-low", + }, +}; + +// Pattern fallback: rawModel regex → canonical alias key (when exact + prefix match fail) +// Order matters: more specific patterns first. Catches AG renamed variants (e.g. gemini-pro-agent) +const MODEL_PATTERNS = { + antigravity: [ + { match: /flash.*extra.*low|extra.*low.*flash|flash.*low|low.*flash/i, alias: "gemini-3.5-flash-extra-low" }, + { match: /flash.*medium|medium.*flash/i, alias: "gemini-3.5-flash-low" }, + { match: /flash.*agent|agent.*flash|flash/i, alias: "gemini-3-flash-agent" }, + { match: /pro.*low|low.*pro/i, alias: "gemini-3.1-pro-low" }, + { match: /gemini.*pro|pro.*gemini/i, alias: "gemini-pro-agent" }, + { match: /opus/i, alias: "claude-opus-4-6-thinking" }, + { match: /sonnet|claude/i, alias: "claude-sonnet-4-6" }, + { match: /gpt.*oss|oss/i, alias: "gpt-oss-120b-medium" }, + ], +}; + +// Models that must NEVER be re-routed — always passthrough to the real upstream, even when +// the tool's other models are mapped. Antigravity's tab-autocomplete (`tab_jump_flash_lite_preview`, +// `tab_flash_lite_preview`, requestType tab/tab_jump) is latency-critical inline completion; routing +// it through 9Router to an external chat model makes typing laggy and burns provider quota per +// keystroke. Without this guard the broad `flash` pattern in MODEL_PATTERNS hijacks them onto the +// flash-agent slot. Verified via MITM dump capture of streamGenerateContent (see AI_JOURNAL). +const MODEL_NO_MAP = { + antigravity: [/^tab[_-]/i], +}; + +// URL substrings whose request/response should NOT be dumped to file (telemetry, polling, empty) +const LOG_BLACKLIST_URL_PARTS = [ + "recordCodeAssistMetrics", + "recordTrajectoryAnalytics", + "fetchAdminControls", + "listExperiments", + "fetchUserInfo", +]; + +function getToolForHost(host) { + const h = (host || "").split(":")[0]; + if (h === "api.individual.githubcopilot.com") return "copilot"; + if (h === "daily-cloudcode-pa.googleapis.com" || h === "cloudcode-pa.googleapis.com") return "antigravity"; + if (h === "q.us-east-1.amazonaws.com" || h === "codewhisperer.us-east-1.amazonaws.com" || h === "runtime.us-east-1.kiro.dev") return "kiro"; + if (h === "api2.cursor.sh") return "cursor"; + return null; +} + +module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost }; diff --git a/src/mitm/dbReader.js b/src/mitm/dbReader.js new file mode 100644 index 0000000..7133947 --- /dev/null +++ b/src/mitm/dbReader.js @@ -0,0 +1,22 @@ +// CJS reader for MITM standalone process. Reads mitmAlias from JSON cache +// at $DATA_DIR/mitm/aliases.json (synced by app from SQLite on startup + writes). +// JSON-only: no SQLite native binding required in MITM bundle. +const fs = require("fs"); +const path = require("path"); +const { DATA_DIR } = require("./paths"); + +const CACHE_FILE = path.join(DATA_DIR, "mitm", "aliases.json"); + +function readCache() { + try { + if (!fs.existsSync(CACHE_FILE)) return null; + return JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8")); + } catch { return null; } +} + +function getMitmAlias(toolName) { + const all = readCache(); + return all?.[toolName] || null; +} + +module.exports = { getMitmAlias }; diff --git a/src/mitm/dns/dnsConfig.js b/src/mitm/dns/dnsConfig.js new file mode 100644 index 0000000..ae1568a --- /dev/null +++ b/src/mitm/dns/dnsConfig.js @@ -0,0 +1,266 @@ +const { exec, spawn, execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { log, err } = require("../logger"); +const { TOOL_HOSTS } = require("../../shared/constants/mitmToolHosts.js"); +const { runElevatedPowerShell, isAdmin } = require("../winElevated.js"); + +/** + * Atomic-ish write for Windows hosts file with rollback on failure. + * Strategy: write `.new` sibling → rename current to `.bak` → rename `.new` to target. + * If anything fails mid-way, restore from `.bak`. Same-volume renames are atomic on NTFS. + */ +function atomicWriteHostsWin(target, originalContent, newContent) { + const tmpNew = `${target}.9router.new`; + const tmpBak = `${target}.9router.bak`; + try { + fs.writeFileSync(tmpNew, newContent, "utf8"); + try { fs.unlinkSync(tmpBak); } catch { /* none */ } + fs.renameSync(target, tmpBak); + try { + fs.renameSync(tmpNew, target); + } catch (e) { + // Rollback: restore original + try { fs.renameSync(tmpBak, target); } catch { fs.writeFileSync(target, originalContent, "utf8"); } + throw e; + } + try { fs.unlinkSync(tmpBak); } catch { /* best effort */ } + } finally { + try { fs.unlinkSync(tmpNew); } catch { /* already moved or never created */ } + } +} + +const IS_WIN = process.platform === "win32"; +const IS_MAC = process.platform === "darwin"; +const HOSTS_FILE = IS_WIN + ? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts") + : "/etc/hosts"; + +/** True when `sudo` exists (e.g. missing on minimal Docker images like Alpine). */ +function isSudoAvailable() { + if (IS_WIN) return false; + try { + execSync("command -v sudo", { stdio: "ignore", windowsHide: true }); + return true; + } catch { + return false; + } +} + +function canRunSudoWithoutPassword() { + if (IS_WIN || !isSudoAvailable()) return true; + try { + execSync("sudo -n true", { stdio: "ignore", windowsHide: true }); + return true; + } catch { + return false; + } +} + +function isSudoPasswordRequired() { + return !IS_WIN && isSudoAvailable() && !canRunSudoWithoutPassword(); +} + +/** + * Execute command with sudo password via stdin (macOS/Linux only). + * Without sudo in PATH (containers), runs via sh — same user, no elevation. + */ +function execWithPassword(command, password) { + return new Promise((resolve, reject) => { + const useSudo = isSudoAvailable(); + const child = useSudo + ? spawn("sudo", ["-S", "sh", "-c", command], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true }) + : spawn("sh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + + child.on("close", (code) => { + if (code === 0) resolve(stdout); + else reject(new Error(stderr || `Exit code ${code}`)); + }); + + if (useSudo) { + child.stdin.write(`${password}\n`); + child.stdin.end(); + } + }); +} + +/** + * Trim trailing blank lines/whitespace, ensure file ends with exactly one newline. + */ +function normalizeHostsContent(content) { + const eol = IS_WIN ? "\r\n" : "\n"; + return content.replace(/[\r\n\s]+$/g, "") + eol; +} + +/** + * Flush DNS cache (macOS/Linux) + */ +async function flushDNS(sudoPassword) { + if (IS_WIN) return; // Windows flushes inline via ipconfig + if (IS_MAC) { + await execWithPassword("dscacheutil -flushcache && killall -HUP mDNSResponder", sudoPassword); + } else { + await execWithPassword("resolvectl flush-caches 2>/dev/null || true", sudoPassword); + } +} + +/** + * Check if DNS entry exists for a specific host + */ +function checkDNSEntry(host = null) { + try { + const hostsContent = fs.readFileSync(HOSTS_FILE, "utf8"); + if (host) return hostsContent.includes(host); + // Legacy: check all antigravity hosts (backward compat) + return TOOL_HOSTS.antigravity.every(h => hostsContent.includes(h)); + } catch { + return false; + } +} + +/** + * Check DNS status per tool — returns { [tool]: boolean } + */ +function checkAllDNSStatus() { + try { + const hostsContent = fs.readFileSync(HOSTS_FILE, "utf8"); + const result = {}; + for (const [tool, hosts] of Object.entries(TOOL_HOSTS)) { + result[tool] = hosts.every(h => hostsContent.includes(h)); + } + return result; + } catch { + return Object.fromEntries(Object.keys(TOOL_HOSTS).map(t => [t, false])); + } +} + +/** + * Add DNS entries for a specific tool + */ +async function addDNSEntry(tool, sudoPassword) { + const hosts = TOOL_HOSTS[tool]; + if (!hosts) throw new Error(`Unknown tool: ${tool}`); + + const entriesToAdd = hosts.filter(h => !checkDNSEntry(h)); + if (entriesToAdd.length === 0) { + log(`🌐 DNS ${tool}: already active`); + return; + } + + try { + if (IS_WIN) { + // Read → trim → append → atomic write (Node-side, no CLI size limit) + const current = fs.readFileSync(HOSTS_FILE, "utf8"); + const trimmed = current.replace(/[\r\n\s]+$/g, ""); + const toAppend = entriesToAdd.map(h => `127.0.0.1 ${h}`).join("\r\n"); + const next = `${trimmed}\r\n${toAppend}\r\n`; + atomicWriteHostsWin(HOSTS_FILE, current, next); + await runElevatedPowerShell("ipconfig /flushdns | Out-Null"); + } else { + const current = fs.readFileSync(HOSTS_FILE, "utf8"); + const trimmed = current.replace(/[\r\n\s]+$/g, ""); + const toAppend = entriesToAdd.map(h => `127.0.0.1 ${h}`).join("\n"); + const next = `${trimmed}\n${toAppend}\n`; + // Use tee via sudo to overwrite atomically — escape single quotes in content + const escaped = next.replace(/'/g, "'\\''"); + await execWithPassword(`printf '%s' '${escaped}' | tee ${HOSTS_FILE} > /dev/null`, sudoPassword); + await flushDNS(sudoPassword); + } + log(`🌐 DNS ${tool}: ✅ added ${entriesToAdd.join(", ")}`); + } catch (error) { + const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : `Failed to add DNS entry: ${error.message}`; + throw new Error(msg); + } +} + +/** + * Remove DNS entries for a specific tool + */ +async function removeDNSEntry(tool, sudoPassword) { + const hosts = TOOL_HOSTS[tool]; + if (!hosts) throw new Error(`Unknown tool: ${tool}`); + + const entriesToRemove = hosts.filter(h => checkDNSEntry(h)); + if (entriesToRemove.length === 0) { + log(`🌐 DNS ${tool}: already inactive`); + return; + } + + try { + if (IS_WIN) { + const current = fs.readFileSync(HOSTS_FILE, "utf8"); + const filtered = current.split(/\r?\n/).filter(l => !entriesToRemove.some(h => l.includes(h))).join("\r\n"); + const next = filtered.replace(/[\r\n\s]+$/g, "") + "\r\n"; + atomicWriteHostsWin(HOSTS_FILE, current, next); + await runElevatedPowerShell("ipconfig /flushdns | Out-Null"); + } else { + const current = fs.readFileSync(HOSTS_FILE, "utf8"); + const filtered = current.split(/\r?\n/).filter(l => !entriesToRemove.some(h => l.includes(h))).join("\n"); + const next = filtered.replace(/[\r\n\s]+$/g, "") + "\n"; + const escaped = next.replace(/'/g, "'\\''"); + await execWithPassword(`printf '%s' '${escaped}' | tee ${HOSTS_FILE} > /dev/null`, sudoPassword); + await flushDNS(sudoPassword); + } + log(`🌐 DNS ${tool}: ✅ removed ${entriesToRemove.join(", ")}`); + } catch (error) { + const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : `Failed to remove DNS entry: ${error.message}`; + throw new Error(msg); + } +} + +/** + * Remove ALL tool DNS entries (used when stopping server) + */ +async function removeAllDNSEntries(sudoPassword) { + for (const tool of Object.keys(TOOL_HOSTS)) { + try { + await removeDNSEntry(tool, sudoPassword); + } catch (e) { + err(`DNS ${tool}: failed to remove — ${e.message}`); + } + } +} + +/** + * Sync removal of ALL tool DNS entries — for use during process shutdown + * when async ops aren't safe. Assumes caller already has root/admin rights. + */ +function removeAllDNSEntriesSync() { + try { + if (!fs.existsSync(HOSTS_FILE)) return; + const allHosts = Object.values(TOOL_HOSTS).flat(); + const content = fs.readFileSync(HOSTS_FILE, "utf8"); + const eol = IS_WIN ? "\r\n" : "\n"; + const filtered = content.split(/\r?\n/).filter(l => !allHosts.some(h => l.includes(h))).join(eol); + const next = filtered.replace(/[\r\n\s]+$/g, "") + eol; + if (next === content) return; + fs.writeFileSync(HOSTS_FILE, next, "utf8"); + if (IS_WIN) { + try { execSync("ipconfig /flushdns", { windowsHide: true, stdio: "ignore" }); } catch { /* ignore */ } + } else if (IS_MAC) { + try { execSync("dscacheutil -flushcache && killall -HUP mDNSResponder", { stdio: "ignore" }); } catch { /* ignore */ } + } else { + try { execSync("resolvectl flush-caches 2>/dev/null || true", { stdio: "ignore" }); } catch { /* ignore */ } + } + } catch { /* best effort during shutdown */ } +} + +module.exports = { + TOOL_HOSTS, + addDNSEntry, + removeDNSEntry, + removeAllDNSEntries, + removeAllDNSEntriesSync, + execWithPassword, + isSudoAvailable, + canRunSudoWithoutPassword, + isSudoPasswordRequired, + checkDNSEntry, + checkAllDNSStatus, +}; diff --git a/src/mitm/handlers/antigravity.js b/src/mitm/handlers/antigravity.js new file mode 100644 index 0000000..aa9273f --- /dev/null +++ b/src/mitm/handlers/antigravity.js @@ -0,0 +1,33 @@ +const { err, createResponseDumper } = require("../logger"); +const { IS_DEV } = require("../config"); +const { fetchRouter, pipeSSE } = require("./base"); + +/** + * Intercept Antigravity request — forward Gemini body as-is to /v1/chat/completions. + * Router auto-detects format via body.userAgent==="antigravity" + body.request.contents, + * runs antigravity→openai→provider→openai→antigravity translators internally. + */ +async function intercept(req, res, bodyBuffer, mappedModel) { + const dumper = IS_DEV ? createResponseDumper(req, "intercept-antigravity") : null; + const isStream = req.url.includes(":streamGenerateContent"); + try { + const body = JSON.parse(bodyBuffer.toString()); + if (body.model) body.model = mappedModel; + + const routerRes = await fetchRouter(body, "/v1/chat/completions", req.headers); + await pipeSSE(routerRes, res, dumper); + } catch (error) { + err(`[antigravity] ${error.message}`); + if (dumper) { dumper.writeChunk(`\n[ERROR] ${error.message}\n`); dumper.end(); } + // For stream endpoint, send SSE error chunk so SDK doesn't hang waiting + if (isStream) { + if (!res.headersSent) res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.end(`data: ${JSON.stringify({ error: { message: error.message } })}\r\n\r\n`); + } else { + if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } })); + } + } +} + +module.exports = { intercept }; diff --git a/src/mitm/handlers/base.js b/src/mitm/handlers/base.js new file mode 100644 index 0000000..5f2d3d1 --- /dev/null +++ b/src/mitm/handlers/base.js @@ -0,0 +1,226 @@ +const { log, err } = require("../logger"); + +const DEFAULT_LOCAL_ROUTER = "http://localhost:20128"; +const ROUTER_BASE = String(process.env.MITM_ROUTER_BASE || DEFAULT_LOCAL_ROUTER) + .trim() + .replace(/\/+$/, "") || DEFAULT_LOCAL_ROUTER; +const API_KEY = process.env.ROUTER_API_KEY; + +// Headers that must not be forwarded to 9Router +const STRIP_HEADERS = new Set([ + "host", "content-length", "connection", "transfer-encoding", + "content-type", "authorization" +]); + +/** + * Send body to 9Router at the given path and return the fetch Response object. + * Optionally forwards client headers (stripped of hop-by-hop / overridden keys). + */ +async function fetchRouter(openaiBody, path = "/v1/chat/completions", clientHeaders = {}) { + const forwarded = {}; + for (const [k, v] of Object.entries(clientHeaders)) { + if (!STRIP_HEADERS.has(k.toLowerCase())) forwarded[k] = v; + } + + const response = await fetch(`${ROUTER_BASE}${path}`, { + method: "POST", + headers: { + ...forwarded, + "Content-Type": "application/json", + ...(API_KEY && { "Authorization": `Bearer ${API_KEY}` }) + }, + body: JSON.stringify(openaiBody) + }); + + // Forward response as-is (status + body). pipeSSE will propagate status. + return response; +} + +/** + * Pipe SSE stream from router directly to client response. + * Optional dumper tees the stream into a debug file. + */ +async function pipeSSE(routerRes, res, dumper) { + const ct = routerRes.headers.get("content-type") || "application/json"; + const status = routerRes.status || 200; + const resHeaders = { "Content-Type": ct, "Cache-Control": "no-cache", "Connection": "keep-alive" }; + if (ct.includes("text/event-stream")) resHeaders["X-Accel-Buffering"] = "no"; + res.writeHead(status, resHeaders); + if (dumper) dumper.writeHeader(routerRes.status, Object.fromEntries(routerRes.headers)); + + if (!routerRes.body) { + const text = await routerRes.text().catch(() => ""); + if (dumper) { dumper.writeChunk(text); dumper.end(); } + res.end(text); + return; + } + + const reader = routerRes.body.getReader(); + const decoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); + if (done) { if (dumper) dumper.end(); res.end(); break; } + if (dumper) dumper.writeChunk(value); + res.write(decoder.decode(value, { stream: true })); + } +} + +/** + * Pipe SSE stream from router, transforming each chunk through a user function. + * Reads SSE data: lines, parses JSON, calls transformFn(parsed, state), + * and writes returned SSE strings to the client response. + * + * @param {Response} routerRes - Fetch Response from 9Router + * @param {http.ServerResponse} res - Client response + * @param {Function} transformFn - (parsedChunk, state) => string|string[]|null + * @param {object} state - Mutable state object shared across chunks and flush + */ +async function pipeTransformedSSE(routerRes, res, transformFn, state) { + const ct = routerRes.headers.get("content-type") || "application/json"; + const resHeaders = { "Content-Type": ct, "Cache-Control": "no-cache", "Connection": "keep-alive" }; + if (ct.includes("text/event-stream")) resHeaders["X-Accel-Buffering"] = "no"; + res.writeHead(200, resHeaders); + + if (!routerRes.body) { + res.end(await routerRes.text().catch(() => "")); + return; + } + + const reader = routerRes.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: false }); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("data:")) continue; + + const data = trimmed.slice(5).trim(); + if (data === "[DONE]") continue; + + if (process.env.DEBUG_MITM) { + log(`[SSE in] ${data.slice(0, 200)}`); + } + + try { + const parsed = JSON.parse(data); + const result = transformFn(parsed, state); + if (result != null) { + const outputs = Array.isArray(result) ? result : [result]; + for (const output of outputs) { + if (process.env.DEBUG_MITM) { + const len = output.length || output.byteLength || 0; + log(`[write binary frame] (${len}B) first 20B: ${Array.from(output.slice(0, 20)).join(',')}`); + } + res.write(Buffer.from(output)); + } + } + } catch { + // Skip unparseable lines + } + } + } + + // Flush: pass null to signal stream end + try { + const flushed = transformFn(null, state); + if (flushed != null) { + const outputs = Array.isArray(flushed) ? flushed : [flushed]; + for (const output of outputs) { + res.write(output); + } + } + } catch { /* ignore flush errors */ } + + res.end(); +} + +/** + * Pipe SSE stream from router, transforming each chunk through a user function, + * and writing binary EventStream frames to the client. + * + * Reads SSE data: lines, parses JSON, calls transformFn(parsed, state), + * and writes returned Uint8Array frames to the client response. + * + * @param {Response} routerRes - Fetch Response from 9Router + * @param {http.ServerResponse} res - Client response + * @param {Function} transformFn - (parsedChunk, state) => Uint8Array|Uint8Array[]|null + * @param {object} state - Mutable state object shared across chunks and flush + */ +async function pipeTransformedEventStream(routerRes, res, transformFn, state) { + const resHeaders = { + "Content-Type": "application/vnd.amazon.eventstream", + "Cache-Control": "no-cache", + "Connection": "keep-alive" + }; + res.writeHead(200, resHeaders); + + if (!routerRes.body) { + res.end(await routerRes.text().catch(() => "")); + return; + } + + const reader = routerRes.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: false }); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("data:")) continue; + + const data = trimmed.slice(5).trim(); + if (data === "[DONE]") continue; + + if (process.env.DEBUG_MITM) { + log(`[SSE in] ${data.slice(0, 200)}`); + } + + try { + const parsed = JSON.parse(data); + const result = transformFn(parsed, state); + if (result != null) { + const outputs = Array.isArray(result) ? result : [result]; + for (const output of outputs) { + if (process.env.DEBUG_MITM) { + const len = output.length || output.byteLength || 0; + log(`[write binary frame] (${len}B) first 20B: ${Array.from(output.slice(0, 20)).join(',')}`); + } + res.write(Buffer.from(output)); + } + } + } catch { + // Skip unparseable lines + } + } + } + + // Flush: pass null to signal stream end + try { + const flushed = transformFn(null, state); + if (flushed != null) { + const outputs = Array.isArray(flushed) ? flushed : [flushed]; + for (const output of outputs) { + res.write(output); + } + } + } catch { /* ignore flush errors */ } + + res.end(); +} + +module.exports = { fetchRouter, pipeSSE, pipeTransformedSSE, pipeTransformedEventStream }; \ No newline at end of file diff --git a/src/mitm/handlers/copilot.js b/src/mitm/handlers/copilot.js new file mode 100644 index 0000000..cf6a920 --- /dev/null +++ b/src/mitm/handlers/copilot.js @@ -0,0 +1,35 @@ +const { err } = require("../logger"); +const { fetchRouter, pipeSSE } = require("./base"); + +// Map Copilot endpoint → 9Router path +const URL_MAP = { + "/chat/completions": "/v1/chat/completions", + "/v1/messages": "/v1/messages", + "/responses": "/v1/responses", +}; + +function resolveRouterPath(reqUrl) { + for (const [pattern, routerPath] of Object.entries(URL_MAP)) { + if (reqUrl.includes(pattern)) return routerPath; + } + return "/v1/chat/completions"; +} + +/** + * Intercept Copilot request — replace model and forward to matching 9Router endpoint + */ +async function intercept(req, res, bodyBuffer, mappedModel) { + try { + const body = JSON.parse(bodyBuffer.toString()); + body.model = mappedModel; + const routerPath = resolveRouterPath(req.url); + const routerRes = await fetchRouter(body, routerPath, req.headers); + await pipeSSE(routerRes, res); + } catch (error) { + err(`[copilot] ${error.message}`); + if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } })); + } +} + +module.exports = { intercept }; diff --git a/src/mitm/handlers/cursor.js b/src/mitm/handlers/cursor.js new file mode 100644 index 0000000..0f38663 --- /dev/null +++ b/src/mitm/handlers/cursor.js @@ -0,0 +1,15 @@ +/** + * Cursor MITM handler — coming soon + * This feature is currently under development. + */ +async function intercept(req, res) { + res.writeHead(501, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + error: { + message: "Cursor MITM support is coming soon.", + type: "not_implemented" + } + })); +} + +module.exports = { intercept }; diff --git a/src/mitm/handlers/kiro.js b/src/mitm/handlers/kiro.js new file mode 100644 index 0000000..b453d2c --- /dev/null +++ b/src/mitm/handlers/kiro.js @@ -0,0 +1,526 @@ +const { err } = require("../logger"); +const { IS_DEV } = require("../config"); +const { fetchRouter, pipeTransformedEventStream } = require("./base"); +const fs = require("fs"); +const path = require("path"); + +// Debug trace log — written to data/logs/mitm/kiro-debug.log (dev only) +const DEBUG_LOG = path.join(__dirname, "../../../data/logs/mitm/kiro-debug.log"); +function dbg(msg) { + if (!IS_DEV) return; + try { + fs.appendFileSync(DEBUG_LOG, `${new Date().toISOString()} ${msg}\n`); + } catch {} +} + +// ─── CRC32 (standard, polynomial 0xEDB88320 — same as AWS EventStream) ─────── +const CRC32_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c; + } + return t; +})(); + +function crc32(buf) { + let crc = 0xffffffff; + for (let i = 0; i < buf.length; i++) { + crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ buf[i]) & 0xff]; + } + return (crc ^ 0xffffffff) >>> 0; +} + +/** + * Initialize state for the Kiro response translator + */ +function initKiroState(modelId) { + return { + modelId: modelId || null, // Model name from first chunk + toolCallInit: {}, // { [index]: { id, name } } — tracks seen tools + hasToolCalls: false, // Whether this response uses tool calls + finishSent: false, // Whether termination has been emitted + usage: null, // Accumulated usage from usage-only chunks + inThink: false, // Whether inside a block + thinkBuf: "" // Buffer for partial thinking content + }; +} + +/** + * Extract thinking blocks from text content. + * Handles both ... and ... tags, + * including partial tags split across SSE chunks. + */ +function extractThinking(text, state) { + if (!text) return { thinking: null, text: null }; + + let working = text; + + // Prepend buffered partial thinking from previous chunk + if (state.inThink && state.thinkBuf) { + working = state.thinkBuf + working; + state.thinkBuf = ""; + state.inThink = false; + } + + // Match or opening tags + const startRe = /|/i; + const startMatch = working.match(startRe); + + if (!startMatch) { + return { thinking: null, text: working }; + } + + const tag = startMatch[0].toLowerCase(); + const closeTag = tag === "" ? "" : ""; + const startIdx = startMatch.index; + const endIdx = working.indexOf(closeTag, startIdx + tag.length); + + if (endIdx === -1) { + // Opening tag without closing — buffer for next chunk + state.inThink = true; + state.thinkBuf = working.slice(startIdx); + const before = working.slice(0, startIdx).trim(); + return { thinking: null, text: before || null }; + } + + // Complete block found + const thinking = working.slice(startIdx + tag.length, endIdx); + const before = working.slice(0, startIdx).trim(); + const after = working.slice(endIdx + closeTag.length).trim(); + const rest = [before, after].filter(Boolean).join(""); + + // Recursively process for more blocks + const recurse = rest + ? extractThinking(rest, { inThink: false, thinkBuf: "" }) + : { thinking: null, text: null }; + + return { + thinking: thinking || null, + text: recurse.text || null + }; +} + +// ─── AWS EventStream frame builder ──────────────────────────────────────────── +/** + * Encode a single string header into the AWS EventStream binary format. + * Header wire format: [nameLen 1B][name][type=7 1B][valueLen 2B][value] + */ +function encodeHeader(name, value) { + const nameBuf = Buffer.from(name, "utf8"); + const valueBuf = Buffer.from(value, "utf8"); + const buf = Buffer.alloc(1 + nameBuf.length + 1 + 2 + valueBuf.length); + let o = 0; + buf[o++] = nameBuf.length; + nameBuf.copy(buf, o); o += nameBuf.length; + buf[o++] = 7; // string type + buf.writeUInt16BE(valueBuf.length, o); o += 2; + valueBuf.copy(buf, o); + return buf; +} + +/** + * Build a single AWS EventStream binary frame with all Smithy-required headers. + * + * Frame layout (big-endian): + * [totalLen 4B][headersLen 4B][preludeCRC 4B] + * [headers ...][payload JSON ...][messageCRC 4B] + * + * The SmithyMessageDecoderStream layer requires three system headers on every frame: + * :message-type = "event" (or "exception" / "error") + * :event-type = e.g. "assistantResponseEvent" + * :content-type = "application/json" + */ +function buildEventStreamFrame(eventType, payload) { + const payloadBuf = Buffer.from( + typeof payload === "string" ? payload : JSON.stringify(payload), + "utf8" + ); + + // All three Smithy system headers are required + const headersBuf = Buffer.concat([ + encodeHeader(":message-type", "event"), + encodeHeader(":event-type", eventType), + encodeHeader(":content-type", "application/json"), + ]); + const headersLen = headersBuf.length; + + const totalLen = 4 + 4 + 4 + headersLen + payloadBuf.length + 4; + const frame = Buffer.alloc(totalLen); + + frame.writeUInt32BE(totalLen, 0); + frame.writeUInt32BE(headersLen, 4); + frame.writeUInt32BE(crc32(frame.slice(0, 8)), 8); // prelude CRC + headersBuf.copy(frame, 12); + payloadBuf.copy(frame, 12 + headersLen); + frame.writeUInt32BE(crc32(frame.slice(0, totalLen - 4)), totalLen - 4); // message CRC + + return frame; +} + +// ─── CodeWhisperer → OpenAI conversion ─────────────────────────────────────── + +/** + * Safely stringify a tool-call input value. + * OpenAI expects `function.arguments` to be a JSON string, never an object. + * If 9router's Anthropic→OpenAI conversion passes the input as a pre-parsed object, + * this prevents the "" + object → "[object Object]" corruption. + */ +function safeArgsString(value) { + if (typeof value === "string") return value; + if (value == null) return "{}"; + try { return JSON.stringify(value); } catch { return "{}"; } +} + +/** + * Convert a CodeWhisperer userInputMessage to one or more OpenAI messages. + * + * A user turn can contain: + * - plain text content → { role:"user", content } + * - toolResults only → one { role:"tool", tool_call_id, content } per result + * - both → tool messages first, then the user text message + */ +function convertUserInputMessage(uim) { + const out = []; + const toolResults = uim.userInputMessageContext?.toolResults || []; + + // Emit one "tool" message per tool result (OpenAI multi-tool format) + for (const tr of toolResults) { + const text = (tr.content || []).map(c => c.text || "").join("\n"); + out.push({ + role: "tool", + tool_call_id: tr.toolUseId || "", + content: text, + }); + } + + // Emit user text only if it exists alongside OR when there are no tool results + const text = (uim.content || "").trim(); + if (text || toolResults.length === 0) { + out.push({ role: "user", content: text }); + } + + return out; +} + +/** + * Convert a CodeWhisperer assistantResponseMessage to an OpenAI assistant message. + * + * The assistant turn can contain: + * - plain text content + * - toolUses (tool calls) → tool_calls[] on the assistant message + */ +function convertAssistantResponseMessage(arm) { + const toolUses = arm.toolUses || []; + + if (toolUses.length > 0) { + return { + role: "assistant", + content: arm.content || null, + tool_calls: toolUses.map(tu => ({ + id: tu.toolUseId || `call_${Date.now()}`, + type: "function", + function: { + name: tu.name || "", + arguments: safeArgsString(tu.input), + }, + })), + }; + } + + return { role: "assistant", content: arm.content || "" }; +} + +/** + * Convert AWS CodeWhisperer conversationState to an OpenAI messages array. + * + * Full multi-turn shape: + * history: [ + * { userInputMessage: { content, userInputMessageContext?: { toolResults?, tools? } } }, + * { assistantResponseMessage: { content, toolUses?: [...] } }, + * ... + * ] + * currentMessage: { userInputMessage: { content, userInputMessageContext?: { toolResults? } } } + */ +function codeWhispererToMessages(body) { + const cs = body.conversationState || {}; + const history = cs.history || []; + const currentMsg = cs.currentMessage; + const messages = []; + + for (const item of history) { + if (item.userInputMessage) { + messages.push(...convertUserInputMessage(item.userInputMessage)); + } else if (item.assistantResponseMessage) { + messages.push(convertAssistantResponseMessage(item.assistantResponseMessage)); + } + } + + // Append the current (latest) turn + if (currentMsg?.userInputMessage) { + messages.push(...convertUserInputMessage(currentMsg.userInputMessage)); + } + + return messages; +} + +/** + * Extract tool definitions from a CodeWhisperer request and convert to OpenAI format. + * + * CodeWhisperer tools live in: + * conversationState.currentMessage.userInputMessage.userInputMessageContext.tools + * OR (in multi-turn) the first history item's userInputMessageContext.tools + * + * CodeWhisperer tool shape: + * { toolSpecification: { name, description, inputSchema: { json: } } } + * + * OpenAI tool shape: + * { type: "function", function: { name, description, parameters: } } + */ +function extractTools(body) { + const cs = body.conversationState || {}; + + // Tools are typically on the currentMessage; may also appear on the first history item + const fromCurrent = cs.currentMessage?.userInputMessage?.userInputMessageContext?.tools || []; + const fromHistory = cs.history?.find(h => h.userInputMessage?.userInputMessageContext?.tools) + ?.userInputMessage?.userInputMessageContext?.tools || []; + const cwTools = fromCurrent.length > 0 ? fromCurrent : fromHistory; + + if (!cwTools.length) return []; + + return cwTools.map(item => { + const spec = item.toolSpecification || item; + return { + type: "function", + function: { + name: spec.name || "", + description: spec.description || `Tool: ${spec.name || "unknown"}`, + parameters: spec.inputSchema?.json || { type: "object", properties: {}, required: [] }, + }, + }; + }); +} + +// ─── OpenAI SSE → EventStream binary conversion ─────────────────────────────── + +/** + * Convert an OpenAI SSE chunk to AWS EventStream binary frame(s) + * This replaces pipeOpenAIasEventStream and works with pipeTransformedEventStream + * + * @param {object|null} chunk - Parsed OpenAI chat.completion.chunk, or null for flush + * @param {object} state - Mutable state object + * @returns {Uint8Array|Uint8Array[]|null} Binary EventStream frame(s) or null to skip + */ +function convertOpenAIToKiro(chunk, state) { + // Flush: ensure clean stream termination + if (!chunk) { + if (state.finishSent) return null; + // Flush any remaining buffered thinking + if (state.inThink && state.thinkBuf) { + state.inThink = false; + const thinking = state.thinkBuf; + state.thinkBuf = ""; + return buildEventStreamFrame("reasoningContentEvent", { + content: thinking, + modelId: state.modelId || "kiro-unknown" + }); + } + return buildEventStreamFrame("messageStopEvent", {}); + } + + const frames = []; + const choice = chunk.choices?.[0]; + const delta = choice?.delta || {}; + + // Capture modelId from first chunk (real API includes it in every content frame) + if (!state.modelId && chunk.model) { + state.modelId = chunk.model; + } + const modelId = state.modelId || "unknown"; + + // Handle usage (may arrive standalone or with other chunks) + if (chunk.usage) { + state.usage = chunk.usage; + } + + // Handle tool calls — stream incrementally, matching real API format + if (delta.tool_calls) { + state.hasToolCalls = true; + for (const tc of delta.tool_calls) { + const idx = tc.index ?? 0; + + if (tc.id && tc.function?.name && !state.toolCallInit[idx]) { + // First appearance: emit frame with name + id, no input + state.toolCallInit[idx] = { id: tc.id, name: tc.function.name }; + dbg(`toolUseEvent init: ${tc.function.name} (${tc.id})`); + frames.push(buildEventStreamFrame("toolUseEvent", { + name: tc.function.name, + toolUseId: tc.id + })); + } + + // Emit incremental input fragment + if (tc.function?.arguments) { + const init = state.toolCallInit[idx]; + dbg(`toolUseEvent fragment: ${tc.function.arguments.slice(0, 100)}`); + frames.push(buildEventStreamFrame("toolUseEvent", { + input: tc.function.arguments, + name: init?.name || tc.function?.name || "", + toolUseId: init?.id || tc.id || "" + })); + } + } + } + + // Handle explicit reasoning_content (type-specific thinking channel) + if (delta.reasoning_content) { + frames.push(buildEventStreamFrame("reasoningContentEvent", { + content: delta.reasoning_content, + modelId + })); + } + + // Handle text content — extract thinking blocks, emit rest as assistantResponseEvent + if (delta.content) { + const { thinking, text } = extractThinking(delta.content, state); + + if (thinking) { + frames.push(buildEventStreamFrame("reasoningContentEvent", { + content: thinking, + modelId + })); + } + + if (text) { + frames.push(buildEventStreamFrame("assistantResponseEvent", { + content: text, + modelId + })); + } + } + + // Handle finish_reason + if (choice?.finish_reason) { + const finishFrames = emitFinish(state); + if (finishFrames) { + frames.push(...(Array.isArray(finishFrames) ? finishFrames : [finishFrames])); + } + } + + if (frames.length === 0) return null; + return frames.length === 1 ? frames[0] : frames; +} + +/** + * Emit termination frames. For tool-call responses, emits stop:true per tool. + * For text-only responses, emits messageStopEvent. + */ +function emitFinish(state) { + const frames = []; + + if (state.hasToolCalls) { + // Tool-call response: emit stop:true for each tool + for (const idx of Object.keys(state.toolCallInit).sort()) { + const tc = state.toolCallInit[idx]; + frames.push(buildEventStreamFrame("toolUseEvent", { + name: tc.name, + stop: true, + toolUseId: tc.id + })); + } + } else { + // Text-only response: emit messageStopEvent + frames.push(buildEventStreamFrame("messageStopEvent", {})); + } + state.finishSent = true; + + // Emit usage if available + if (state.usage) { + frames.push(buildEventStreamFrame("usageEvent", { + inputTokens: state.usage.prompt_tokens || 0, + outputTokens: state.usage.completion_tokens || 0 + })); + } + + state.toolCallInit = {}; + return frames.length > 0 ? frames : null; +} + +// ─── MITM intercept entry point ─────────────────────────────────────────────── +/** + * Intercept Kiro IDE CodeWhisperer request and convert to EventStream response: + * 1. Parse CodeWhisperer JSON body (reject binary EventStream formats) + * 2. Convert CodeWhisperer format to OpenAI messages[] format + * 3. Forward to 9router /v1/chat/completions (OpenAI SSE) + * 4. Convert OpenAI SSE response → AWS EventStream binary frames + * 5. Stream EventStream frames back to Kiro IDE + * + * @param {http.IncomingMessage} req - HTTP request from Kiro IDE + * @param {http.ServerResponse} res - HTTP response to Kiro IDE + * @param {Buffer} bodyBuffer - Request body buffer + * @param {string} mappedModel - Model name after MITM alias mapping + */ +async function intercept(req, res, bodyBuffer, mappedModel) { + try { + // Detect and handle binary data (e.g., continuation requests with EventStream frames) + if (isBinaryEventStream(bodyBuffer)) { + // Binary EventStream requests are typically continuation/streaming frames + // that don't contain model info - pass them through directly to avoid JSON.parse crash + throw new Error(`Binary EventStream format detected (${bodyBuffer.length}B) - request should use passthrough instead of intercept`); + } + + const body = JSON.parse(bodyBuffer.toString()); + + // 1 + 2: CodeWhisperer → OpenAI messages + tools + const messages = codeWhispererToMessages(body); + if (messages.length === 0) { + throw new Error("codeWhispererToMessages produced 0 messages — check request body"); + } + + const tools = extractTools(body); + + const openaiBody = { + model: mappedModel, + messages, + stream: true, + // Forward tools so Claude uses structured tool_calls instead of XML text fallback + ...(tools.length > 0 && { tools, tool_choice: "auto" }), + }; + + // 3: Forward to 9router + const routerRes = await fetchRouter(openaiBody, "/v1/chat/completions", req.headers); + + // 4 + 5: Re-encode response as AWS EventStream binary using standard pipeline + const state = initKiroState(mappedModel); + + await pipeTransformedEventStream(routerRes, res, convertOpenAIToKiro, state); + } catch (error) { + err(`[Kiro MITM] Request processing failed: ${error.message}`); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + } + res.end(JSON.stringify({ + error: { + message: error.message, + type: "mitm_error", + handler: "kiro" + } + })); + } +} + +// Detect AWS EventStream binary format +function isBinaryEventStream(buffer) { + if (!buffer || buffer.length < 12) return false; + // AWS EventStream signature: + // - First 4 bytes: total frame length (big-endian) + // - Bytes 4-8: headers length (big-endian) + // - Typical frame length: 100-10000 bytes + const totalLen = buffer.readUInt32BE(0); + const headersLen = buffer.readUInt32BE(4); + // Sanity checks: frame length should be reasonable and headers should fit + return totalLen > 12 && totalLen < 1000000 && headersLen < totalLen - 12; +} + +module.exports = { intercept }; diff --git a/src/mitm/logger.js b/src/mitm/logger.js new file mode 100644 index 0000000..5e53210 --- /dev/null +++ b/src/mitm/logger.js @@ -0,0 +1,106 @@ +const fs = require("fs"); +const path = require("path"); +const zlib = require("zlib"); +const { DATA_DIR } = require("./paths"); +const { LOG_BLACKLIST_URL_PARTS } = require("./config"); + +function time() { + return new Date().toLocaleTimeString("en-US", { hour12: false }); +} + +const log = (msg) => console.log(`[${time()}] [MITM] ${msg}`); +const err = (msg) => console.error(`[${time()}] ❌ [MITM] ${msg}`); + +const DUMP_DIR = path.join(DATA_DIR, "logs", "mitm"); +if (!fs.existsSync(DUMP_DIR)) fs.mkdirSync(DUMP_DIR, { recursive: true }); + +// Clear all files inside DUMP_DIR (called on MITM server start to avoid unbounded growth) +function clearDumpDir() { + try { + if (!fs.existsSync(DUMP_DIR)) return; + for (const f of fs.readdirSync(DUMP_DIR)) { + try { fs.rmSync(path.join(DUMP_DIR, f), { recursive: true, force: true }); } catch { /* ignore */ } + } + } catch { /* ignore */ } +} + +const EMPTY_BODY_RE = /^\s*(\{\s*\}|\[\s*\]|null)?\s*$/; + +function slugify(s, max = 80) { + return String(s).replace(/[^a-zA-Z0-9]/g, "_").substring(0, max); +} + +function isBlacklisted(url) { + if (!url) return false; + return LOG_BLACKLIST_URL_PARTS.some(part => url.includes(part)); +} + +// Decode body buffer based on content-encoding header +function decodeBody(buf, encoding) { + if (!buf || buf.length === 0) return buf; + try { + const enc = (encoding || "").toLowerCase(); + if (enc.includes("gzip")) return zlib.gunzipSync(buf); + if (enc.includes("br")) return zlib.brotliDecompressSync(buf); + if (enc.includes("deflate")) return zlib.inflateSync(buf); + } catch { /* return raw on failure */ } + return buf; +} + +// Save raw request: method + url + headers + body +function dumpRequest(req, bodyBuffer, tag = "raw") { + if (isBlacklisted(req.url)) return null; + try { + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const slug = slugify((req.headers.host || "") + req.url); + const file = path.join(DUMP_DIR, `${ts}_${tag}_${slug}.req.json`); + let parsed = null; + try { parsed = JSON.parse(bodyBuffer.toString()); } catch { /* not JSON */ } + fs.writeFileSync(file, JSON.stringify({ + method: req.method, + url: req.url, + host: req.headers.host, + headers: req.headers, + body: parsed ?? bodyBuffer.toString("utf8") + }, null, 2)); + return file; + } catch { return null; } +} + +// Buffer-based response dumper — collects chunks then decodes + writes once on end() +// Trade-off: holds response in RAM, but enables gzip/br decoding for readable output. +function createResponseDumper(req, tag = "raw") { + if (isBlacklisted(req.url)) return null; + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const slug = slugify((req.headers.host || "") + req.url); + const file = path.join(DUMP_DIR, `${ts}_${tag}_${slug}.res.txt`); + let status = 0; + let headers = {}; + const chunks = []; + return { + writeHeader: (s, h) => { status = s; headers = h || {}; }, + writeChunk: (chunk) => { + if (chunk == null) return; + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }, + end: () => { + try { + const raw = Buffer.concat(chunks); + const enc = headers["content-encoding"] || headers["Content-Encoding"]; + const decoded = decodeBody(raw, enc); + const text = decoded.toString("utf8"); + // Skip empty / trivially-empty bodies + if (EMPTY_BODY_RE.test(text)) return; + // Strip content-encoding since body is now decoded + const cleanHeaders = { ...headers }; + delete cleanHeaders["content-encoding"]; + delete cleanHeaders["Content-Encoding"]; + const out = `STATUS: ${status}\nHEADERS: ${JSON.stringify(cleanHeaders, null, 2)}\n---BODY---\n${text}`; + fs.writeFileSync(file, out); + } catch { /* ignore */ } + }, + file + }; +} + +module.exports = { log, err, dumpRequest, createResponseDumper, clearDumpDir }; diff --git a/src/mitm/manager.js b/src/mitm/manager.js new file mode 100644 index 0000000..a9d6e6a --- /dev/null +++ b/src/mitm/manager.js @@ -0,0 +1,883 @@ +const { exec, spawn, execSync } = require("child_process"); +const path = require("path"); +const fs = require("fs"); +const os = require("os"); +const net = require("net"); +const https = require("https"); +const crypto = require("crypto"); +const { addDNSEntry, removeDNSEntry, removeAllDNSEntries, removeAllDNSEntriesSync, checkAllDNSStatus, TOOL_HOSTS, isSudoAvailable, isSudoPasswordRequired } = require("./dns/dnsConfig"); +const { isAdmin } = require("./winElevated.js"); + +const IS_WIN = process.platform === "win32"; +const IS_MAC = process.platform === "darwin"; +const { generateCert } = require("./cert/generate"); +const { installCert, uninstallCert } = require("./cert/install"); +const { isCertExpired } = require("./cert/rootCA"); +const { DATA_DIR, MITM_DIR } = require("./paths"); +const { log, err } = require("./logger"); +const { LSOF_BIN } = require("./config"); + +const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; + +function shellQuoteSingle(str) { + if (str == null || str === "") return "''"; + return `'${String(str).replace(/'/g, "'\\''")}'`; +} + +async function resolveMitmRouterBaseUrl() { + if (!_getSettings) return DEFAULT_MITM_ROUTER_BASE; + try { + const s = await _getSettings(); + const raw = s && s.mitmRouterBaseUrl != null ? String(s.mitmRouterBaseUrl).trim() : ""; + if (!raw) return DEFAULT_MITM_ROUTER_BASE; + const u = new URL(raw); + if (u.protocol !== "http:" && u.protocol !== "https:") return DEFAULT_MITM_ROUTER_BASE; + return raw.replace(/\/+$/, ""); + } catch { + return DEFAULT_MITM_ROUTER_BASE; + } +} + +const MITM_PORT = 443; +const MITM_WIN_NODE_PORT = 8443; +const PID_FILE = path.join(MITM_DIR, ".mitm.pid"); +const LOCK_FILE = path.join(MITM_DIR, ".mitm.lock"); + +const MITM_MAX_RESTARTS = 5; +const MITM_RESTART_DELAYS_MS = [5000, 10000, 20000, 30000, 60000]; +const MITM_RESTART_RESET_MS = 60000; + +let mitmRestartCount = 0; +let mitmLastStartTime = 0; +let mitmIsRestarting = false; + +function resolveBundledServerPath() { + if (process.env.MITM_SERVER_PATH) return process.env.MITM_SERVER_PATH; + const sibling = path.join(__dirname, "server.js"); + if (fs.existsSync(sibling)) return sibling; + const fromCwd = path.join(process.cwd(), "src", "mitm", "server.js"); + if (fs.existsSync(fromCwd)) return fromCwd; + const fromNext = path.join(process.cwd(), "..", "src", "mitm", "server.js"); + if (fs.existsSync(fromNext)) return fromNext; + return fromCwd; +} + +// Copy bundled server.js into DATA_DIR so MITM doesn't lock node_modules +// (prevents EBUSY on `npm i -g 9router@latest` while MITM is running). +function ensureRuntimeServer(bundledPath) { + try { + if (!bundledPath || !fs.existsSync(bundledPath)) return bundledPath; + + // Dev mode: source file has relative requires (./logger, ./config...), + // only the bundled file inside node_modules is self-contained + safe to copy. + if (!bundledPath.includes(`${path.sep}node_modules${path.sep}`)) { + return bundledPath; + } + + const runtimeDir = path.join(DATA_DIR, "runtime", "mitm"); + const runtimeServer = path.join(runtimeDir, "server.js"); + + // Skip copy if sizes match (bundle unchanged since last run) + if (fs.existsSync(runtimeServer)) { + try { + if (fs.statSync(bundledPath).size === fs.statSync(runtimeServer).size) return runtimeServer; + } catch { /* recopy */ } + } + + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.copyFileSync(bundledPath, runtimeServer); + return runtimeServer; + } catch (e) { + try { log(`[MITM] runtime copy failed: ${e.message}`); } catch { /* ignore */ } + return bundledPath; + } +} + +const SERVER_PATH = ensureRuntimeServer(resolveBundledServerPath()); +const ENCRYPT_ALGO = "aes-256-gcm"; +const ENCRYPT_SALT = "9router-mitm-pwd"; + +function getProcessUsingPort443() { + try { + if (IS_WIN) { + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command ` + + `"$c = Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c) { $c.OwningProcess } else { 0 }"`; + const pidStr = execSync(psCmd, { encoding: "utf8", windowsHide: true }).trim(); + const pid = parseInt(pidStr, 10); + if (pid && pid > 4) { + const tasklistResult = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: "utf8", windowsHide: true }); + const processMatch = tasklistResult.match(/"([^"]+)"/); + if (processMatch) return processMatch[1].replace(".exe", ""); + } + } else { + const result = execSync(`${LSOF_BIN} -i :443`, { encoding: "utf8", windowsHide: true }); + const lines = result.trim().split("\n"); + if (lines.length > 1) return lines[1].split(/\s+/)[0]; + } + } catch { + return null; + } + return null; +} + +let serverProcess = null; +let serverPid = null; + +function getCachedPassword() { return globalThis.__mitmSudoPassword || null; } +function setCachedPassword(pwd) { globalThis.__mitmSudoPassword = pwd; } + +function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return err.code === "EACCES"; + } +} + +function killProcess(pid, force = false, sudoPassword = null) { + if (IS_WIN) { + const flag = force ? "/F " : ""; + exec(`taskkill ${flag}/PID ${pid}`, { windowsHide: true }, () => { }); + } else { + const sig = force ? "SIGKILL" : "SIGTERM"; + const cmd = `pkill -${sig} -P ${pid} 2>/dev/null; kill -${sig} ${pid} 2>/dev/null`; + if (sudoPassword || isSudoAvailable()) { + const { execWithPassword } = require("./dns/dnsConfig"); + execWithPassword(cmd, sudoPassword || "").catch(() => exec(cmd, { windowsHide: true }, () => { })); + } else { + exec(cmd, { windowsHide: true }, () => { }); + } + } +} + +function deriveKey() { + try { + const { machineIdSync } = require("node-machine-id"); + const raw = machineIdSync(); + return crypto.createHash("sha256").update(raw + ENCRYPT_SALT).digest(); + } catch { + return crypto.createHash("sha256").update(ENCRYPT_SALT).digest(); + } +} + +function encryptPassword(plaintext) { + const key = deriveKey(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(ENCRYPT_ALGO, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`; +} + +function decryptPassword(stored) { + try { + const [ivHex, tagHex, dataHex] = stored.split(":"); + if (!ivHex || !tagHex || !dataHex) return null; + const key = deriveKey(); + const decipher = crypto.createDecipheriv(ENCRYPT_ALGO, key, Buffer.from(ivHex, "hex")); + decipher.setAuthTag(Buffer.from(tagHex, "hex")); + return decipher.update(Buffer.from(dataHex, "hex")) + decipher.final("utf8"); + } catch { + return null; + } +} + +let _getSettings = null; +let _updateSettings = null; + +function initDbHooks(getSettingsFn, updateSettingsFn) { + _getSettings = getSettingsFn; + _updateSettings = updateSettingsFn; +} + +async function saveMitmSettings(enabled, password) { + if (!_updateSettings) return; + try { + const updates = { mitmEnabled: enabled }; + if (password) updates.mitmSudoEncrypted = encryptPassword(password); + await _updateSettings(updates); + } catch (e) { + err(`Failed to save settings: ${e.message}`); + } +} + +async function clearEncryptedPassword() { + if (!_updateSettings) return; + try { + await _updateSettings({ mitmSudoEncrypted: null }); + } catch (e) { + err(`Failed to clear encrypted password: ${e.message}`); + } +} + +async function loadEncryptedPassword() { + if (!_getSettings) return null; + try { + const settings = await _getSettings(); + if (!settings.mitmSudoEncrypted) return null; + return decryptPassword(settings.mitmSudoEncrypted); + } catch { + return null; + } +} + +async function saveDnsToolState(tool, enabled) { + if (!_updateSettings || !_getSettings) return; + try { + const s = await _getSettings(); + const next = { ...(s.dnsToolEnabled || {}), [tool]: enabled }; + await _updateSettings({ dnsToolEnabled: next }); + } catch (e) { + err(`Failed to save DNS state: ${e.message}`); + } +} + +async function loadDnsToolState() { + if (!_getSettings) return {}; + try { + const s = await _getSettings(); + return s.dnsToolEnabled || {}; + } catch { + return {}; + } +} + +/** + * Re-apply DNS for tools previously enabled — called on app startup after MITM running. + */ +async function restoreToolDNS(sudoPassword) { + const state = await loadDnsToolState(); + const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); + for (const [tool, enabled] of Object.entries(state)) { + if (!enabled || !TOOL_HOSTS[tool]) continue; + try { + await addDNSEntry(tool, password); + } catch (e) { + err(`DNS ${tool}: restore failed — ${e.message}`); + } + } +} + +/** + * Check if user has privilege to mutate hosts file. + * Win: needs admin. Mac/Linux: root OR cached/encrypted sudo password. + */ +async function hasDnsPrivilege() { + if (IS_WIN) return isAdmin(); + if (isAdmin()) return true; + if (!isSudoPasswordRequired()) return true; + const pwd = getCachedPassword() || await loadEncryptedPassword(); + return !!pwd; +} + +function checkPort443Free() { + return new Promise((resolve) => { + const tester = net.createServer(); + tester.once("error", (err) => { + if (err.code === "EADDRINUSE") resolve("in-use"); + else resolve("no-permission"); + }); + tester.once("listening", () => { tester.close(() => resolve("free")); }); + tester.listen(MITM_PORT, "127.0.0.1"); + }); +} + +function getPort443Owner(sudoPassword) { + return new Promise((resolve) => { + if (IS_WIN) { + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "` + + `$c = Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; ` + + `if ($c) { $c.OwningProcess } else { 0 }"`; + exec(psCmd, { windowsHide: true }, (err, stdout) => { + if (err) return resolve(null); + const pid = parseInt(stdout.trim(), 10); + if (!pid || pid <= 4) return resolve(null); + exec(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { windowsHide: true }, (e2, out2) => { + const m = out2?.match(/"([^"]+)"/); + resolve({ pid, name: m ? m[1] : "unknown" }); + }); + }); + } else { + // Only find process actually LISTENING on TCP port 443 + exec(`${LSOF_BIN} -nP -iTCP:443 -sTCP:LISTEN -t`, { windowsHide: true }, (err, stdout) => { + if (err || !stdout?.trim()) return resolve(null); + const pid = parseInt(stdout.trim().split("\n")[0], 10); + if (!pid || isNaN(pid)) return resolve(null); + exec(`ps -p ${pid} -o comm=`, { windowsHide: true }, (e2, out2) => { + resolve({ pid, name: (out2?.trim() || "unknown") }); + }); + }); + } + }); +} + +async function killLeftoverMitm(sudoPassword) { + if (serverProcess && !serverProcess.killed) { + try { serverProcess.kill("SIGKILL"); } catch { /* ignore */ } + serverProcess = null; + serverPid = null; + } + try { + if (fs.existsSync(PID_FILE)) { + const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); + if (savedPid && isProcessAlive(savedPid)) { + killProcess(savedPid, true, sudoPassword); + await new Promise(r => setTimeout(r, 500)); + } + fs.unlinkSync(PID_FILE); + } + } catch { /* ignore */ } + if (!IS_WIN && SERVER_PATH) { + try { + const escaped = SERVER_PATH.replace(/'/g, "'\\''"); + if (sudoPassword || isSudoAvailable()) { + const { execWithPassword } = require("./dns/dnsConfig"); + await execWithPassword(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, sudoPassword || "").catch(() => { }); + } else { + exec(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, { windowsHide: true }, () => { }); + } + await new Promise(r => setTimeout(r, 500)); + } catch { /* ignore */ } + } +} + +function pollMitmHealth(timeoutMs, port = MITM_PORT) { + return new Promise((resolve) => { + const deadline = Date.now() + timeoutMs; + const check = () => { + const req = https.request( + { hostname: "127.0.0.1", port, path: "/_mitm_health", method: "GET", rejectUnauthorized: false }, + (res) => { + let body = ""; + res.on("data", (d) => { body += d; }); + res.on("end", () => { + try { + const json = JSON.parse(body); + resolve(json.ok === true ? { ok: true, pid: json.pid || null } : null); + } catch { resolve(null); } + }); + } + ); + req.on("error", () => { + if (Date.now() < deadline) setTimeout(check, 500); + else resolve(null); + }); + req.end(); + }; + check(); + }); +} + +/** + * Get full MITM status including per-tool DNS status + */ +async function getMitmStatus() { + let running = serverProcess !== null && !serverProcess.killed; + let pid = serverPid; + + if (!running) { + try { + if (fs.existsSync(PID_FILE)) { + const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); + if (savedPid && isProcessAlive(savedPid)) { + running = true; + pid = savedPid; + } else { + fs.unlinkSync(PID_FILE); + } + } + } catch { /* ignore */ } + } + + const dnsStatus = checkAllDNSStatus(); + const rootCACertPath = path.join(MITM_DIR, "rootCA.crt"); + const certExists = fs.existsSync(rootCACertPath); + const { checkCertInstalled } = require("./cert/install"); + const certTrusted = certExists ? await checkCertInstalled(rootCACertPath) : false; + + return { running, pid, certExists, certTrusted, dnsStatus }; +} + +async function scheduleMitmRestart(apiKey) { + if (mitmIsRestarting) return; + // Set guard synchronously before any await to prevent concurrent calls + // from passing the check above. + mitmIsRestarting = true; + + const aliveMs = Date.now() - mitmLastStartTime; + if (aliveMs >= MITM_RESTART_RESET_MS) mitmRestartCount = 0; + + if (mitmRestartCount >= MITM_MAX_RESTARTS) { + err("Max restart attempts reached. Giving up."); + mitmIsRestarting = false; + return; + } + + const attempt = mitmRestartCount; + const delay = MITM_RESTART_DELAYS_MS[Math.min(attempt, MITM_RESTART_DELAYS_MS.length - 1)]; + mitmRestartCount++; + + log(`Restarting in ${delay / 1000}s... (${mitmRestartCount}/${MITM_MAX_RESTARTS})`); + await new Promise((r) => setTimeout(r, delay)); + + try { + const settings = _getSettings ? await _getSettings() : null; + if (settings && !settings.mitmEnabled) { + log("MITM disabled, skipping restart"); + mitmIsRestarting = false; + return; + } + const password = getCachedPassword() || await loadEncryptedPassword(); + if (!password && !IS_WIN) { + err("No cached password, cannot auto-restart"); + mitmIsRestarting = false; + return; + } + await startServer(apiKey, password); + log("🔄 Restarted successfully"); + mitmRestartCount = 0; + mitmIsRestarting = false; + } catch (e) { + err(`Restart attempt ${mitmRestartCount}/${MITM_MAX_RESTARTS} failed: ${e.message}`); + mitmIsRestarting = false; + // Schedule next retry + scheduleMitmRestart(apiKey); + } +} + +/** + * Start MITM server only (cert + server, no DNS) + */ +async function killPort443Owner(owner, sudoPassword) { + if (!owner || !owner.pid) return; + if (IS_WIN) { + try { + execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${owner.pid} -Force -ErrorAction SilentlyContinue"`, { windowsHide: true }); + } catch { /* best effort */ } + } else { + try { + const { execWithPassword } = require("./dns/dnsConfig"); + if (sudoPassword || isSudoAvailable()) { + await execWithPassword(`kill -9 ${owner.pid}`, sudoPassword || ""); + } else { + execSync(`kill -9 ${owner.pid}`, { windowsHide: true }); + } + } catch { /* best effort */ } + } + await new Promise(r => setTimeout(r, 800)); +} + +async function startServer(apiKey, sudoPassword, forceKillPort443 = false) { + if (!serverProcess || serverProcess.killed) { + try { + if (fs.existsSync(PID_FILE)) { + const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); + if (savedPid && isProcessAlive(savedPid)) { + serverPid = savedPid; + log(`♻️ Reusing existing process (PID: ${savedPid})`); + await saveMitmSettings(true, sudoPassword); + if (sudoPassword) setCachedPassword(sudoPassword); + return { running: true, pid: savedPid }; + } else { + fs.unlinkSync(PID_FILE); + } + } + } catch { /* ignore */ } + } + + if (serverProcess && !serverProcess.killed) { + throw new Error("MITM server is already running"); + } + + // Atomically claim lock to prevent concurrent startServer across processes. + // O_EXCL (flag: "wx") fails with EEXIST if the file already exists. + try { + fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" }); + } catch (e) { + if (e.code === "EEXIST") { + let stale = false; + try { + const pid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8").trim(), 10); + stale = !pid || !isProcessAlive(pid); + } catch { stale = true; } // unreadable lock → treat as stale + if (!stale) throw new Error("MITM server is already starting (lock contention)"); + try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ } + fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" }); + } else throw e; + } + + try { + await killLeftoverMitm(sudoPassword); + + if (!IS_WIN) { + const portStatus = await checkPort443Free(); + if (portStatus === "in-use" || portStatus === "no-permission") { + const owner = await getPort443Owner(sudoPassword); + if (owner) { + const shortName = owner.name.includes("/") + ? owner.name.split("/").filter(Boolean).pop() + : owner.name; + if (forceKillPort443) { + log(`Killing process on port 443 (PID ${owner.pid}, name=${shortName})...`); + await killPort443Owner(owner, sudoPassword); + } else { + const e = new Error(`Port 443 is already in use by "${shortName}" (PID ${owner.pid}).`); + e.code = "PORT_443_BUSY"; + e.portOwner = { pid: owner.pid, name: shortName }; + throw e; + } + } + } + } + + // Step 1: Generate Root CA if missing or expired + const rootCACertPath = path.join(MITM_DIR, "rootCA.crt"); + const rootCAKeyPath = path.join(MITM_DIR, "rootCA.key"); + const certExists = fs.existsSync(rootCACertPath) && fs.existsSync(rootCAKeyPath); + + if (!certExists || isCertExpired(rootCACertPath)) { + if (certExists) { + // Uninstall expired cert from system store before regenerating + log("🔐 Cert expired — uninstalling old cert..."); + const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); + try { await uninstallCert(password, rootCACertPath); } catch { /* best effort */ } + } + log("🔐 Generating Root CA..."); + await generateCert(); + } + + // Step 1.5: Auto-install Root CA if not trusted yet + const { checkCertInstalled } = require("./cert/install"); + const rootCATrusted = await checkCertInstalled(rootCACertPath); + const linuxNoSystemTrust = !IS_WIN && !IS_MAC && !isSudoAvailable(); + if (!rootCATrusted) { + log("🔐 Cert: not trusted → installing..."); + const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); + if (linuxNoSystemTrust) { + log(`🔐 Cert: skipping system trust (no sudo). Install ${rootCACertPath} as a trusted CA on machines that use this proxy.`); + } else { + if (!password && isSudoPasswordRequired()) { + throw new Error("Sudo password required to install Root CA certificate"); + } + try { + await installCert(password, rootCACertPath); + log("🔐 Cert: ✅ trusted"); + } catch (e) { + throw new Error(`Failed to trust certificate: ${e.message}`); + } + } + } else { + log("🔐 Cert: already trusted ✅"); + } + + // Step 2: Spawn server (Root CA already installed in Step 1.5) + // Verify server.js exists — recopy if runtime file was deleted (antivirus/cleanup) + let effectiveServerPath = SERVER_PATH; + if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) { + log(`[MITM] server.js missing at ${effectiveServerPath} → recopying`); + effectiveServerPath = ensureRuntimeServer(resolveBundledServerPath()); + if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) { + throw new Error(`MITM server.js not found at ${effectiveServerPath}. Reinstall 9router.`); + } + } + const mitmRouterBase = await resolveMitmRouterBaseUrl(); + log(`🚀 Starting server... (router: ${mitmRouterBase})`); + if (IS_WIN) { + // Check port 443 — ask user before killing + const winOwner = await getPort443Owner(sudoPassword); + if (winOwner) { + if (forceKillPort443) { + log(`Killing process on port 443 (PID ${winOwner.pid}, name=${winOwner.name})...`); + await killPort443Owner(winOwner, sudoPassword); + } else { + const e = new Error(`Port 443 is already in use by "${winOwner.name}" (PID ${winOwner.pid}).`); + e.code = "PORT_443_BUSY"; + e.portOwner = { pid: winOwner.pid, name: winOwner.name }; + throw e; + } + } + + // Spawn directly — process already has admin rights + // cwd=tmpdir so process doesn't lock the install dir on Windows (EBUSY on update) + serverProcess = spawn( + process.execPath, + [effectiveServerPath], + { + detached: false, + windowsHide: true, + cwd: os.tmpdir(), + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + ROUTER_API_KEY: apiKey, + NODE_ENV: "production", + MITM_ROUTER_BASE: mitmRouterBase, + }, + } + ); + + if (_updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); + } else if (isSudoAvailable()) { + // Pass HOME explicitly so os.homedir() resolves to the unprivileged user's home + // instead of /root when sudo resets the environment. + const inlineCmd = [ + `HOME=${shellQuoteSingle(os.homedir())}`, + `ROUTER_API_KEY=${shellQuoteSingle(apiKey)}`, + `MITM_ROUTER_BASE=${shellQuoteSingle(mitmRouterBase)}`, + "NODE_ENV=production", + shellQuoteSingle(process.execPath), + shellQuoteSingle(effectiveServerPath), + ].join(" "); + serverProcess = spawn( + "sudo", ["-S", "-E", "sh", "-c", inlineCmd], + { detached: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] } + ); + serverProcess.stdin.write(`${sudoPassword}\n`); + serverProcess.stdin.end(); + } else { + // Docker/minimal images: no sudo — same as Windows-style direct spawn + serverProcess = spawn(process.execPath, [effectiveServerPath], { + detached: false, + windowsHide: true, + cwd: os.tmpdir(), + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + ROUTER_API_KEY: apiKey, + NODE_ENV: "production", + MITM_ROUTER_BASE: mitmRouterBase, + }, + }); + } + + if (serverProcess) { + serverPid = serverProcess.pid; + fs.writeFileSync(PID_FILE, String(serverPid)); + mitmLastStartTime = Date.now(); + } + + // Set NODE_EXTRA_CA_CERTS so Node-based GUI apps (Electron/AG language_server) trust MITM cert + if (IS_MAC) { + const rootCAPath = path.join(MITM_DIR, "rootCA.crt"); + if (fs.existsSync(rootCAPath)) { + exec(`launchctl setenv NODE_EXTRA_CA_CERTS "${rootCAPath}"`, { windowsHide: true }, (e) => { + if (e) log(`[launchctl] Failed to set NODE_EXTRA_CA_CERTS: ${e.message}`); + else log(`[launchctl] NODE_EXTRA_CA_CERTS set to ${rootCAPath}`); + }); + } + } else if (IS_WIN) { + const rootCAPath = path.join(MITM_DIR, "rootCA.crt"); + if (fs.existsSync(rootCAPath)) { + exec(`setx NODE_EXTRA_CA_CERTS "${rootCAPath}"`, { windowsHide: true }, (e) => { + if (e) log(`[setx] Failed to set NODE_EXTRA_CA_CERTS: ${e.message}`); + else log(`[setx] NODE_EXTRA_CA_CERTS set for current user`); + }); + } + } + + let startError = null; + if (serverProcess) { + serverProcess.stdout.on("data", (data) => { + // server.js already formats its own logs — print as-is + process.stdout.write(data); + }); + serverProcess.stderr.on("data", (data) => { + const msg = data.toString().trim(); + // Mac/Linux: filter sudo password prompt noise + if (msg && (IS_WIN || (!msg.includes("Password:") && !msg.includes("password for")))) { + err(msg); + startError = msg; + } + // Detect wrong/missing password — clear cache and stop retry loop + if (!IS_WIN && (msg.includes("incorrect password") || msg.includes("no password was provided"))) { + setCachedPassword(null); + clearEncryptedPassword(); + mitmIsRestarting = true; // prevent scheduleMitmRestart from firing + } + }); + serverProcess.on("exit", (code) => { + log(`Server exited (code: ${code})`); + serverProcess = null; + serverPid = null; + try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ } + try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ } + // Auto-restart on unexpected exit + if (code !== 0 && !mitmIsRestarting) scheduleMitmRestart(apiKey); + }); + } + + const health = await pollMitmHealth(8000, MITM_PORT); + if (!health) { + if (serverProcess && !serverProcess.killed) { try { serverProcess.kill(); } catch { /* ignore */ } serverProcess = null; } + const processUsing443 = getProcessUsingPort443(); + const portInfo = processUsing443 ? ` Port 443 already in use by ${processUsing443}.` : ""; + const reason = startError || `Check sudo password or port 443 access.${portInfo}`; + throw new Error(`MITM server failed to start. ${reason}`); + } + + if (_updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); + + log(`✅ Server healthy (PID: ${serverPid || health.pid})`); + + // Log DNS status per tool + const dnsStatus = checkAllDNSStatus(); + for (const [tool, active] of Object.entries(dnsStatus)) { + log(`🌐 DNS ${tool}: ${active ? "✅ active" : "❌ inactive"}`); + } + + await saveMitmSettings(true, sudoPassword); + if (sudoPassword) setCachedPassword(sudoPassword); + + // Server is healthy — remove lock file (PID file persists as the marker) + try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ } + + return { running: true, pid: serverPid }; + } catch (e) { + // Clean up lock on any failure + try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ } + throw e; + } +} + +/** + * Stop MITM server — removes ALL tool DNS entries first, then kills server + */ +async function stopServer(sudoPassword) { + // Prevent auto-restart from triggering on intentional stop + mitmIsRestarting = true; + mitmRestartCount = 0; + log("⏹ Stopping server..."); + + // Kill server process + const proc = serverProcess; + const pidToKill = proc && !proc.killed + ? proc.pid + : (() => { try { return parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); } catch { return null; } })(); + + if (pidToKill && isProcessAlive(pidToKill)) { + log(`Killing server (PID: ${pidToKill})...`); + killProcess(pidToKill, false, sudoPassword); + await new Promise(r => setTimeout(r, 1000)); + if (isProcessAlive(pidToKill)) killProcess(pidToKill, true, sudoPassword); + } + serverProcess = null; + serverPid = null; + + if (IS_WIN) { + const hostsFile = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts"); + const allHosts = Object.values(TOOL_HOSTS).flat(); + try { + const { isAdmin, runElevatedPowerShell, quotePs } = require("./winElevated.js"); + if (isAdmin()) { + // Direct fs write — bypass PowerShell to avoid parser pitfalls + const content = fs.readFileSync(hostsFile, "utf8"); + const filtered = content.split(/\r?\n/).filter(l => !allHosts.some(h => l.includes(h))).join("\r\n"); + const next = filtered.replace(/[\r\n\s]+$/g, "") + "\r\n"; + if (next !== content) fs.writeFileSync(hostsFile, next, "utf8"); + try { require("child_process").execSync("ipconfig /flushdns", { windowsHide: true, stdio: "ignore" }); } catch { /* ignore */ } + log("🌐 DNS: ✅ all tool hosts removed"); + } else { + const hostsList = allHosts.map(quotePs).join(","); + const script = ` + $hosts = @(${hostsList}) + $lines = Get-Content -LiteralPath ${quotePs(hostsFile)} + $filtered = $lines | Where-Object { + $line = $_ + -not ($hosts | Where-Object { $line -match [regex]::Escape($_) }) + } + Set-Content -LiteralPath ${quotePs(hostsFile)} -Value $filtered + ipconfig /flushdns | Out-Null + `; + await runElevatedPowerShell(script); + } + } catch (e) { err(`Failed to clean hosts: ${e.message}`); } + } else { + await removeAllDNSEntries(sudoPassword); + } + + // Unset NODE_EXTRA_CA_CERTS so apps don't keep trusting stale MITM cert + if (IS_MAC) { + exec(`launchctl unsetenv NODE_EXTRA_CA_CERTS`, { windowsHide: true }, (e) => { + if (e) log(`[launchctl] Failed to unset NODE_EXTRA_CA_CERTS: ${e.message}`); + else log(`[launchctl] NODE_EXTRA_CA_CERTS unset`); + }); + } else if (IS_WIN) { + exec(`reg delete HKCU\\Environment /F /V NODE_EXTRA_CA_CERTS`, { windowsHide: true }, (e) => { + if (e) log(`[reg] Failed to unset NODE_EXTRA_CA_CERTS: ${e.message}`); + else log(`[reg] NODE_EXTRA_CA_CERTS unset`); + }); + } + + try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ } + try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ } + await saveMitmSettings(false, null); + mitmIsRestarting = false; + + return { running: false, pid: null }; +} + +/** + * Enable DNS for a specific tool (requires server running) + */ +async function enableToolDNS(tool, sudoPassword) { + const status = await getMitmStatus(); + if (!status.running) throw new Error("MITM server is not running. Start the server first."); + + const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); + await addDNSEntry(tool, password); + await saveDnsToolState(tool, true); + return { success: true }; +} + +/** + * Disable DNS for a specific tool + */ +async function disableToolDNS(tool, sudoPassword) { + const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); + await removeDNSEntry(tool, password); + await saveDnsToolState(tool, false); + return { success: true }; +} + +/** + * Install Root CA to system trust store (standalone, no server start) + */ +async function trustCert(sudoPassword) { + const rootCACertPath = path.join(MITM_DIR, "rootCA.crt"); + if (!fs.existsSync(rootCACertPath)) throw new Error("Root CA not found. Start server first to generate it."); + const { installCert } = require("./cert/install"); + if (!IS_WIN && !IS_MAC && !isSudoAvailable()) { + log(`🔐 Cert: system trust unavailable (no sudo). Use file: ${rootCACertPath}`); + return; + } + const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); + if (!password && isSudoPasswordRequired()) throw new Error("Sudo password required to trust certificate"); + await installCert(password, rootCACertPath); + if (password) setCachedPassword(password); +} + +// Legacy aliases for backward compatibility +const startMitm = startServer; +const stopMitm = stopServer; + +module.exports = { + getMitmStatus, + startServer, + stopServer, + enableToolDNS, + disableToolDNS, + trustCert, + // Legacy + startMitm, + stopMitm, + getCachedPassword, + setCachedPassword, + loadEncryptedPassword, + clearEncryptedPassword, + isSudoPasswordRequired, + initDbHooks, + restoreToolDNS, + hasDnsPrivilege, + removeAllDNSEntriesSync, +}; diff --git a/src/mitm/paths.js b/src/mitm/paths.js new file mode 100644 index 0000000..892c225 --- /dev/null +++ b/src/mitm/paths.js @@ -0,0 +1,32 @@ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +const APP_NAME = "9router"; + +function defaultDir() { + if (process.platform === "win32") { + return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), APP_NAME); + } + return path.join(os.homedir(), `.${APP_NAME}`); +} + +function getDataDir() { + const configured = process.env.DATA_DIR; + if (!configured) return defaultDir(); + try { + fs.mkdirSync(configured, { recursive: true }); + return configured; + } catch (e) { + if (e?.code === "EACCES" || e?.code === "EPERM") { + console.warn(`[DATA_DIR] '${configured}' not writable → fallback ~/.${APP_NAME}`); + return defaultDir(); + } + throw e; + } +} + +const DATA_DIR = getDataDir(); +const MITM_DIR = path.join(DATA_DIR, "mitm"); + +module.exports = { DATA_DIR, MITM_DIR }; diff --git a/src/mitm/server.js b/src/mitm/server.js new file mode 100644 index 0000000..d675e6a --- /dev/null +++ b/src/mitm/server.js @@ -0,0 +1,440 @@ +const https = require("https"); +const http2 = require("http2"); +const tls = require("tls"); +const fs = require("fs"); +const path = require("path"); +const dns = require("dns"); +const { promisify } = require("util"); +const { execSync } = require("child_process"); +const { log, err, dumpRequest, createResponseDumper, clearDumpDir } = require("./logger"); +const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost } = require("./config"); +const { DATA_DIR, MITM_DIR } = require("./paths"); +const { generateCert, getCertForDomain } = require("./cert/generate"); +const { getMitmAlias } = require("./dbReader"); +const { applyAntigravityIdeVersionOverride } = require("./antigravityIdeVersion"); +const LOCAL_PORT = 443; +const IS_WIN = process.platform === "win32"; +const ENABLE_FILE_LOG = IS_DEV; + +// Clear stale dump files on every MITM start (prevents unbounded disk usage) +clearDumpDir(); +const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" }; + +// Host rewrite for upstream forward: PROD cloudcode-pa is rate-limited (429), +// daily-cloudcode-pa (dev endpoint) accepts same body+token. Same trick as open-sse. +const HOST_REWRITE = { + "cloudcode-pa.googleapis.com": "daily-cloudcode-pa.googleapis.com", +}; + +const handlers = { + antigravity: require("./handlers/antigravity"), + copilot: require("./handlers/copilot"), + kiro: require("./handlers/kiro"), + cursor: require("./handlers/cursor"), +}; + +// ── SSL / SNI ───────────────────────────────────────────────── + +const certCache = new Map(); +let rootCAPem; + +function sniCallback(servername, cb) { + try { + if (certCache.has(servername)) return cb(null, certCache.get(servername)); + const certData = getCertForDomain(servername); + if (!certData) return cb(new Error(`Failed to generate cert for ${servername}`)); + const ctx = require("tls").createSecureContext({ + key: certData.key, + cert: `${certData.cert}\n${rootCAPem}` + }); + certCache.set(servername, ctx); + cb(null, ctx); + } catch (e) { + err(`SNI error for ${servername}: ${e.message}`); + cb(e); + } +} + +let sslOptions; +try { + if (!fs.existsSync(path.join(MITM_DIR, "rootCA.key")) || !fs.existsSync(path.join(MITM_DIR, "rootCA.crt"))) { + log("Root CA missing, generating..."); + generateCert(); + } + + const rootKey = fs.readFileSync(path.join(MITM_DIR, "rootCA.key")); + const rootCert = fs.readFileSync(path.join(MITM_DIR, "rootCA.crt")); + rootCAPem = rootCert.toString("utf8"); + sslOptions = { key: rootKey, cert: rootCert, SNICallback: sniCallback }; +} catch (e) { + err(`Root CA not found: ${e.message}`); + process.exit(1); +} + +// ── Helpers ─────────────────────────────────────────────────── + +const cachedTargetIPs = {}; +const CACHE_TTL_MS = 5 * 60 * 1000; + +async function resolveTargetIP(hostname) { + const cached = cachedTargetIPs[hostname]; + if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.ip; + const resolver = new dns.Resolver(); + resolver.setServers(["8.8.8.8"]); + const resolve4 = promisify(resolver.resolve4.bind(resolver)); + const addresses = await resolve4(hostname); + cachedTargetIPs[hostname] = { ip: addresses[0], ts: Date.now() }; + return cachedTargetIPs[hostname].ip; +} + +function collectBodyRaw(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", chunk => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +// Extract model from URL path (Gemini), body (OpenAI/Anthropic), or Kiro conversationState +function extractModel(url, body) { + const urlMatch = url.match(/\/models\/([^/:]+)/); + if (urlMatch) return urlMatch[1]; + + // Skip parsing if body is binary (AWS EventStream, Protocol Buffers, etc.) + if (isBinaryData(body)) return null; + + try { + const parsed = JSON.parse(body.toString()); + if (parsed.conversationState) { + return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null; + } + return parsed.model || null; + } catch { return null; } +} + +// Detect binary data vs JSON text +function isBinaryData(buffer) { + if (!buffer || buffer.length === 0) return false; + // AWS EventStream signature: first 4 bytes = frame length (big-endian uint32) + // Check for non-printable chars in first 100 bytes (common in binary protocols) + const sample = buffer.slice(0, Math.min(100, buffer.length)); + let nonPrintable = 0; + for (let i = 0; i < sample.length; i++) { + const byte = sample[i]; + // Count non-ASCII printable chars (excluding whitespace) + if (byte < 0x20 && byte !== 0x09 && byte !== 0x0A && byte !== 0x0D) { + nonPrintable++; + } + if (byte > 0x7E) nonPrintable++; + } + // If >30% non-printable, treat as binary + return (nonPrintable / sample.length) > 0.3; +} + +function getMappedModel(tool, model) { + if (!model) return null; + try { + const aliases = getMitmAlias(tool); + if (!aliases) return null; + // Normalize via synonym map (e.g., public AG names -> backend model ids) + const normalizedModel = String(model).replace(/^models\//, ""); + const lookup = MODEL_SYNONYMS?.[tool]?.[normalizedModel] || normalizedModel; + if (aliases[lookup]) return aliases[lookup]; + // Prefix match fallback + const prefixKey = Object.keys(aliases).find(k => k && aliases[k] && (lookup.startsWith(k) || k.startsWith(lookup))); + if (prefixKey) return aliases[prefixKey]; + // Pattern fallback: catches AG renamed variants (e.g. deprecated pro IDs → gemini-pro-agent) + const patterns = MODEL_PATTERNS?.[tool] || []; + for (const { match, alias } of patterns) { + if (match.test(lookup) && aliases[alias]) return aliases[alias]; + } + return null; + } catch { return null; } +} + +/** + * Forward request to real upstream. + * Optional onResponse(rawBuffer) callback — if provided, tees the response + * so it's both forwarded to client AND passed to the callback for inspection. + * Also tees full stream into a dump file when ENABLE_FILE_LOG is on. + */ +async function passthrough(req, res, bodyBuffer, onResponse) { + const originalHost = (req.headers.host || TARGET_HOSTS[0]).split(":")[0]; + // Only rewrite host for chat endpoints — daily-cloudcode-pa rejects auth/login requests + const isChatEndpoint = req.url.includes(":generateContent") || req.url.includes(":streamGenerateContent"); + const targetHost = isChatEndpoint ? (HOST_REWRITE[originalHost] || originalHost) : originalHost; + const dumper = ENABLE_FILE_LOG ? createResponseDumper(req, "passthrough") : null; + + const tool = getToolForHost(req.headers.host); + const versionOverride = tool === "antigravity" + ? applyAntigravityIdeVersionOverride(bodyBuffer, req.headers) + : { bodyBuffer, headers: req.headers }; + const bodyForForwarding = versionOverride.bodyBuffer; + const headersForForwarding = { ...versionOverride.headers, host: targetHost }; + if (bodyForForwarding !== bodyBuffer) { + headersForForwarding["content-length"] = String(bodyForForwarding.length); + } + + // ALPN negotiate: try HTTP/2 first (like browsers/mitmweb), fallback HTTP/1.1 + try { + const proto = await negotiateAlpn(targetHost); + if (proto === "h2") { + return await passthroughHttp2(req, res, bodyForForwarding, headersForForwarding, targetHost, onResponse, dumper); + } + } catch (e) { + err(`[mitm] ALPN negotiate failed: ${e.message}, fallback to HTTP/1.1`); + } + + return passthroughHttps(req, res, bodyForForwarding, headersForForwarding, targetHost, onResponse, dumper); +} + +// ── ALPN negotiation cache ──────────────────────────────────── +const alpnCache = new Map(); // host → "h2" | "http/1.1" +async function negotiateAlpn(host) { + if (alpnCache.has(host)) return alpnCache.get(host); + const ip = await resolveTargetIP(host); + return new Promise((resolve, reject) => { + const socket = tls.connect({ + host: ip, port: 443, servername: host, + ALPNProtocols: ["h2", "http/1.1"], rejectUnauthorized: false, + }, () => { + const proto = socket.alpnProtocol || "http/1.1"; + alpnCache.set(host, proto); + log(`🔗 [mitm] ALPN ${host} → ${proto}`); + socket.end(); + resolve(proto); + }); + socket.once("error", reject); + socket.setTimeout(5000, () => { socket.destroy(new Error("ALPN timeout")); }); + }); +} + +// HTTP/2 passthrough using node:http2 native +async function passthroughHttp2(req, res, bodyBuffer, headers, targetHost, onResponse, dumper) { + const targetIP = await resolveTargetIP(targetHost); + // HTTP/2 pseudo-headers required; strip HTTP/1.1-only headers + const h2Headers = {}; + for (const [k, v] of Object.entries(headers)) { + const lk = k.toLowerCase(); + if (lk === "host" || lk === "connection" || lk === "keep-alive" || + lk === "transfer-encoding" || lk === "upgrade" || lk === "proxy-connection") continue; + h2Headers[lk] = v; + } + h2Headers[":method"] = req.method; + h2Headers[":path"] = req.url; + h2Headers[":scheme"] = "https"; + h2Headers[":authority"] = targetHost; + + return new Promise((resolve) => { + const client = http2.connect(`https://${targetHost}`, { + createConnection: () => tls.connect({ + host: targetIP, port: 443, servername: targetHost, + ALPNProtocols: ["h2"], rejectUnauthorized: false, + }), + }); + client.once("error", (e) => { + err(`[mitm] http2 client error: ${e.message}`); + if (dumper) { dumper.writeChunk(`\n[ERROR h2] ${e.message}\n`); dumper.end(); } + if (!res.headersSent) res.writeHead(502); + if (!res.writableEnded) res.end("Bad Gateway"); + try { client.close(); } catch {} + resolve(); + }); + + const stream = client.request(h2Headers, { endStream: bodyBuffer.length === 0 }); + if (bodyBuffer.length > 0) stream.end(bodyBuffer); + + stream.once("response", (responseHeaders) => { + const status = responseHeaders[":status"]; + // Filter pseudo-headers + connection-specific + const outHeaders = {}; + for (const [k, v] of Object.entries(responseHeaders)) { + if (k.startsWith(":")) continue; + if (k === "connection" || k === "keep-alive" || k === "transfer-encoding") continue; + outHeaders[k] = v; + } + res.writeHead(status, outHeaders); + if (dumper) dumper.writeHeader(status, outHeaders); + + const chunks = []; + stream.on("data", chunk => { + if (dumper) dumper.writeChunk(chunk); + if (onResponse) chunks.push(chunk); + res.write(chunk); + }); + stream.on("end", () => { + if (dumper) dumper.end(); + if (!res.writableEnded) res.end(); + if (onResponse) try { onResponse(Buffer.concat(chunks), outHeaders); } catch {} + try { client.close(); } catch {} + resolve(); + }); + }); + stream.once("error", (e) => { + err(`[mitm] http2 stream error: ${e.message}`); + if (dumper) { dumper.writeChunk(`\n[ERROR h2-stream] ${e.message}\n`); dumper.end(); } + if (!res.headersSent) res.writeHead(502); + if (!res.writableEnded) res.end(); + try { client.close(); } catch {} + resolve(); + }); + }); +} + +// Fallback: raw https.request HTTP/1.1 with custom DNS (bypasses /etc/hosts MITM loop) +async function passthroughHttps(req, res, bodyBuffer, headers, targetHost, onResponse, dumper) { + const targetIP = await resolveTargetIP(targetHost); + const forwardReq = https.request({ + hostname: targetIP, + port: 443, + path: req.url, + method: req.method, + headers, + servername: targetHost, + rejectUnauthorized: false + }, (forwardRes) => { + res.writeHead(forwardRes.statusCode, forwardRes.headers); + if (dumper) dumper.writeHeader(forwardRes.statusCode, forwardRes.headers); + + if (!onResponse && !dumper) { + forwardRes.pipe(res); + return; + } + + const chunks = []; + forwardRes.on("data", chunk => { + if (dumper) dumper.writeChunk(chunk); + if (onResponse) chunks.push(chunk); + res.write(chunk); + }); + forwardRes.on("end", () => { + if (dumper) dumper.end(); + res.end(); + if (onResponse) try { onResponse(Buffer.concat(chunks), forwardRes.headers); } catch { /* ignore */ } + }); + }); + + forwardReq.on("error", (e) => { + err(`Passthrough error: ${e.message}`); + if (dumper) { dumper.writeChunk(`\n[ERROR] ${e.message}\n`); dumper.end(); } + if (!res.headersSent) res.writeHead(502); + res.end("Bad Gateway"); + }); + + if (bodyBuffer.length > 0) forwardReq.write(bodyBuffer); + forwardReq.end(); +} + +// ── Request handler ─────────────────────────────────────────── + +const server = https.createServer(sslOptions, async (req, res) => { + try { + if (req.url === "/_mitm_health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, pid: process.pid })); + return; + } + + const bodyBuffer = await collectBodyRaw(req); + if (ENABLE_FILE_LOG) dumpRequest(req, bodyBuffer, "raw"); + + // Anti-loop: skip requests from 9Router + if (req.headers[INTERNAL_REQUEST_HEADER.name] === INTERNAL_REQUEST_HEADER.value) { + return passthrough(req, res, bodyBuffer); + } + + const tool = getToolForHost(req.headers.host); + if (!tool) return passthrough(req, res, bodyBuffer); + + const patterns = URL_PATTERNS[tool] || []; + const isChat = patterns.some(p => req.url.includes(p)); + if (!isChat) return passthrough(req, res, bodyBuffer); + + // Cursor uses binary proto — model extraction not possible at this layer. + // Delegate directly to handler which decodes proto internally. + if (tool === "cursor") { + return handlers[tool].intercept(req, res, bodyBuffer, null, passthrough); + } + + const model = extractModel(req.url, bodyBuffer); + + // Intentional passthrough: some models must never be re-routed (e.g. Antigravity + // tab-autocomplete) so latency-critical inline completion stays native. Silent — this + // is by design, not a leak, and fires per keystroke. See MODEL_NO_MAP in config.js. + if (model && (MODEL_NO_MAP[tool] || []).some((re) => re.test(model))) { + return passthrough(req, res, bodyBuffer); + } + + const mappedModel = getMappedModel(tool, model); + if (!mappedModel) { + return passthrough(req, res, bodyBuffer); + } + + return handlers[tool].intercept(req, res, bodyBuffer, mappedModel, passthrough); + } catch (e) { + err(`Unhandled error: ${e.message}`); + if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: e.message, type: "mitm_error" } })); + } +}); + +// Kill only processes LISTENING on LOCAL_PORT (not outbound connections) +function killPort(port) { + try { + let pidList = []; + if (IS_WIN) { + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command ` + + `"Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess"`; + const out = execSync(psCmd, { encoding: "utf-8", windowsHide: true }).trim(); + if (!out) return; + pidList = out.split(/\r?\n/).map(s => s.trim()).filter(p => p && Number(p) !== process.pid && Number(p) > 4); + } else { + const out = execSync(`${LSOF_BIN} -nP -iTCP:${port} -sTCP:LISTEN -t`, { encoding: "utf-8", windowsHide: true }).trim(); + if (!out) return; + pidList = out.split("\n").filter(p => p && Number(p) !== process.pid); + } + if (pidList.length === 0) return; + pidList.forEach(pid => { + try { + if (IS_WIN) execSync(`taskkill /F /PID ${pid}`, { windowsHide: true }); + else process.kill(Number(pid), "SIGKILL"); + } catch (e) { + err(`Failed to kill PID ${pid}: ${e.message}`); + } + }); + log(`Killed ${pidList.length} process(es) on port ${port}`); + } catch (e) { + if (e.status !== 1) throw e; + } +} + +try { + killPort(LOCAL_PORT); +} catch (e) { + err(`Cannot kill process on port ${LOCAL_PORT}: ${e.message}`); + process.exit(1); +} + +server.listen(LOCAL_PORT, () => log(`🚀 Server ready on :${LOCAL_PORT}`)); + +server.on("error", (e) => { + if (e.code === "EADDRINUSE") err(`Port ${LOCAL_PORT} already in use`); + else if (e.code === "EACCES") err(`Permission denied for port ${LOCAL_PORT}`); + else err(e.message); + process.exit(1); +}); + +const { removeAllDNSEntriesSync } = require("./dns/dnsConfig"); +let isShuttingDown = false; +const shutdown = () => { + if (isShuttingDown) return; + isShuttingDown = true; + // Strip tool hosts from /etc/hosts so other apps aren't broken after exit + removeAllDNSEntriesSync(); + const forceExit = setTimeout(() => process.exit(0), 1500); + server.close(() => { clearTimeout(forceExit); process.exit(0); }); +}; +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); +if (process.platform === "win32") process.on("SIGBREAK", shutdown); diff --git a/src/mitm/winElevated.js b/src/mitm/winElevated.js new file mode 100644 index 0000000..0ff6aa5 --- /dev/null +++ b/src/mitm/winElevated.js @@ -0,0 +1,81 @@ +const { exec, execSync } = require("child_process"); + +const IS_WIN = process.platform === "win32"; + +/** + * Detect if current Windows process has admin rights (no UAC popup needed). + * Uses `net session` which only succeeds when elevated. + */ +function isAdmin() { + if (IS_WIN) { + try { + execSync("net session >nul 2>&1", { windowsHide: true, stdio: "ignore" }); + return true; + } catch { + return false; + } + } + return typeof process.getuid === "function" && process.getuid() === 0; +} + +/** + * Quote a string safely for PowerShell single-quoted literal. + */ +function quotePs(value) { + return `'${String(value).replace(/'/g, "''")}'`; +} + +/** + * Run PowerShell script — escalated via UAC popup if not already admin. + * Returns Promise resolving on exit code 0, rejecting otherwise. + * + * IMPORTANT: each call triggers ONE UAC popup. Batch multiple admin tasks + * into a single script string to minimize popups. + */ +function runElevatedPowerShell(script) { + if (!IS_WIN) return Promise.reject(new Error("Windows-only")); + + const encoded = Buffer.from(script, "utf16le").toString("base64"); + + // If already admin, run directly — zero popup + if (isAdmin()) { + return new Promise((resolve, reject) => { + exec( + `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`, + { windowsHide: true }, + (error, stdout, stderr) => { + if (error) reject(new Error(stderr || error.message)); + else resolve(stdout); + } + ); + }); + } + + // Not admin — wrap with Start-Process -Verb RunAs (UAC popup) + const wrapper = ` + $proc = Start-Process powershell -ArgumentList @( + '-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass', + '-WindowStyle','Hidden','-EncodedCommand','${encoded}' + ) -Verb RunAs -Wait -PassThru -WindowStyle Hidden; + if ($proc.ExitCode -ne 0) { throw "Elevated command exited with code $($proc.ExitCode)" } + `; + + return new Promise((resolve, reject) => { + exec( + `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ${quotePs(wrapper)}`, + { windowsHide: true }, + (error, stdout, stderr) => { + if (error) { + const msg = stderr || error.message; + if (msg.includes("canceled by the user") || msg.includes("operation was canceled")) { + reject(new Error("User canceled UAC prompt")); + } else { + reject(new Error(msg)); + } + } else resolve(stdout); + } + ); + }); +} + +module.exports = { isAdmin, runElevatedPowerShell, quotePs }; diff --git a/src/models/index.js b/src/models/index.js new file mode 100644 index 0000000..99444f6 --- /dev/null +++ b/src/models/index.js @@ -0,0 +1,38 @@ +// Database Models - Export all from localDb +export { + getProviderConnections, + getProviderConnectionById, + createProviderConnection, + updateProviderConnection, + deleteProviderConnection, + getProviderNodes, + getProviderNodeById, + createProviderNode, + updateProviderNode, + deleteProviderNode, + getProxyPools, + getProxyPoolById, + createProxyPool, + updateProxyPool, + deleteProxyPool, + deleteProviderConnectionsByProvider, + getCombos, + getComboById, + getComboByName, + createCombo, + updateCombo, + deleteCombo, + getModelAliases, + setModelAlias, + deleteModelAlias, + getCustomModels, + addCustomModel, + deleteCustomModel, + getMitmAlias, + setMitmAliasAll, + getApiKeys, + createApiKey, + deleteApiKey, + validateApiKey, + isCloudEnabled, +} from "@/lib/localDb"; diff --git a/src/proxy.js b/src/proxy.js new file mode 100644 index 0000000..566dd14 --- /dev/null +++ b/src/proxy.js @@ -0,0 +1,9 @@ +import { proxy as dashboardProxy } from "./dashboardGuard"; + +export default async function proxy(request) { + return dashboardProxy(request); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon\\.ico).*)"], +}; diff --git a/src/shared/components/AddCustomEmbeddingModal.js b/src/shared/components/AddCustomEmbeddingModal.js new file mode 100644 index 0000000..db17fe7 --- /dev/null +++ b/src/shared/components/AddCustomEmbeddingModal.js @@ -0,0 +1,183 @@ +"use client"; + +import { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Modal, Input, Button, Badge } from "@/shared/components"; + +const DEFAULT_BASE_URL = "https://api.openai.com/v1"; + +// Dual-mode modal: edit when `node` provided, add otherwise +export default function AddCustomEmbeddingModal({ isOpen, onClose, onCreated, onSaved, node }) { + const isEdit = !!node; + const [formData, setFormData] = useState({ + name: "", + prefix: "", + baseUrl: DEFAULT_BASE_URL, + }); + const [submitting, setSubmitting] = useState(false); + const [checkKey, setCheckKey] = useState(""); + const [checkModelId, setCheckModelId] = useState(""); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + + useEffect(() => { + if (!isOpen) return; + setValidationResult(null); + setCheckKey(""); + setCheckModelId(""); + if (isEdit) { + setFormData({ + name: node.name || "", + prefix: node.prefix || "", + baseUrl: node.baseUrl || DEFAULT_BASE_URL, + }); + } else { + setFormData({ name: "", prefix: "", baseUrl: DEFAULT_BASE_URL }); + } + }, [isOpen, isEdit, node]); + + const handleSubmit = async () => { + if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; + setSubmitting(true); + try { + const url = isEdit ? `/api/provider-nodes/${node.id}` : "/api/provider-nodes"; + const method = isEdit ? "PUT" : "POST"; + const payload = { + name: formData.name, + prefix: formData.prefix, + baseUrl: formData.baseUrl, + }; + if (!isEdit) payload.type = "custom-embedding"; + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (res.ok) { + if (isEdit) onSaved?.(data.node); + else onCreated?.(data.node); + } + } catch (error) { + console.log("Error saving custom embedding node:", error); + } finally { + setSubmitting(false); + } + }; + + const handleValidate = async () => { + setValidating(true); + try { + const res = await fetch("/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: formData.baseUrl, + apiKey: checkKey, + type: "custom-embedding", + modelId: checkModelId.trim() || undefined, + }), + }); + const data = await res.json(); + setValidationResult(data); + } catch { + setValidationResult({ valid: false, error: "Network error" }); + } finally { + setValidating(false); + } + }; + + const renderValidationResult = () => { + if (!validationResult) return null; + const { valid, error, dimensions } = validationResult; + if (valid) { + return ( + <> + Valid + {dimensions && {dimensions} dims} + + ); + } + return ( +
+ Invalid + {error && {error}} +
+ ); + }; + + return ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder="Voyage AI" + hint="Required. A friendly label for this embedding provider." + /> + setFormData({ ...formData, prefix: e.target.value })} + placeholder="voyage" + hint="Required. Used as the provider prefix for model IDs (e.g. voyage/voyage-3)." + /> + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder="https://api.voyageai.com/v1" + hint="Most embedding APIs are OpenAI-compatible: Voyage, Cohere, Jina, Mistral, Together..." + /> + setCheckKey(e.target.value)} + /> + setCheckModelId(e.target.value)} + placeholder="e.g. voyage-3, embed-english-v3.0, text-embedding-3-small" + hint="Required for validation. Will send a test embeddings request." + /> +
+ + {renderValidationResult()} +
+
+ + +
+
+
+ ); +} + +AddCustomEmbeddingModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onCreated: PropTypes.func, + onSaved: PropTypes.func, + node: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + prefix: PropTypes.string, + baseUrl: PropTypes.string, + }), +}; diff --git a/src/shared/components/Avatar.js b/src/shared/components/Avatar.js new file mode 100644 index 0000000..ae0a755 --- /dev/null +++ b/src/shared/components/Avatar.js @@ -0,0 +1,88 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +export default function Avatar({ + src, + alt = "Avatar", + name, + size = "md", + className, +}) { + const sizes = { + xs: "size-6 text-xs", + sm: "size-8 text-sm", + md: "size-10 text-base", + lg: "size-12 text-lg", + xl: "size-16 text-xl", + }; + + // Get initials from name + const getInitials = (name) => { + if (!name) return "?"; + const parts = name.split(" "); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); + }; + + // Generate color from name + const getColorFromName = (name) => { + if (!name) return "bg-primary"; + const colors = [ + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-emerald-500", + "bg-teal-500", + "bg-cyan-500", + "bg-sky-500", + "bg-blue-500", + "bg-indigo-500", + "bg-violet-500", + "bg-purple-500", + "bg-fuchsia-500", + "bg-pink-500", + "bg-rose-500", + ]; + const index = name.charCodeAt(0) % colors.length; + return colors[index]; + }; + + if (src) { + return ( +
+ ); + } + + return ( +
+ {getInitials(name)} +
+ ); +} + diff --git a/src/shared/components/Badge.js b/src/shared/components/Badge.js new file mode 100644 index 0000000..6188cf5 --- /dev/null +++ b/src/shared/components/Badge.js @@ -0,0 +1,54 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +const variants = { + default: "bg-surface-2 text-text-muted", + primary: "bg-brand-500/10 text-brand-600 dark:text-brand-300", + success: "bg-green-500/10 text-green-600 dark:text-green-400", + warning: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", + error: "bg-red-500/10 text-red-600 dark:text-red-400", + info: "bg-blue-500/10 text-blue-600 dark:text-blue-400", +}; + +const sizes = { + sm: "px-2 py-0.5 text-[10px]", + md: "px-2.5 py-1 text-xs", + lg: "px-3 py-1.5 text-sm", +}; + +export default function Badge({ + children, + variant = "default", + size = "md", + dot = false, + icon, + className, +}) { + return ( + + {dot && ( + + )} + {icon && {icon}} + {children} + + ); +} diff --git a/src/shared/components/Button.js b/src/shared/components/Button.js new file mode 100644 index 0000000..38ab4df --- /dev/null +++ b/src/shared/components/Button.js @@ -0,0 +1,56 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +const variants = { + primary: "bg-brand-500 hover:bg-brand-600 text-white shadow-sm disabled:bg-surface-3 disabled:text-text-muted", + secondary: "bg-surface-2 hover:bg-surface-3 text-text-main border border-border disabled:opacity-50", + outline: "border border-border text-text-main hover:bg-surface-2 hover:border-brand-500/40", + ghost: "text-text-muted hover:bg-surface-2 hover:text-text-main", + danger: "bg-red-500 hover:bg-red-600 text-white shadow-sm disabled:bg-surface-3 disabled:text-text-muted", + success: "bg-green-600 hover:bg-green-700 text-white shadow-sm disabled:bg-surface-3 disabled:text-text-muted", +}; + +const sizes = { + sm: "h-7 px-3 text-xs rounded-[8px]", + md: "h-9 px-4 text-sm rounded-[10px]", + lg: "h-11 px-6 text-sm rounded-[10px]", +}; + +export default function Button({ + children, + variant = "primary", + size = "md", + icon, + iconRight, + disabled = false, + loading = false, + fullWidth = false, + className, + ...props +}) { + return ( + + ); +} diff --git a/src/shared/components/CapacityBadges.js b/src/shared/components/CapacityBadges.js new file mode 100644 index 0000000..81c3418 --- /dev/null +++ b/src/shared/components/CapacityBadges.js @@ -0,0 +1,28 @@ +"use client"; + +import { CAPACITY_META } from "@/shared/constants/models"; +import Tooltip from "./Tooltip"; + +// Render small icon badges for a model's capabilities (only those set true). +// colorOverride: force a single color class for all badges (default: per-cap color). +// size: icon font-size in px (default 16). +export default function CapacityBadges({ caps, className = "", colorOverride, size = 16 }) { + if (!caps) return null; + const active = Object.keys(CAPACITY_META).filter((k) => caps[k]); + if (active.length === 0) return null; + + return ( + + {active.map((k) => ( + + + {CAPACITY_META[k].icon} + + + ))} + + ); +} diff --git a/src/shared/components/Card.js b/src/shared/components/Card.js new file mode 100644 index 0000000..765e622 --- /dev/null +++ b/src/shared/components/Card.js @@ -0,0 +1,116 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +export default function Card({ + children, + title, + subtitle, + icon, + action, + padding = "md", + hover = false, + elev = false, + className, + ...props +}) { + const paddings = { + none: "", + xs: "p-3", + sm: "p-4", + md: "p-6", + lg: "p-8", + }; + + return ( +
+ {(title || action) && ( +
+
+ {icon && ( +
+ {icon} +
+ )} +
+ {title && ( +

{title}

+ )} + {subtitle && ( +

{subtitle}

+ )} +
+
+ {action} +
+ )} + {children} +
+ ); +} + +Card.Section = function CardSection({ children, className, ...props }) { + return ( +
+ {children} +
+ ); +}; + +Card.Row = function CardRow({ children, className, ...props }) { + return ( +
+ {children} +
+ ); +}; + +Card.ListItem = function CardListItem({ + children, + actions, + className, + ...props +}) { + return ( +
+
{children}
+ {actions && ( +
+ {actions} +
+ )} +
+ ); +}; diff --git a/src/shared/components/ChangelogModal.js b/src/shared/components/ChangelogModal.js new file mode 100644 index 0000000..591f6a9 --- /dev/null +++ b/src/shared/components/ChangelogModal.js @@ -0,0 +1,97 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; +import { createPortal } from "react-dom"; +import PropTypes from "prop-types"; +import { marked } from "marked"; +import { GITHUB_CONFIG } from "@/shared/constants/config"; + +marked.setOptions({ gfm: true, breaks: true }); + +export default function ChangelogModal({ isOpen, onClose }) { + const [html, setHtml] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const modalRef = useRef(null); + + useEffect(() => { + if (!isOpen || html) return; + setLoading(true); + setError(""); + fetch(GITHUB_CONFIG.changelogUrl) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.text(); + }) + .then((md) => setHtml(marked.parse(md))) + .catch((err) => setError(err.message || "Failed to load")) + .finally(() => setLoading(false)); + }, [isOpen, html]); + + useEffect(() => { + const handleClickOutside = (e) => { + if (modalRef.current && !modalRef.current.contains(e.target)) { + onClose(); + } + }; + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + }, [isOpen, onClose]); + + if (!isOpen || typeof document === "undefined") return null; + + return createPortal( +
+ {/* Overlay */} +
+ + {/* Modal content */} +
+ {/* Header */} +
+

Change Log

+ +
+ + {/* Body */} +
+ {loading && ( +
+ progress_activity + Loading... +
+ )} + {error && ( +
Failed to load changelog: {error}
+ )} + {!loading && !error && html && ( +
+ )} +
+
+
, + document.body + ); +} + +ChangelogModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/shared/components/ComboFormModal.js b/src/shared/components/ComboFormModal.js new file mode 100644 index 0000000..d80732d --- /dev/null +++ b/src/shared/components/ComboFormModal.js @@ -0,0 +1,176 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Modal from "./Modal"; +import Input from "./Input"; +import Button from "./Button"; +import ModelSelectModal from "./ModelSelectModal"; + +const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; + +// Inline editable model item +function ModelItem({ index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown, onRemove }) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(model); + const commit = () => { + const trimmed = draft.trim(); + if (trimmed && trimmed !== model) onEdit(trimmed); + else setDraft(model); + setEditing(false); + }; + const handleKeyDown = (e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") { setDraft(model); setEditing(false); } + }; + return ( +
+ {index + 1} + {editing ? ( + setDraft(e.target.value)} onBlur={commit} onKeyDown={handleKeyDown} + className="min-w-0 flex-1 rounded border border-primary/40 bg-white px-1.5 py-0.5 font-mono text-xs text-text-main outline-none dark:bg-black/20" /> + ) : ( +
setEditing(true)} title="Click to edit">{model}
+ )} +
+ + +
+ +
+ ); +} + +// Reusable Combo create/edit modal. forcePrefix auto-prepends to name. +export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null, forcePrefix = "", title }) { + // Strip prefix when editing existing combo so user only edits suffix + const initialName = combo?.name + ? (forcePrefix && combo.name.startsWith(forcePrefix) ? combo.name.slice(forcePrefix.length) : combo.name) + : ""; + const [name, setName] = useState(initialName); + const [models, setModels] = useState(combo?.models || []); + const [showModelSelect, setShowModelSelect] = useState(false); + const [saving, setSaving] = useState(false); + const [nameError, setNameError] = useState(""); + const [modelAliases, setModelAliases] = useState({}); + + useEffect(() => { + if (!isOpen) return; + fetch("/api/models/alias").then((r) => r.ok ? r.json() : null).then((d) => d && setModelAliases(d.aliases || {})).catch(() => {}); + }, [isOpen]); + + const validateName = (value) => { + if (!value.trim()) { setNameError("Name is required"); return false; } + const full = forcePrefix + value; + if (!VALID_NAME_REGEX.test(full)) { setNameError("Only letters, numbers, -, _ and . allowed"); return false; } + setNameError(""); + return true; + }; + + const handleNameChange = (e) => { + let value = e.target.value; + // If user types prefix manually, strip it (we always prepend) + if (forcePrefix && value.startsWith(forcePrefix)) value = value.slice(forcePrefix.length); + setName(value); + if (value) validateName(value); else setNameError(""); + }; + + const handleAddModel = (model) => { + if (!models.includes(model.value)) setModels([...models, model.value]); + }; + const handleDeselectModel = (model) => { + setModels(models.filter((m) => m !== model.value)); + }; + const handleRemoveModel = (i) => setModels(models.filter((_, idx) => idx !== i)); + const handleMoveUp = (i) => { + if (i === 0) return; + const a = [...models]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; setModels(a); + }; + const handleMoveDown = (i) => { + if (i === models.length - 1) return; + const a = [...models]; [a[i], a[i + 1]] = [a[i + 1], a[i]]; setModels(a); + }; + + const handleSave = async () => { + if (!validateName(name)) return; + setSaving(true); + await onSave({ name: forcePrefix + name.trim(), models }); + setSaving(false); + }; + + const isEdit = !!combo; + + return ( + <> + +
+
+ {forcePrefix ? ( + <> + +
+ {forcePrefix} + +
+ {nameError &&

{nameError}

} + + ) : ( + + )} +

+ {forcePrefix ? `Auto-prefixed with "${forcePrefix}". ` : ""}Only letters, numbers, -, _ and . allowed +

+
+ +
+ + {models.length === 0 ? ( +
+ layers +

No models added yet

+
+ ) : ( +
+ {models.map((model, index) => ( + { const a = [...models]; a[index] = v; setModels(a); }} + onMoveUp={() => handleMoveUp(index)} + onMoveDown={() => handleMoveDown(index)} + onRemove={() => handleRemoveModel(index)} /> + ))} +
+ )} + +
+ +
+ + +
+
+
+ + setShowModelSelect(false)} + onSelect={handleAddModel} onDeselect={handleDeselectModel} + activeProviders={activeProviders} modelAliases={modelAliases} + title="Add Model to Combo" kindFilter={kindFilter} + addedModelValues={models} closeOnSelect={false} /> + + ); +} diff --git a/src/shared/components/CursorAuthModal.js b/src/shared/components/CursorAuthModal.js new file mode 100644 index 0000000..704db9b --- /dev/null +++ b/src/shared/components/CursorAuthModal.js @@ -0,0 +1,212 @@ +"use client"; + +import { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Modal, Button, Input } from "@/shared/components"; + +/** + * Cursor Auth Modal + * Auto-detect and import token from Cursor IDE's local SQLite database + */ +export default function CursorAuthModal({ isOpen, onSuccess, onClose }) { + const [accessToken, setAccessToken] = useState(""); + const [machineId, setMachineId] = useState(""); + const [error, setError] = useState(null); + const [importing, setImporting] = useState(false); + const [autoDetecting, setAutoDetecting] = useState(false); + const [autoDetected, setAutoDetected] = useState(false); + const [windowsManual, setWindowsManual] = useState(false); + + const runAutoDetect = async () => { + setAutoDetecting(true); + setError(null); + setAutoDetected(false); + setWindowsManual(false); + + try { + const res = await fetch("/api/oauth/cursor/auto-import"); + const data = await res.json(); + + if (data.found) { + setAccessToken(data.accessToken); + setMachineId(data.machineId); + setAutoDetected(true); + } else if (data.windowsManual) { + setWindowsManual(true); + } else { + setError(data.error || "Could not auto-detect tokens"); + } + } catch (err) { + setError("Failed to auto-detect tokens"); + } finally { + setAutoDetecting(false); + } + }; + + // Auto-detect tokens when modal opens + useEffect(() => { + if (!isOpen) return; + runAutoDetect(); + }, [isOpen]); + + const handleImportToken = async () => { + if (!accessToken.trim()) { + setError("Please enter an access token"); + return; + } + + if (!machineId.trim()) { + setError("Please enter a machine ID"); + return; + } + + setImporting(true); + setError(null); + + try { + const res = await fetch("/api/oauth/cursor/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accessToken: accessToken.trim(), + machineId: machineId.trim(), + }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Import failed"); + } + + onSuccess?.(); + onClose(); + } catch (err) { + setError(err.message); + } finally { + setImporting(false); + } + }; + + return ( + +
+ {/* Auto-detecting state */} + {autoDetecting && ( +
+
+ + progress_activity + +
+

Auto-detecting tokens...

+

+ Reading from Cursor IDE database +

+
+ )} + + {/* Form (shown after auto-detect completes) */} + {!autoDetecting && ( + <> + {/* Success message if auto-detected */} + {autoDetected && ( +
+
+ check_circle +

+ Tokens auto-detected from Cursor IDE successfully! +

+
+
+ )} + + {/* Windows manual instructions */} + {windowsManual && ( +
+
+ info +

+ Could not read Cursor database automatically. +

+
+

+ Make sure Cursor IDE has been opened at least once, then click Retry. If the problem persists, paste your tokens manually below. +

+ +
+ )} + + {/* Info message if not auto-detected */} + {!autoDetected && !windowsManual && !error && ( +
+
+ info +

+ Cursor IDE not detected. Please paste your tokens manually. +

+
+
+ )} + + {/* Access Token Input */} +
+ +