diff --git a/.gitignore b/.gitignore index 7822a87..1e4acbb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ config.yaml certs/ clients/ +.omc/ diff --git a/Dockerfile b/Dockerfile index 66b9720..8833e33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,9 @@ FROM node:22-slim AS builder WORKDIR /app +# Build tools needed by better-sqlite3 if no prebuilt binary matches the runtime +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 make g++ \ + && rm -rf /var/lib/apt/lists/* COPY package.json package-lock.json* ./ RUN npm install COPY tsconfig.json ./ @@ -11,6 +15,11 @@ WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY package.json ./ +RUN mkdir -p /app/data EXPOSE 8443 -CMD ["node", "dist/index.js", "/app/config.yaml"] +# Config path defaults to /app/data/config.yaml (persistent volume). +# If missing on first start, the container auto-generates it from +# CCG_REFRESH_TOKEN env or a mounted credentials.json. Override path with +# CCG_CONFIG_PATH env or by passing an arg to node. +CMD ["node", "dist/index.js"] diff --git a/README.vi.md b/README.vi.md new file mode 100644 index 0000000..22dcc47 --- /dev/null +++ b/README.vi.md @@ -0,0 +1,344 @@ +# CC Gateway — Hướng dẫn Deploy & Sử dụng Local + +Tài liệu tiếng Việt mô tả cách triển khai CC Gateway và cách dùng ở môi trường local. Bản tiếng Anh đầy đủ ở [`README.md`](README.md). + +CC Gateway là một reverse proxy đứng giữa Claude Code và Anthropic API, làm nhiệm vụ chuẩn hoá identity (device ID, email, env, headers, prompt, billing header…) để nhiều máy có thể dùng chung một subscription mà chỉ "lộ" ra một danh tính duy nhất. + +--- + +## 1. Yêu cầu + +- **Node.js 22+** (chạy trực tiếp) hoặc **Docker + Docker Compose** (chạy container). +- Đã đăng nhập Claude Code trên máy admin ít nhất một lần (để có OAuth credentials trong macOS Keychain hoặc `~/.claude/.credentials.json`). +- `python3`, `openssl`, `bash` (script setup cần đến). +- Tuỳ chọn: HTTP/HTTPS proxy (Clash, V2Ray…) nếu mạng cần. + +Kiểm tra nhanh: + +```bash +node -v # >= v22 +docker -v # nếu định deploy bằng Docker +claude --version # đảm bảo đã login Claude Code +``` + +--- + +## 2. Chạy local (development) + +### 2.1. Setup một câu lệnh + +```bash +git clone https://github.com/motiful/cc-gateway.git +cd cc-gateway +npm install +bash scripts/quick-setup.sh +``` + +Script `quick-setup.sh` sẽ: + +1. Đọc OAuth token (access + refresh + expires) từ Keychain hoặc `~/.claude/.credentials.json`. +2. Sinh `device_id` và `client_token` ngẫu nhiên. +3. Ghi `config.yaml` ở thư mục gốc. +4. Tạo launcher cho client đầu tiên ở `./clients/cc-`. +5. Khởi động gateway ở `http://localhost:8443` bằng `npm run dev` (tsx watch, auto-reload). + +Nếu `config.yaml` đã tồn tại, script chỉ start gateway, không ghi đè config. + +### 2.2. Sử dụng + +Mở terminal khác: + +```bash +./clients/cc- +``` + +Claude Code sẽ chạy và toàn bộ traffic đi qua gateway. Mọi tham số gốc của `claude` đều dùng được, ví dụ: + +```bash +./clients/cc- --print "hello" +./clients/cc- --resume +``` + +### 2.3. Cài làm lệnh `ccg` cho tiện + +```bash +chmod +x ./clients/cc- +./clients/cc- install # tạo lệnh `ccg` toàn hệ thống +ccg # chạy Claude Code qua gateway +ccg status # xem trạng thái kết nối + hijack +ccg help # liệt kê toàn bộ subcommand +``` + +Tuỳ chọn "hijack" để lệnh `claude` mặc định cũng đi qua gateway: + +```bash +ccg hijack # alias claude → ccg (terminal mới tự áp dụng) +ccg release # huỷ hijack, trả lại claude gốc +ccg native # chạy claude gốc một lần, bỏ qua gateway +``` + +### 2.4. Thêm client (cho user khác trong team) + +Có hai cách: dùng dashboard web (mục 4) hoặc dùng script: + +```bash +bash scripts/add-client.sh alice +bash scripts/add-client.sh bob +``` + +Script sẽ: +- Sinh token mới và append vào `config.yaml` (gateway hot-reload trong ~2 giây nhờ `watchFile`). +- Tạo file `./clients/cc-alice`, `./clients/cc-bob`. + +Gửi nguyên file launcher cho user — không cần share `config.yaml`, không cần OAuth login lại. + +Mặc định launcher trỏ về `http://localhost:8443`. Để trỏ tới gateway từ xa: + +```bash +bash scripts/add-client.sh alice "" gateway.example.com:8443 https +``` + +Bốn tham số: ` [token] [host:port] [scheme]`. Token để rỗng (`""`) sẽ tự sinh. + +### 2.5. Chạy sau lưng proxy + +```bash +HTTPS_PROXY=http://127.0.0.1:7890 bash scripts/quick-setup.sh +# hoặc chạy thủ công: +HTTPS_PROXY=http://127.0.0.1:7890 npm run dev +``` + +Gateway tôn trọng `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` cho cả request lên Anthropic API lẫn refresh token. + +### 2.6. Lệnh npm hữu ích + +```bash +npm run dev # tsx watch, auto-reload +npm run build # biên dịch TypeScript ra dist/ +npm start # chạy bản đã build +npm test # chạy test rewriter +npm run add-user -- # tạo user đăng nhập dashboard (bản đã build) +npm run add-user:dev -- # tương tự, chạy bằng tsx +npm run generate-token # sinh token rời +npm run generate-identity # sinh identity rời +``` + +> **Lưu ý:** `add-user` tạo tài khoản đăng nhập **dashboard web** (lưu trong SQLite ở `./data/ccg.db`), khác với `add-client.sh` (tạo launcher Claude Code cho client). Xem chi tiết ở mục 4. + +--- + +## 3. Deploy bằng Docker (production) + +### 3.1. Setup tự động + +Trên máy admin (đã login Claude Code): + +```bash +bash scripts/admin-setup.sh +``` + +Script sẽ hỏi tương tác: +1. Lấy OAuth credentials. +2. Tạo `config.yaml` + launcher cho client đầu tiên. +3. Build và `docker compose up -d`. +4. Hỏi địa chỉ public mà client sẽ kết nối tới. + +Sau khi container chạy, kiểm tra: + +```bash +docker compose ps +docker compose logs -f gateway +curl http://localhost:8443/_health # phải trả về 200 +``` + +### 3.2. Thêm client sau khi đã deploy + +```bash +bash scripts/add-client.sh charlie "" your-domain.com:443 https +docker compose restart # nạp lại danh sách token +``` + +`docker-compose.yml` mount `ccg_data` (volume) để giữ `config.yaml` + SQLite giữa các lần restart, nên `device_id` và token sẽ không bị reset. + +### 3.3. Deploy với Coolify (sẵn cấu hình) + +`docker-compose.yml` đã có sẵn label cho Coolify + Traefik: +- Tạo service mới trên Coolify, point tới repo này. +- Set domain (Coolify tự sinh `SERVICE_FQDN_GATEWAY`). +- Traefik sẽ tự cấp Let's Encrypt cert và redirect HTTP → HTTPS. +- Mount `~/.claude/.credentials.json` ở host vào `/app/data/claude-credentials.json` (read-only) để container tự bootstrap config lần đầu. + +### 3.4. Deploy thủ công với TLS tự ký + +Khi cần TLS mà không có domain công khai: + +```bash +mkdir certs +openssl req -x509 -newkey rsa:2048 \ + -keyout certs/key.pem -out certs/cert.pem \ + -days 365 -nodes -subj "/CN=cc-gateway" +``` + +Bỏ comment phần `tls` trong `config.yaml`, sau đó sinh launcher với scheme `https`: + +```bash +bash scripts/add-client.sh alice "" :8443 https +``` + +Launcher tự thêm `NODE_TLS_REJECT_UNAUTHORIZED=0` để chấp nhận self-signed cert. + +### 3.5. Tailscale (lựa chọn nhẹ nhất) + +Nếu mọi máy đều có Tailscale, chạy gateway trên một máy bất kỳ trong mesh — không cần TLS, không cần public IP, không cần forward port. Trỏ launcher tới hostname Tailscale (vd: `gateway.tailnet-xxx.ts.net:8443`). + +### 3.6. Auto-bootstrap config khi khởi động + +Khi container start lần đầu mà chưa có `config.yaml` ở `/app/data/config.yaml` (hoặc đường dẫn trong `CCG_CONFIG_PATH`), gateway sẽ tự sinh config từ một trong các nguồn sau: + +- `CCG_REFRESH_TOKEN` env var (nếu set). +- File `claude-credentials.json` mount vào (mặc định `/app/data/claude-credentials.json`, read-only). + +`docker-compose.yml` đã cấu hình mount sẵn `/root/.claude/.credentials.json` của host vào path này, nên trên server vừa cài Claude Code chỉ cần `docker compose up -d` là gateway tự bootstrap. + +Ngoài ra: +- **Token rotation persist**: mỗi lần OAuth refresh, refresh token mới được ghi đè vào `config.yaml` để restart không bị "replay" token đã tiêu thụ. +- **Sync from credentials**: nếu host chạy `claude` và rotate token, gateway phát hiện `refresh_token` trong `credentials.json` khác với `config.yaml` và đồng bộ lại trước khi load. + +Có thể sinh thủ công `config.yaml` từ login Claude hiện có: + +```bash +bash scripts/gen-config.sh --client whiletrue0x > config.yaml +# hoặc ghi thẳng vào file Coolify volume: +bash scripts/gen-config.sh --out /data/coolify/applications//config.yaml +``` + +--- + +## 4. Dashboard web + +Gateway expose sẵn một dashboard quản trị ở chính port 8443 (`/dashboard`), không phải port riêng: + +| Path | Mục đích | +|---|---| +| `/` | Redirect → `/dashboard` (nếu đã login) hoặc `/login` | +| `/login` | Form đăng nhập admin | +| `/dashboard` | Xem usage/cost + quản lý client | +| `/api/clients` | REST API list/create client (cookie session) | +| `/api/clients/` | REST API delete client | +| `/_health` | Health check (không cần login) | + +### 4.1. Tạo user đăng nhập dashboard + +Lần đầu start, log sẽ cảnh báo `No dashboard users yet`. Tạo user: + +```bash +# Local (dev) +npm run add-user:dev -- admin + +# Trong container +docker compose exec gateway node dist/scripts/add-user.js admin +``` + +Script hỏi password tương tác (>= 8 ký tự), hash bằng scrypt, lưu vào SQLite (`./data/ccg.db` hoặc `config.db.path`). Sau đó truy cập `http://localhost:8443/login`. + +### 4.2. Tính năng dashboard + +- **Quản lý client trong UI**: thêm/xoá launcher trực tiếp, copy lệnh cài đặt cho user. +- **Per-request token usage & cost**: mỗi request lưu input / output / cache tokens riêng. +- **Tổng hợp theo period**: today / 7 ngày / 30 ngày / all time. +- **Hot-reload**: thêm/xoá client trong dashboard cập nhật `config.yaml` và gateway nạp lại token trong ~2 giây mà không cần restart. + +Pricing tính trong `src/pricing.ts`. Dữ liệu metric lưu SQLite, sống cùng volume `ccg_data` nên không mất khi redeploy. + +--- + +## 5. Cấu trúc file quan trọng + +``` +cc-gateway/ +├── src/ +│ ├── index.ts # entrypoint, watchFile config hot-reload +│ ├── proxy.ts # HTTP server, route dashboard + proxy upstream +│ ├── dashboard.ts # HTML cho /login + /dashboard +│ ├── rewriter.ts # rewrite identity, env, prompt, headers +│ ├── oauth.ts # quản lý access/refresh token +│ ├── auth.ts # auth bằng client token (proxy) + session (dashboard) +│ ├── users.ts # CRUD user dashboard (scrypt hash) +│ ├── clients.ts # CRUD client launcher +│ ├── db.ts # SQLite (better-sqlite3) +│ ├── metrics.ts # ghi nhận request + token usage +│ ├── usage-parser.ts # bóc tách input/output/cache token từ response +│ ├── pricing.ts # bảng giá theo model +│ ├── bootstrap-config.ts # auto-sinh config.yaml lần đầu +│ ├── proxy-agent.ts # honour HTTPS_PROXY / HTTP_PROXY +│ ├── session.ts # cookie session cho dashboard +│ ├── config.ts # parse YAML +│ ├── logger.ts +│ └── scripts/ # add-user, generate-token, generate-identity +├── scripts/ # bash scripts dùng từ shell +│ ├── quick-setup.sh # setup + start local trong 1 lệnh +│ ├── admin-setup.sh # deploy Docker tương tác +│ ├── add-client.sh # thêm client + sinh launcher +│ ├── extract-token.sh # rút OAuth token khỏi Keychain +│ └── gen-config.sh # sinh config.yaml từ login Claude hiện có +├── clients/ # launcher cho từng user (gitignore) +├── data/ # SQLite + config khi chạy local (gitignore) +├── config.yaml # config gateway (gitignore, do script sinh hoặc bootstrap) +├── config.example.yaml # mẫu để tham khảo cấu trúc +├── docker-compose.yml # deploy Docker + Coolify + Traefik +├── Dockerfile # multi-stage build Node 22 +└── clash-rules.yaml # rule Clash chặn traffic trực tiếp tới Anthropic +``` + +--- + +## 6. Troubleshooting + +| Triệu chứng | Nguyên nhân & cách xử lý | +|---|---| +| `Error: No Claude Code credentials found` | Chưa login Claude Code lần nào. Chạy `claude` trên máy admin, hoàn tất OAuth qua trình duyệt, rồi chạy lại setup. | +| `No dashboard users yet` ở log | Chạy `npm run add-user -- ` (hoặc `docker compose exec gateway node dist/scripts/add-user.js `) để tạo user trước khi vào `/login`. | +| Gateway start xong nhưng client báo 401 | Token trong launcher không khớp `config.yaml`. Sinh lại launcher bằng `add-client.sh` với token đúng, hoặc kiểm tra mục `auth.tokens`. | +| Anthropic trả 401 dù vừa refresh | Đảm bảo bản đang chạy là sau commit `497f46f` (forward OAuth qua `Authorization: Bearer` + `anthropic-beta`). Build lại nếu deploy bằng Docker. | +| Refresh token bị "consumed" sau restart | Cần bản có `f22386e` — gateway tự ghi token rotated trở lại `config.yaml`. Kiểm tra quyền ghi của file/volume. | +| Refresh token hết hạn (hiếm, sau vài tháng) | Chạy lại `bash scripts/extract-token.sh` trên máy admin, hoặc cập nhật `oauth.refresh_token` trong `config.yaml`. | +| Request không qua proxy | Kiểm tra `HTTPS_PROXY` đã set khi start gateway chưa. Restart sau khi đổi env. | +| Docker container không đọc được credentials | Đảm bảo mount `~/.claude/.credentials.json:/app/data/claude-credentials.json:ro` đúng; với non-root user, chỉnh path host cho phù hợp. | +| Dashboard hiện 0 request dù client đang dùng | Kiểm tra `data/ccg.db` có quyền ghi; xem log `metrics`/`usage-parser` để bắt lỗi parse usage block. | +| MCP request không đi qua gateway | `mcp-proxy.anthropic.com` hard-code, không theo `ANTHROPIC_BASE_URL`. Dùng Clash chặn nếu không cần MCP. | + +Log: +- Local: stdout của `npm run dev`. +- Docker: `docker compose logs -f gateway`. +- Health check: `GET /_health`. + +--- + +Log: +- Local: stdout của `npm run dev`. +- Docker: `docker compose logs -f gateway`. +- Health check: `GET /_health`. + +--- + +## 7. Các bước rút gọn + +**Local, một mình, một máy:** +```bash +npm install && bash scripts/quick-setup.sh +npm run add-user:dev -- admin # tạo user dashboard +./clients/cc- install +ccg +# mở http://localhost:8443 để xem dashboard +``` + +**Server Docker, nhiều client:** +```bash +bash scripts/admin-setup.sh +docker compose exec gateway node dist/scripts/add-user.js admin +# Thêm client qua dashboard hoặc: +bash scripts/add-client.sh alice "" gateway.example.com:443 https +# gửi file ./clients/cc-alice cho Alice (config tự hot-reload, không cần restart) +``` + +Xem [`README.md`](README.md) để biết chi tiết về cơ chế rewrite, OAuth lifecycle, Clash rules và các caveat. diff --git a/config.example.yaml b/config.example.yaml index 5f0aae9..0d5214c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -76,3 +76,9 @@ process: logging: level: info # debug | info | warn | error audit: true # log which client made each request + +# SQLite database — stores dashboard users and request metrics. +# Path is relative to working directory. In Docker, mount /app/data as a volume +# so the DB survives restarts. Create dashboard users with: npm run add-user +db: + path: ./data/ccg.db diff --git a/docker-compose.yml b/docker-compose.yml index e9c4d52..563d52b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,23 @@ services: gateway: build: . - ports: - - "8443:8443" + # Coolify auto-generates SERVICE_FQDN_GATEWAY from the domain configured + # in the service UI. Set the domain there (e.g. ccg.example.com) and + # Coolify will substitute it into the labels below at deploy time. + environment: + - SERVICE_FQDN_GATEWAY=/ + expose: + - "8443" volumes: - - ./config.yaml:/app/config.yaml:ro - - ./certs:/app/certs:ro + # ccg_data persists both the SQLite DB and the auto-generated config.yaml + # (so device_id + tokens survive restarts/redeploys). + - ccg_data:/app/data + # Host's claude login credentials — read on first start to bootstrap + # config.yaml automatically. Read-only so the container cannot tamper. + - /root/.claude/.credentials.json:/app/data/claude-credentials.json:ro restart: unless-stopped + networks: + - coolify healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:8443/_health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"] interval: 30s @@ -17,3 +28,25 @@ services: options: max-size: "10m" max-file: "3" + labels: + - "traefik.enable=true" + - "traefik.docker.network=coolify" + - "traefik.http.services.ccg.loadbalancer.server.port=8443" + # HTTPS router — Coolify substitutes SERVICE_FQDN_GATEWAY at deploy time + - "traefik.http.routers.ccg.rule=Host(`${SERVICE_FQDN_GATEWAY}`)" + - "traefik.http.routers.ccg.entrypoints=https" + - "traefik.http.routers.ccg.tls=true" + - "traefik.http.routers.ccg.tls.certresolver=letsencrypt" + # HTTP → HTTPS redirect + - "traefik.http.routers.ccg-http.rule=Host(`${SERVICE_FQDN_GATEWAY}`)" + - "traefik.http.routers.ccg-http.entrypoints=http" + - "traefik.http.routers.ccg-http.middlewares=ccg-redirect" + - "traefik.http.middlewares.ccg-redirect.redirectscheme.scheme=https" + - "traefik.http.middlewares.ccg-redirect.redirectscheme.permanent=true" + +networks: + coolify: + external: true + +volumes: + ccg_data: diff --git a/package-lock.json b/package-lock.json index 152e674..01c10db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { "name": "cc-gateway", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-gateway", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { + "better-sqlite3": "^11.5.0", "https-proxy-agent": "^9.0.0", "yaml": "^2.7.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.11", "@types/node": "^22.0.0", "tsx": "^4.19.0", "typescript": "^5.7.0" @@ -460,6 +462,16 @@ "node": ">=18" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", @@ -479,6 +491,87 @@ "node": ">= 20" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -496,6 +589,48 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -538,6 +673,27 @@ "@esbuild/win32-x64": "0.27.4" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -566,6 +722,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", @@ -579,12 +741,164 @@ "node": ">= 20" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.90.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.90.0.tgz", + "integrity": "sha512-pZNQT7UnYlMwMBy5N1lV5X/YLTbZM5ncytN3xL7CHEzhDN8uVe0u55yaPUJICIJjaCW8NrM5BFdqr7HLweStNA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -595,6 +909,129 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -615,6 +1052,18 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -636,6 +1085,18 @@ "dev": true, "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", diff --git a/package.json b/package.json index 78f9645..7f0fb1b 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,17 @@ "dev": "tsx watch src/index.ts", "generate-token": "tsx src/scripts/generate-token.ts", "generate-identity": "tsx src/scripts/generate-identity.ts", + "add-user": "node dist/scripts/add-user.js", + "add-user:dev": "tsx src/scripts/add-user.ts", "test": "tsx tests/rewriter.test.ts" }, "dependencies": { + "better-sqlite3": "^11.5.0", "https-proxy-agent": "^9.0.0", "yaml": "^2.7.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.11", "@types/node": "^22.0.0", "tsx": "^4.19.0", "typescript": "^5.7.0" diff --git a/scripts/add-client.sh b/scripts/add-client.sh index a056dc1..1505ea8 100755 --- a/scripts/add-client.sh +++ b/scripts/add-client.sh @@ -31,7 +31,7 @@ with open('$CONFIG', 'w') as f: echo " - name: ${CLIENT_NAME}" echo " token: ${CLIENT_TOKEN}" } - echo "✓ Token added to config.yaml (restart gateway to pick up)" + echo "✓ Token added to config.yaml (gateway will hot-reload within ~2s)" fi # Generate the launcher script @@ -64,7 +64,18 @@ fi cat >> "$LAUNCHER" <<'SCRIPT_BODY' -INSTALL_PATH="/usr/local/bin/ccg" +# Pick a writable install dir. Apple Silicon Macs ship without /usr/local/bin +# by default; Intel Macs and most Linux distros have it. Fall back to +# ~/.local/bin so install always works without sudo as a last resort. +if [[ -d /opt/homebrew/bin ]]; then + INSTALL_DIR="/opt/homebrew/bin" +elif [[ -d /usr/local/bin ]]; then + INSTALL_DIR="/usr/local/bin" +else + INSTALL_DIR="$HOME/.local/bin" + mkdir -p "$INSTALL_DIR" +fi +INSTALL_PATH="$INSTALL_DIR/ccg" SELF_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" # Detect shell RC file case "$SHELL" in @@ -79,9 +90,20 @@ ALIAS_TAG="# cc-gateway alias" case "$1" in install) - cp "$0" "$INSTALL_PATH" 2>/dev/null || sudo cp "$0" "$INSTALL_PATH" - chmod +x "$INSTALL_PATH" - echo "Installed as 'ccg'." + if cp "$0" "$INSTALL_PATH" 2>/dev/null; then :; else + sudo cp "$0" "$INSTALL_PATH" || { echo "Install failed: cannot write to $INSTALL_PATH"; exit 1; } + fi + chmod +x "$INSTALL_PATH" 2>/dev/null || sudo chmod +x "$INSTALL_PATH" + echo "Installed as 'ccg' at $INSTALL_PATH." + case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) + echo "" + echo "Note: $INSTALL_DIR is not on your PATH." + echo " Add this line to $RC_FILE and reopen your terminal:" + echo " export PATH=\"$INSTALL_DIR:\$PATH\"" + ;; + esac echo "" echo " ccg Start Claude Code through gateway" echo " ccg hijack Make 'claude' also go through gateway" diff --git a/scripts/gen-config.sh b/scripts/gen-config.sh new file mode 100755 index 0000000..46e5f80 --- /dev/null +++ b/scripts/gen-config.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# Generate config.yaml for CC Gateway from existing Claude Code OAuth login. +# +# Usage: +# bash scripts/gen-config.sh # print to stdout +# bash scripts/gen-config.sh > config.yaml # save to file +# bash scripts/gen-config.sh --client whiletrue0x # set seed client name +# bash scripts/gen-config.sh --out /path/to/config.yaml # write directly +# +# Server use (Coolify host): +# bash scripts/gen-config.sh --out /data/coolify/applications//config.yaml \ +# && docker restart +set -e + +CLIENT_NAME="whiletrue0x" +OUT_FILE="" +DEVICE_ID="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --client) CLIENT_NAME="$2"; shift 2 ;; + --out) OUT_FILE="$2"; shift 2 ;; + --device) DEVICE_ID="$2"; shift 2 ;; + -h|--help) + sed -n '2,11p' "$0" | sed 's/^# \?//' + exit 0 + ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +[[ -z "$DEVICE_ID" ]] && DEVICE_ID=$(openssl rand -hex 32) +CLIENT_TOKEN=$(openssl rand -hex 32) + +# Extract OAuth from macOS Keychain or Linux credentials file +CREDS=$(security find-generic-password -a "$USER" -s "Claude Code-credentials" -w 2>/dev/null || true) +if [[ -z "$CREDS" ]]; then + for f in "$HOME/.claude/.credentials.json" "$HOME/.config/claude/.credentials.json"; do + if [[ -f "$f" ]]; then CREDS=$(cat "$f"); break; fi + done +fi +if [[ -z "$CREDS" ]]; then + echo "Error: No Claude Code OAuth credentials found." >&2 + echo "Run 'claude' first to login, then re-run this script." >&2 + exit 1 +fi + +eval "$(echo "$CREDS" | python3 -c " +import sys, json +d = json.load(sys.stdin)['claudeAiOauth'] +print(f'ACCESS_TOKEN=\"{d[\"accessToken\"]}\"') +print(f'REFRESH_TOKEN=\"{d[\"refreshToken\"]}\"') +print(f'EXPIRES_AT={d.get(\"expiresAt\", 0)}') +")" + +if [[ -z "$REFRESH_TOKEN" ]]; then + echo "Error: Could not extract refresh_token from credentials." >&2 + exit 1 +fi + +NODE_VER=$(node -v 2>/dev/null || echo "v22.0.0") +OS_VER=$(uname -sr) + +read -r -d '' CONFIG_BODY < "$OUT_FILE" + chmod 600 "$OUT_FILE" + echo "✓ Wrote $OUT_FILE" >&2 + echo " seed client: ${CLIENT_NAME}" >&2 + echo " client token: ${CLIENT_TOKEN}" >&2 + echo " device_id: ${DEVICE_ID:0:8}..." >&2 +else + printf '%s\n' "$CONFIG_BODY" +fi diff --git a/src/auth.ts b/src/auth.ts index 7f47de8..cb4510e 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -4,31 +4,38 @@ import type { Config, TokenEntry } from './config.js' const tokenMap = new Map() export function initAuth(config: Config) { + setAuthTokens(config.auth.tokens) +} + +/** Replace the in-memory token map. Call after mutating config.yaml's auth.tokens. */ +export function setAuthTokens(tokens: TokenEntry[]) { tokenMap.clear() - for (const entry of config.auth.tokens) { + for (const entry of tokens) { tokenMap.set(entry.token, entry) } } /** * Authenticate incoming request by Bearer token. - * Returns the token entry name (for audit logging) or null if unauthorized. + * Returns the matched TokenEntry (so callers can read name + cost limit) or null. */ -export function authenticate(req: IncomingMessage): string | null { +export function authenticate(req: IncomingMessage): TokenEntry | null { // CC with ANTHROPIC_API_KEY sends x-api-key header const apiKey = req.headers['x-api-key'] if (apiKey && typeof apiKey === 'string') { const entry = tokenMap.get(apiKey) - if (entry) return entry.name + if (entry) return entry } - // Fallback: Bearer token in Authorization or Proxy-Authorization + // Bearer token in Authorization or Proxy-Authorization const authHeader = req.headers['proxy-authorization'] || req.headers['authorization'] - if (!authHeader || typeof authHeader !== 'string') return null - - const match = authHeader.match(/^Bearer\s+(.+)$/i) - if (!match) return null + if (authHeader && typeof authHeader === 'string') { + const match = authHeader.match(/^Bearer\s+(.+)$/i) + if (match) { + const entry = tokenMap.get(match[1]) + if (entry) return entry + } + } - const entry = tokenMap.get(match[1]) - return entry?.name ?? null + return null } diff --git a/src/bootstrap-config.ts b/src/bootstrap-config.ts new file mode 100644 index 0000000..b5da54e --- /dev/null +++ b/src/bootstrap-config.ts @@ -0,0 +1,248 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs' +import { dirname, resolve } from 'path' +import { randomBytes } from 'crypto' +import { parseDocument, YAMLMap } from 'yaml' +import { log } from './logger.js' + +interface ClaudeCredentials { + accessToken?: string + refreshToken?: string + expiresAt?: number +} + +function readCredentialsFile(path: string): ClaudeCredentials | null { + if (!existsSync(path)) return null + try { + const raw = readFileSync(path, 'utf-8') + const parsed = JSON.parse(raw) + const c = parsed.claudeAiOauth || parsed + if (typeof c.refreshToken !== 'string') return null + return { + accessToken: typeof c.accessToken === 'string' ? c.accessToken : undefined, + refreshToken: c.refreshToken, + expiresAt: typeof c.expiresAt === 'number' ? c.expiresAt : undefined, + } + } catch (err) { + log('warn', `Failed to read credentials from ${path}: ${err instanceof Error ? err.message : err}`) + return null + } +} + +function gatherSeedCredentials(): ClaudeCredentials | null { + // 1. File path (explicit env) + const credPath = process.env.CCG_CREDENTIALS_PATH + if (credPath) { + const fromFile = readCredentialsFile(credPath) + if (fromFile) return fromFile + log('warn', `CCG_CREDENTIALS_PATH set but file unreadable or missing required fields: ${credPath}`) + } + + // 2. Common default file locations (when user mounts credentials.json into container) + for (const p of ['/app/data/claude-credentials.json', '/run/secrets/claude-credentials.json']) { + const fromFile = readCredentialsFile(p) + if (fromFile) return fromFile + } + + // 3. Env vars + if (process.env.CCG_REFRESH_TOKEN) { + return { + refreshToken: process.env.CCG_REFRESH_TOKEN, + accessToken: process.env.CCG_ACCESS_TOKEN || undefined, + expiresAt: process.env.CCG_EXPIRES_AT ? Number(process.env.CCG_EXPIRES_AT) : undefined, + } + } + + return null +} + +function buildConfigYaml(creds: ClaudeCredentials, opts: { + clientName: string + clientToken: string + deviceId: string + email: string + dbPath: string +}): string { + const { clientName, clientToken, deviceId, email, dbPath } = opts + return `server: + port: 8443 + +upstream: + url: https://api.anthropic.com + +oauth: + access_token: "${creds.accessToken || ''}" + refresh_token: "${creds.refreshToken}" + expires_at: ${creds.expiresAt || 0} + +auth: + tokens: + - name: ${clientName} + token: ${clientToken} + +identity: + device_id: "${deviceId}" + email: "${email}" + +env: + platform: darwin + platform_raw: darwin + arch: arm64 + node_version: v22.0.0 + terminal: iTerm2.app + package_managers: npm,pnpm + runtimes: node + is_running_with_bun: false + is_ci: false + is_claude_ai_auth: true + version: "2.1.81" + version_base: "2.1.81" + build_time: "2026-03-20T21:26:18Z" + deployment_environment: unknown-darwin + vcs: git + +prompt_env: + platform: darwin + shell: zsh + os_version: "Darwin 24.4.0" + working_dir: /Users/jack/projects + +process: + constrained_memory: 34359738368 + rss_range: [300000000, 500000000] + heap_total_range: [40000000, 80000000] + heap_used_range: [100000000, 200000000] + +logging: + level: info + audit: true + +db: + path: ${dbPath} +` +} + +/** + * Ensure a config file exists at the given path. If missing, attempt to + * auto-generate one from environment / mounted credentials. Returns true if + * a config now exists at the path (whether it was already there or just + * created). Returns false (and logs why) if bootstrap was needed but failed. + */ +export function bootstrapConfigIfMissing(configPath: string): boolean { + const absPath = resolve(configPath) + if (existsSync(absPath)) return true + + log('info', `No config found at ${absPath} — attempting auto-bootstrap`) + + const creds = gatherSeedCredentials() + if (!creds || !creds.refreshToken) { + log('error', 'Cannot bootstrap config: no OAuth credentials available.') + log('error', ' Set CCG_REFRESH_TOKEN env var, or mount a credentials.json at') + log('error', ' CCG_CREDENTIALS_PATH (or /app/data/claude-credentials.json).') + return false + } + + const dbPath = resolve(dirname(absPath), 'ccg.db') + const yaml = buildConfigYaml(creds, { + clientName: process.env.CCG_SEED_CLIENT_NAME || 'seed', + clientToken: process.env.CCG_SEED_CLIENT_TOKEN || randomBytes(32).toString('hex'), + deviceId: process.env.CCG_DEVICE_ID || randomBytes(32).toString('hex'), + email: process.env.CCG_EMAIL || 'user@example.com', + dbPath, + }) + + mkdirSync(dirname(absPath), { recursive: true }) + writeFileSync(absPath, yaml, { encoding: 'utf-8' }) + try { + chmodSync(absPath, 0o600) + } catch { + // chmod may fail on some mounted filesystems — non-fatal + } + + log('info', `Generated ${absPath} (seed client + fresh device_id)`) + log('info', ' Edit identity/env/prompt_env if you need a specific fingerprint.') + return true +} + +/** + * Persist a refreshed OAuth token bundle into config.yaml's `oauth:` section. + * Round-trips through parseDocument so user edits / comments are preserved. + */ +export function updateConfigOAuth( + configPath: string, + next: { accessToken: string; refreshToken: string; expiresAt: number }, +): void { + const absPath = resolve(configPath) + if (!existsSync(absPath)) { + log('warn', `updateConfigOAuth: ${absPath} does not exist, skipping persist`) + return + } + try { + const raw = readFileSync(absPath, 'utf-8') + const doc = parseDocument(raw) + let oauth = doc.getIn(['oauth'], true) as YAMLMap | undefined + if (!oauth) { + oauth = new YAMLMap() + doc.set('oauth', oauth) + } + oauth.set('access_token', next.accessToken) + oauth.set('refresh_token', next.refreshToken) + oauth.set('expires_at', next.expiresAt) + writeFileSync(absPath, doc.toString(), 'utf-8') + } catch (err) { + log('error', `updateConfigOAuth failed: ${err instanceof Error ? err.message : err}`) + } +} + +/** + * If a mounted credentials.json has a refresh token that differs from the one + * already in config.yaml, copy it across before the gateway boots. This handles + * the case where the host re-logged in to Claude and rotated the refresh token. + * + * Only syncs when the mounted credentials are actually newer than config.yaml. + * The gateway rotates refresh_tokens at runtime and persists them back to + * config.yaml, so a mounted credentials.json from a past `claude login` will + * usually be STALE relative to config.yaml — overwriting blindly would replay + * a consumed refresh_token and brick auth on every restart. + */ +export function syncOAuthFromCredentialsIfChanged(configPath: string): void { + const absPath = resolve(configPath) + if (!existsSync(absPath)) return + + const creds = gatherSeedCredentials() + if (!creds || !creds.refreshToken) return + + let currentRefresh: string | undefined + let currentExpiresAt = 0 + try { + const raw = readFileSync(absPath, 'utf-8') + const doc = parseDocument(raw) + const oauth = doc.getIn(['oauth'], true) as YAMLMap | undefined + const rt = oauth?.get('refresh_token') + if (typeof rt === 'string') currentRefresh = rt + const exp = oauth?.get('expires_at') + if (typeof exp === 'number') currentExpiresAt = exp + } catch { + return + } + + if (currentRefresh && currentRefresh === creds.refreshToken) return + + // Tokens differ. Only adopt the mounted creds when they're plausibly newer + // than what the gateway has already rotated to. expiresAt is monotonic per + // login, so a higher value means the mounted file is fresher. + const credsExpiresAt = creds.expiresAt || 0 + if (currentRefresh && credsExpiresAt <= currentExpiresAt) { + log( + 'info', + `Ignoring mounted credentials: expires_at=${credsExpiresAt} is not newer than config.yaml expires_at=${currentExpiresAt} (credentials.json is stale; gateway has rotated past it)`, + ) + return + } + + log('info', 'Mounted credentials are newer than config.yaml — syncing refresh_token') + updateConfigOAuth(absPath, { + accessToken: creds.accessToken || '', + refreshToken: creds.refreshToken, + expiresAt: credsExpiresAt, + }) +} diff --git a/src/clients.ts b/src/clients.ts new file mode 100644 index 0000000..fdecba2 --- /dev/null +++ b/src/clients.ts @@ -0,0 +1,701 @@ +import { readFileSync, writeFileSync } from 'fs' +import { resolve } from 'path' +import { randomBytes } from 'crypto' +import { parseDocument, YAMLSeq, YAMLMap } from 'yaml' +import type { CostLimitPeriod } from './config.js' + +export interface ClientEntry { + name: string + token: string + cost_limit_usd?: number + cost_limit_period?: CostLimitPeriod +} + +export interface ClientLimitInput { + cost_limit_usd?: number | null + cost_limit_period?: CostLimitPeriod | null +} + +const NAME_RE = /^[a-zA-Z0-9_.-]{1,64}$/ +const VALID_PERIODS: CostLimitPeriod[] = ['lifetime', 'monthly', 'daily'] + +function configPath(): string { + return resolve( + process.argv[2] || + process.env.CCG_CONFIG_PATH || + '/app/data/config.yaml', + ) +} + +function loadDoc(path: string) { + const raw = readFileSync(path, 'utf-8') + return parseDocument(raw) +} + +function getTokensSeq(doc: ReturnType): YAMLSeq { + const auth = doc.getIn(['auth'], true) as YAMLMap | undefined + if (!auth) throw new Error('config: auth section missing') + let tokens = auth.get('tokens', true) as YAMLSeq | undefined + if (!tokens) { + tokens = new YAMLSeq() + auth.set('tokens', tokens) + } + return tokens +} + +function readEntry(item: YAMLMap): ClientEntry | null { + const name = item.get('name') + const token = item.get('token') + if (typeof name !== 'string' || typeof token !== 'string') return null + const limitRaw = item.get('cost_limit_usd') + const periodRaw = item.get('cost_limit_period') + const cost_limit_usd = typeof limitRaw === 'number' && limitRaw > 0 ? limitRaw : undefined + const cost_limit_period = + typeof periodRaw === 'string' && (VALID_PERIODS as string[]).includes(periodRaw) + ? (periodRaw as CostLimitPeriod) + : undefined + return { name, token, cost_limit_usd, cost_limit_period } +} + +function applyLimitToYamlEntry(entry: YAMLMap, input: ClientLimitInput): void { + if (input.cost_limit_usd === null || input.cost_limit_usd === 0) { + entry.delete('cost_limit_usd') + entry.delete('cost_limit_period') + return + } + if (typeof input.cost_limit_usd === 'number' && input.cost_limit_usd > 0) { + entry.set('cost_limit_usd', input.cost_limit_usd) + const period = + typeof input.cost_limit_period === 'string' && + (VALID_PERIODS as string[]).includes(input.cost_limit_period) + ? input.cost_limit_period + : 'lifetime' + entry.set('cost_limit_period', period) + } else if (input.cost_limit_period !== undefined && input.cost_limit_period !== null) { + // period without limit: ignore + } +} + +export function listClients(): ClientEntry[] { + const doc = loadDoc(configPath()) + const tokens = getTokensSeq(doc) + const out: ClientEntry[] = [] + for (const item of tokens.items) { + if (item instanceof YAMLMap) { + const e = readEntry(item) + if (e) out.push(e) + } + } + return out +} + +export function addClient(name: string, limit?: ClientLimitInput): ClientEntry { + if (!NAME_RE.test(name)) { + throw new Error('client name must be 1-64 chars, [a-zA-Z0-9_.-]') + } + const path = configPath() + const doc = loadDoc(path) + const tokens = getTokensSeq(doc) + + for (const item of tokens.items) { + if (item instanceof YAMLMap && item.get('name') === name) { + throw new Error(`client "${name}" already exists`) + } + } + + const token = randomBytes(32).toString('hex') + const entry = new YAMLMap() + entry.set('name', name) + entry.set('token', token) + if (limit) applyLimitToYamlEntry(entry, limit) + tokens.add(entry) + + writeFileSync(path, doc.toString(), 'utf-8') + const result = readEntry(entry) + return result || { name, token } +} + +export function setClientLimit(name: string, limit: ClientLimitInput): ClientEntry { + const path = configPath() + const doc = loadDoc(path) + const tokens = getTokensSeq(doc) + let target: YAMLMap | null = null + for (const item of tokens.items) { + if (item instanceof YAMLMap && item.get('name') === name) { + target = item + break + } + } + if (!target) throw new Error(`client "${name}" not found`) + applyLimitToYamlEntry(target, limit) + writeFileSync(path, doc.toString(), 'utf-8') + const result = readEntry(target) + if (!result) throw new Error('failed to read updated entry') + return result +} + +export function removeClient(name: string): boolean { + const path = configPath() + const doc = loadDoc(path) + const tokens = getTokensSeq(doc) + + let removedAt = -1 + for (let i = 0; i < tokens.items.length; i++) { + const item = tokens.items[i] + if (item instanceof YAMLMap && item.get('name') === name) { + removedAt = i + break + } + } + if (removedAt === -1) return false + + tokens.delete(removedAt) + if (tokens.items.length === 0) { + throw new Error('cannot remove the last client — at least one token must remain') + } + writeFileSync(path, doc.toString(), 'utf-8') + return true +} + +export interface LauncherOptions { + name: string + token: string + gatewayAddr: string // e.g. "ccg.example.com" or "host:port" + scheme: 'http' | 'https' +} + +export function buildPowerShellLauncherScript(opts: LauncherOptions): string { + const tlsBypass = + opts.scheme === 'https' + ? '\n# Accept self-signed TLS cert from gateway\n$env:NODE_TLS_REJECT_UNAUTHORIZED = "0"\n' + : '' + return `# CC Gateway Client Launcher (Windows / PowerShell) +# +# Usage: +# .\\cc-${opts.name}.ps1 Start Claude Code through gateway +# .\\cc-${opts.name}.ps1 --print "hi" Single-shot mode +# .\\cc-${opts.name}.ps1 install Install as 'ccg' system command (user PATH) +# .\\cc-${opts.name}.ps1 uninstall Remove 'ccg' and restore native claude +# .\\cc-${opts.name}.ps1 hijack Alias claude -> ccg in PowerShell profile +# .\\cc-${opts.name}.ps1 release Undo shell hijack +# .\\cc-${opts.name}.ps1 hijack-gui Persist user env vars so VS Code / Cursor extension uses gateway +# .\\cc-${opts.name}.ps1 release-gui Undo GUI hijack +# .\\cc-${opts.name}.ps1 native Run native claude (bypass gateway, one-time) +# .\\cc-${opts.name}.ps1 status Show gateway URL + hijack state + health +[CmdletBinding()] +param([Parameter(ValueFromRemainingArguments=$true)][string[]]$RestArgs) + +$GatewayUrl = "${opts.scheme}://${opts.gatewayAddr}" +$ClientToken = "${opts.token}" +${tlsBypass} +$InstallDir = Join-Path $env:LOCALAPPDATA "ccg-bin" +$InstallPs1 = Join-Path $InstallDir "ccg.ps1" +$InstallCmd = Join-Path $InstallDir "ccg.cmd" +$ProfilePath = $PROFILE.CurrentUserAllHosts +$AliasTag = "# cc-gateway alias" + +function Add-ToUserPath { + param([string]$Dir) + $userPath = [Environment]::GetEnvironmentVariable("PATH", "User") + if (-not $userPath) { $userPath = "" } + $parts = $userPath -split ';' | Where-Object { $_ -ne "" } + if ($parts -notcontains $Dir) { + $new = (@($Dir) + $parts) -join ';' + [Environment]::SetEnvironmentVariable("PATH", $new, "User") + return $true + } + return $false +} + +function Test-AliasInProfile { + if (-not (Test-Path $ProfilePath)) { return $false } + return [bool](Select-String -Path $ProfilePath -Pattern ([regex]::Escape($AliasTag)) -Quiet -ErrorAction SilentlyContinue) +} + +function Remove-AliasFromProfile { + if (-not (Test-Path $ProfilePath)) { return } + (Get-Content $ProfilePath) | Where-Object { $_ -notlike "*$AliasTag*" } | Set-Content $ProfilePath +} + +function Invoke-Install { + if (-not (Test-Path $InstallDir)) { New-Item -ItemType Directory -Path $InstallDir | Out-Null } + Copy-Item -Path $PSCommandPath -Destination $InstallPs1 -Force + $cmdContent = @' +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0ccg.ps1" %* +'@ + Set-Content -Path $InstallCmd -Value $cmdContent -Encoding ASCII + $added = Add-ToUserPath $InstallDir + Write-Host "Installed as 'ccg' at $InstallDir." + if ($added) { + Write-Host "" + Write-Host "Added $InstallDir to your user PATH. Open a NEW terminal for 'ccg' to be found." + } + Write-Host "" + Write-Host " ccg Start Claude Code through gateway" + Write-Host " ccg hijack Make shell 'claude' also go through gateway" + Write-Host " ccg hijack-gui Make VS Code / Cursor extension go through gateway" + Write-Host " ccg release Restore shell 'claude' to native" + Write-Host " ccg release-gui Restore GUI extension to native" + Write-Host " ccg status Show gateway connection status" + Write-Host " ccg help Show this help" +} + +function Test-GuiHijack { + $url = [Environment]::GetEnvironmentVariable("ANTHROPIC_BASE_URL", "User") + return (-not [string]::IsNullOrEmpty($url)) +} + +function Invoke-HijackGui { + [Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", $ClientToken, "User") + [Environment]::SetEnvironmentVariable("ANTHROPIC_BASE_URL", $GatewayUrl, "User") + Write-Host "Done. GUI apps (VS Code / Cursor) will route through gateway." + Write-Host " Fully Quit and reopen VS Code / Cursor for the extension to pick up the env vars." + Write-Host " Undo: ccg release-gui" +} + +function Invoke-ReleaseGui { + if (Test-GuiHijack) { + [Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", $null, "User") + [Environment]::SetEnvironmentVariable("ANTHROPIC_BASE_URL", $null, "User") + Write-Host "Done. GUI env vars removed. Restart VS Code / Cursor." + } else { + Write-Host "Nothing to undo - GUI hijack not active." + } +} + +function Invoke-Uninstall { + Remove-Item -Path $InstallPs1 -ErrorAction SilentlyContinue + Remove-Item -Path $InstallCmd -ErrorAction SilentlyContinue + if (Test-AliasInProfile) { Remove-AliasFromProfile } + if (Test-GuiHijack) { + [Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", $null, "User") + [Environment]::SetEnvironmentVariable("ANTHROPIC_BASE_URL", $null, "User") + } + Write-Host "Removed. Native 'claude' restored." +} + +function Invoke-Hijack { + if (Test-AliasInProfile) { + Write-Host "Already active. Run 'ccg release' to undo." + return + } + $dir = Split-Path -Parent $ProfilePath + if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + if (-not (Test-Path $ProfilePath)) { New-Item -ItemType File -Path $ProfilePath -Force | Out-Null } + Add-Content -Path $ProfilePath -Value "Set-Alias claude ccg $AliasTag" + Write-Host "Done. 'claude' now goes through gateway." + Write-Host " New PowerShell terminals: automatic." + Write-Host ' This terminal: reopen or run: . $PROFILE' + Write-Host " Undo anytime: ccg release" +} + +function Invoke-Release { + if (Test-AliasInProfile) { + Remove-AliasFromProfile + Remove-Item Alias:claude -ErrorAction SilentlyContinue + Write-Host "Done. 'claude' is back to native." + } else { + Write-Host "Nothing to undo - 'claude' is already native." + } +} + +function Invoke-Native { + $rest = if ($RestArgs.Count -gt 1) { $RestArgs[1..($RestArgs.Count - 1)] } else { @() } + $app = Get-Command claude -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $app) { Write-Error "claude not found"; exit 1 } + & $app.Source @rest + exit $LASTEXITCODE +} + +function Test-GatewayHealth { + try { + $params = @{ Uri = "$GatewayUrl/_health"; TimeoutSec = 3; UseBasicParsing = $true; ErrorAction = 'Stop' } + if ($PSVersionTable.PSVersion.Major -ge 6) { $params.SkipCertificateCheck = $true } + else { [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } } + $r = Invoke-WebRequest @params + return ($r.StatusCode -lt 500) + } catch { return $false } +} + +function Invoke-Status { + Write-Host "Gateway: $GatewayUrl" + if (Test-AliasInProfile) { + Write-Host "Shell: ON (claude -> ccg)" + } else { + Write-Host "Shell: OFF (claude = native)" + } + if (Test-GuiHijack) { + Write-Host "GUI: ON (VS Code / Cursor -> gateway)" + } else { + Write-Host "GUI: OFF (VS Code / Cursor uses native)" + } + if (Test-GatewayHealth) { Write-Host "Health: OK" } else { Write-Host "Health: UNREACHABLE" } +} + +function Invoke-Help { + Write-Host "ccg - Claude Code Gateway Client (Windows)" + Write-Host "" + Write-Host "Usage:" + Write-Host " ccg Start Claude Code through gateway" + Write-Host " ccg [claude args] Pass any arguments to Claude Code" + Write-Host " ccg --print 'hi' Single-shot mode" + Write-Host "" + Write-Host "Setup:" + Write-Host " ccg install Install as 'ccg' system command (user PATH)" + Write-Host " ccg uninstall Remove 'ccg' and clean up" + Write-Host "" + Write-Host "Routing (shell):" + Write-Host " ccg hijack Make 'claude' (in terminal) go through gateway" + Write-Host " ccg release Restore shell 'claude' to native" + Write-Host " ccg native [args] Run native claude once (bypass gateway)" + Write-Host "" + Write-Host "Routing (GUI extension):" + Write-Host " ccg hijack-gui Make VS Code / Cursor extension use gateway" + Write-Host " ccg release-gui Restore GUI extension to native" + Write-Host "" + Write-Host "Info:" + Write-Host " ccg status Show gateway and hijack status" + Write-Host " ccg help Show this help" +} + +# Subcommand dispatch +if ($RestArgs -and $RestArgs.Count -gt 0) { + switch ($RestArgs[0]) { + 'install' { Invoke-Install; exit 0 } + 'uninstall' { Invoke-Uninstall; exit 0 } + 'hijack' { Invoke-Hijack; exit 0 } + 'release' { Invoke-Release; exit 0 } + 'hijack-gui' { Invoke-HijackGui; exit 0 } + 'release-gui' { Invoke-ReleaseGui; exit 0 } + 'native' { Invoke-Native } + 'status' { Invoke-Status; exit 0 } + 'help' { Invoke-Help; exit 0 } + '--help' { Invoke-Help; exit 0 } + '-h' { Invoke-Help; exit 0 } + } +} + +# Main: launch through gateway +# Pick first match — on Windows npm installs both 'claude' and 'claude.cmd'; +# without -First 1, $claudeApp is an array and '& $claudeApp.Source' joins paths. +$claudeApp = Get-Command claude -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if (-not $claudeApp) { + Write-Error "Error: 'claude' not found. Install Claude Code first:" + Write-Error " npm install -g @anthropic-ai/claude-code" + exit 1 +} + +$env:ANTHROPIC_API_KEY = $ClientToken +$env:ANTHROPIC_BASE_URL = $GatewayUrl +$env:CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1" +$env:CLAUDE_CODE_ATTRIBUTION_HEADER = "false" + +if (-not (Test-GatewayHealth)) { + Write-Host "Warning: Gateway at $GatewayUrl is not reachable." + Write-Host "Make sure the gateway is running." + Write-Host "" +} + +& $claudeApp.Source @RestArgs +exit $LASTEXITCODE +` +} + +export function buildLauncherScript(opts: LauncherOptions): string { + const tlsBypass = + opts.scheme === 'https' + ? '\n# Accept self-signed TLS cert from gateway\nexport NODE_TLS_REJECT_UNAUTHORIZED=0\n' + : '' + return `#!/bin/bash +# CC Gateway Client Launcher +# +# Usage: +# ./cc-${opts.name} Start Claude Code through gateway +# ./cc-${opts.name} --print "hello" Single-shot mode +# ./cc-${opts.name} install Install as 'ccg' command system-wide +# ./cc-${opts.name} uninstall Remove 'ccg' and restore native claude +# ./cc-${opts.name} hijack Alias claude -> ccg in shell rc +# ./cc-${opts.name} release Undo shell hijack +# ./cc-${opts.name} hijack-gui Persist env vars so VS Code / Cursor extension uses gateway +# ./cc-${opts.name} release-gui Undo GUI hijack +# ./cc-${opts.name} native Run native claude (bypass gateway, one-time) +GATEWAY_URL="${opts.scheme}://${opts.gatewayAddr}" +CLIENT_TOKEN="${opts.token}" +${tlsBypass} +# Pick a writable install dir. Apple Silicon Macs ship without /usr/local/bin +# by default; Intel Macs and most Linux distros have it. Fall back to +# ~/.local/bin so install always works without sudo as a last resort. +if [[ -d /opt/homebrew/bin ]]; then + INSTALL_DIR="/opt/homebrew/bin" +elif [[ -d /usr/local/bin ]]; then + INSTALL_DIR="/usr/local/bin" +else + INSTALL_DIR="$HOME/.local/bin" + mkdir -p "$INSTALL_DIR" +fi +INSTALL_PATH="$INSTALL_DIR/ccg" +SELF_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" +# Detect shell RC file +case "$SHELL" in + */zsh) RC_FILE="\${ZDOTDIR:-$HOME}/.zshrc" ;; + */bash) RC_FILE="$HOME/.bashrc" ;; + */fish) RC_FILE="\${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish" ;; + *) RC_FILE="$HOME/.profile" ;; +esac +ALIAS_TAG="# cc-gateway alias" + +# ── Subcommands ── + +case "$1" in + install) + if cp "$0" "$INSTALL_PATH" 2>/dev/null; then :; else + sudo cp "$0" "$INSTALL_PATH" || { echo "Install failed: cannot write to $INSTALL_PATH"; exit 1; } + fi + chmod +x "$INSTALL_PATH" 2>/dev/null || sudo chmod +x "$INSTALL_PATH" + echo "Installed as 'ccg' at $INSTALL_PATH." + case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) + echo "" + echo "Note: $INSTALL_DIR is not on your PATH." + echo " Add this line to $RC_FILE and reopen your terminal:" + echo " export PATH=\"$INSTALL_DIR:\$PATH\"" + ;; + esac + echo "" + echo " ccg Start Claude Code through gateway" + echo " ccg hijack Make shell 'claude' also go through gateway" + echo " ccg hijack-gui Make VS Code / Cursor extension go through gateway" + echo " ccg release Restore shell 'claude' to native" + echo " ccg release-gui Restore GUI extension to native" + echo " ccg status Show gateway connection status" + echo " ccg help Show this help" + exit 0 + ;; + + uninstall) + rm "$INSTALL_PATH" 2>/dev/null || sudo rm "$INSTALL_PATH" + if grep -q "$ALIAS_TAG" "$RC_FILE" 2>/dev/null; then + sed -i.bak "/$ALIAS_TAG/d" "$RC_FILE" + rm -f "\${RC_FILE}.bak" + fi + # Also clean up GUI hijack if present + case "$(uname -s)" in + Darwin) + PLIST_PATH="$HOME/Library/LaunchAgents/com.ccg.env.plist" + if [[ -f "$PLIST_PATH" ]]; then + launchctl unload "$PLIST_PATH" 2>/dev/null + rm -f "$PLIST_PATH" + launchctl unsetenv ANTHROPIC_API_KEY 2>/dev/null + launchctl unsetenv ANTHROPIC_BASE_URL 2>/dev/null + fi + ;; + Linux) + rm -f "\${XDG_CONFIG_HOME:-$HOME/.config}/environment.d/ccg.conf" + ;; + esac + echo "Removed. Native 'claude' restored." + exit 0 + ;; + + hijack) + if grep -q "$ALIAS_TAG" "$RC_FILE" 2>/dev/null; then + echo "Already active. Run 'ccg release' to undo." + else + if [[ "$SHELL" == */fish ]]; then + echo "alias claude 'ccg' $ALIAS_TAG" >> "$RC_FILE" + else + echo "alias claude='ccg' $ALIAS_TAG" >> "$RC_FILE" + fi + echo "Done. 'claude' now goes through gateway." + echo " New terminals: automatic." + echo " This terminal: reopen or run: source $RC_FILE" + echo " Undo anytime: ccg release" + fi + exit 0 + ;; + + release) + if grep -q "$ALIAS_TAG" "$RC_FILE" 2>/dev/null; then + sed -i.bak "/$ALIAS_TAG/d" "$RC_FILE" + rm -f "\${RC_FILE}.bak" + # Unalias in current shell + unalias claude 2>/dev/null + echo "Done. 'claude' is back to native." + else + echo "Nothing to undo — 'claude' is already native." + fi + exit 0 + ;; + + hijack-gui) + # Persist ANTHROPIC_* env vars so GUI apps (VS Code / Cursor) inherit them. + # macOS: LaunchAgent + launchctl setenv. Linux: ~/.config/environment.d/. + case "$(uname -s)" in + Darwin) + PLIST_PATH="$HOME/Library/LaunchAgents/com.ccg.env.plist" + mkdir -p "$(dirname "$PLIST_PATH")" + cat > "$PLIST_PATH" < + + + + Label + com.ccg.env + ProgramArguments + + /bin/sh + -c + launchctl setenv ANTHROPIC_API_KEY '$CLIENT_TOKEN'; launchctl setenv ANTHROPIC_BASE_URL '$GATEWAY_URL' + + RunAtLoad + + + +PLIST + chmod 600 "$PLIST_PATH" + launchctl unload "$PLIST_PATH" 2>/dev/null + launchctl load "$PLIST_PATH" + launchctl setenv ANTHROPIC_API_KEY "$CLIENT_TOKEN" + launchctl setenv ANTHROPIC_BASE_URL "$GATEWAY_URL" + echo "Done. GUI apps (VS Code / Cursor) will route through gateway." + echo " Fully Quit and reopen VS Code / Cursor (Cmd+Q, not just close window)." + echo " Undo: ccg release-gui" + ;; + Linux) + ENV_DIR="\${XDG_CONFIG_HOME:-$HOME/.config}/environment.d" + ENV_FILE="$ENV_DIR/ccg.conf" + mkdir -p "$ENV_DIR" + cat > "$ENV_FILE" </dev/null + rm -f "$PLIST_PATH" + fi + launchctl unsetenv ANTHROPIC_API_KEY 2>/dev/null + launchctl unsetenv ANTHROPIC_BASE_URL 2>/dev/null + echo "Done. GUI env vars removed. Restart VS Code / Cursor." + ;; + Linux) + ENV_FILE="\${XDG_CONFIG_HOME:-$HOME/.config}/environment.d/ccg.conf" + if [[ -f "$ENV_FILE" ]]; then + rm -f "$ENV_FILE" + echo "Done. Removed $ENV_FILE. Log out and back in for the change to take effect." + else + echo "Nothing to undo — GUI hijack not active." + fi + ;; + *) + echo "release-gui is not supported on $(uname -s)." + exit 1 + ;; + esac + exit 0 + ;; + + native) + shift + exec command claude "$@" + ;; + + status) + echo "Gateway: $GATEWAY_URL" + if grep -q "$ALIAS_TAG" "$RC_FILE" 2>/dev/null; then + echo "Shell: ON (claude → ccg)" + else + echo "Shell: OFF (claude = native)" + fi + GUI_STATE="OFF (VS Code / Cursor uses native)" + case "$(uname -s)" in + Darwin) + [[ -f "$HOME/Library/LaunchAgents/com.ccg.env.plist" ]] && GUI_STATE="ON (VS Code / Cursor → gateway)" + ;; + Linux) + [[ -f "\${XDG_CONFIG_HOME:-$HOME/.config}/environment.d/ccg.conf" ]] && GUI_STATE="ON (VS Code / Cursor → gateway)" + ;; + esac + echo "GUI: $GUI_STATE" + HEALTH=$(curl -sk --max-time 3 "\${GATEWAY_URL}/_health" 2>/dev/null) + if [[ -n "$HEALTH" ]]; then + echo "Health: OK" + else + echo "Health: UNREACHABLE" + fi + exit 0 + ;; + + help|--help|-h) + echo "ccg — Claude Code Gateway Client" + echo "" + echo "Usage:" + echo " ccg Start Claude Code through gateway" + echo " ccg [claude args] Pass any arguments to Claude Code" + echo " ccg --print \\"hi\\" Single-shot mode" + echo "" + echo "Setup:" + echo " ccg install Install as 'ccg' system command" + echo " ccg uninstall Remove 'ccg' and clean up" + echo "" + echo "Routing (shell):" + echo " ccg hijack Make 'claude' (in terminal) go through gateway" + echo " ccg release Restore shell 'claude' to native" + echo " ccg native [args] Run native claude once (bypass gateway)" + echo "" + echo "Routing (GUI extension):" + echo " ccg hijack-gui Make VS Code / Cursor extension use gateway" + echo " ccg release-gui Restore GUI extension to native" + echo "" + echo "Info:" + echo " ccg status Show gateway and hijack status" + echo " ccg help Show this help" + exit 0 + ;; +esac + +# ── Main: launch through gateway ── + +# Check claude is installed +if ! command -v claude &>/dev/null; then + echo "Error: 'claude' not found. Install Claude Code first:" + echo " npm install -g @anthropic-ai/claude-code" + exit 1 +fi + +# Set env vars for this process only — nothing is written to disk +export ANTHROPIC_API_KEY="$CLIENT_TOKEN" +export ANTHROPIC_BASE_URL="$GATEWAY_URL" +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +export CLAUDE_CODE_ATTRIBUTION_HEADER=false + +# Check gateway is reachable +HEALTH=$(curl -sk --max-time 3 "\${GATEWAY_URL}/_health" 2>/dev/null) +if [[ -z "$HEALTH" ]]; then + echo "Warning: Gateway at \${GATEWAY_URL} is not reachable." + echo "Make sure the gateway is running." + echo "" +fi + +# Pass all arguments through to claude +exec claude "$@" +` +} diff --git a/src/config.ts b/src/config.ts index 3226203..293acac 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,9 +2,15 @@ import { readFileSync } from 'fs' import { parse } from 'yaml' import { resolve } from 'path' +export type CostLimitPeriod = 'lifetime' | 'monthly' | 'daily' + export type TokenEntry = { name: string token: string + // Optional cost cap (USD). 0 / undefined = unlimited. + cost_limit_usd?: number + // Window the cap applies to. Defaults to 'lifetime'. + cost_limit_period?: CostLimitPeriod } export type Config = { @@ -48,6 +54,9 @@ export type Config = { level: 'debug' | 'info' | 'warn' | 'error' audit: boolean } + db?: { + path: string + } } export function loadConfig(configPath?: string): Config { diff --git a/src/dashboard.ts b/src/dashboard.ts new file mode 100644 index 0000000..d667640 --- /dev/null +++ b/src/dashboard.ts @@ -0,0 +1,1575 @@ +export function renderLogin(error?: string): string { + const errBlock = error + ? `
${escapeHtml(error)}
` + : '' + return ` + + + +CC Gateway — Login + + + + +
+

CC Gateway

+

Sign in to access the dashboard

+ ${errBlock} + + + + + +
+ +` +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export function renderDashboard(): string { + return ` + + + +CC Gateway — Dashboard + + + + + +
+
+

Overview

+
+ loading… + + +
+ +
+
+
+
+
+
+
+

Cost & usage by period

+
+
+
+

Requests over time (per client)

+
+
+
+

By model

+
+
+
+

+ Clients + +

+
+
+
+
+

+ Recent requests + +

+ +
+
+
+ + How to use this dashboard + +
+

Stats row — totals across the gateway's full history (persisted in SQLite): requests, accumulated cost (USD list price), tokens, active clients, errors, uptime.

+

Cost & usage by period — same totals split by Today / Last 7d / Last 30d / All time so you can track spend trend.

+

Requests over time — per-client traffic. Toggle Last 60 min / Last 24 h.

+

By model — per-model totals: calls, input/output/cache tokens, and cost. Cost uses Anthropic public list prices.

+

Clients — every entry under auth.tokens, with their lifetime calls / tokens / cost. Click + Add client to generate a token, append it to config.yaml, and download a launcher script.

+

Recent requests — last 50 requests with model, tokens, cost, and duration. New rows stream in at the top; updates pause while you're hovering so the view doesn't jump.

+

Pick a target platform — macOS / Linux downloads cc-<name> (bash), Windows downloads cc-<name>.ps1 (PowerShell). The "Client created" panel shows OS-specific install steps to copy into a terminal.

+
+
+
+
+
+ + + + + + + + +` +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..14b9841 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,80 @@ +import Database from 'better-sqlite3' +import { mkdirSync } from 'fs' +import { dirname, resolve } from 'path' +import { randomBytes } from 'crypto' + +let db: Database.Database | null = null + +export function initDb(path: string): Database.Database { + const absPath = resolve(path) + mkdirSync(dirname(absPath), { recursive: true }) + + db = new Database(absPath) + db.pragma('journal_mode = WAL') + db.pragma('foreign_keys = ON') + db.pragma('synchronous = NORMAL') + + migrate(db) + return db +} + +export function getDb(): Database.Database { + if (!db) throw new Error('DB not initialized — call initDb() first') + return db +} + +function migrate(db: Database.Database) { + db.exec(` + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS request_metrics ( + ts INTEGER NOT NULL, + client TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + status INTEGER NOT NULL, + duration_ms INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_rm_ts ON request_metrics(ts); + CREATE INDEX IF NOT EXISTS idx_rm_client_ts ON request_metrics(client, ts); + `) + + // ── 2026-05 schema bump: per-request token usage + cost ── + const cols = db.prepare('PRAGMA table_info(request_metrics)').all() as Array<{ name: string }> + const has = (n: string) => cols.some((c) => c.name === n) + const addCol = (sql: string) => db.exec(`ALTER TABLE request_metrics ADD COLUMN ${sql}`) + if (!has('model')) addCol("model TEXT NOT NULL DEFAULT ''") + if (!has('input_tokens')) addCol('input_tokens INTEGER NOT NULL DEFAULT 0') + if (!has('output_tokens')) addCol('output_tokens INTEGER NOT NULL DEFAULT 0') + if (!has('cache_read_tokens')) addCol('cache_read_tokens INTEGER NOT NULL DEFAULT 0') + if (!has('cache_creation_tokens')) addCol('cache_creation_tokens INTEGER NOT NULL DEFAULT 0') + if (!has('cost_usd')) addCol('cost_usd REAL NOT NULL DEFAULT 0') + if (!has('user_message')) addCol("user_message TEXT NOT NULL DEFAULT ''") + + db.exec('CREATE INDEX IF NOT EXISTS idx_rm_model_ts ON request_metrics(model, ts)') +} + +export function getOrCreateMeta(key: string, factory: () => string): string { + const db = getDb() + const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(key) as { value: string } | undefined + if (row) return row.value + const value = factory() + db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run(key, value) + return value +} + +export function getSessionSecret(): Buffer { + const hex = getOrCreateMeta('session_secret', () => randomBytes(32).toString('hex')) + return Buffer.from(hex, 'hex') +} diff --git a/src/index.ts b/src/index.ts index 13e822b..5be6d62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,20 +1,77 @@ +import { watchFile } from 'fs' +import { resolve } from 'path' import { loadConfig } from './config.js' import { setLogLevel, log } from './logger.js' -import { initOAuth } from './oauth.js' +import { initOAuth, setOnTokensUpdated } from './oauth.js' import { startProxy } from './proxy.js' +import { initAuth } from './auth.js' +import { initDb } from './db.js' +import { initMetrics } from './metrics.js' +import { countUsers } from './users.js' +import { + bootstrapConfigIfMissing, + syncOAuthFromCredentialsIfChanged, + updateConfigOAuth, +} from './bootstrap-config.js' -const configPath = process.argv[2] +// Resolve the config path: explicit arg > CCG_CONFIG_PATH env > /app/data/config.yaml. +// /app/data is the persistent volume so the auto-generated config survives restarts. +const configPath = + process.argv[2] || + process.env.CCG_CONFIG_PATH || + '/app/data/config.yaml' try { + if (!bootstrapConfigIfMissing(configPath)) { + process.exit(1) + } + + // If a credentials.json is mounted and its refresh_token differs from the + // one persisted in config.yaml (e.g. host did `claude` and rotated the + // token), refresh the config before we load it. + syncOAuthFromCredentialsIfChanged(configPath) + const config = loadConfig(configPath) setLogLevel(config.logging.level) log('info', 'CC Gateway starting...') + log('info', `Config: ${resolve(configPath)}`) + + // Whenever OAuth refreshes (immediately or on the schedule), persist the + // rotated refresh_token back to config.yaml so container restarts pick up + // the latest valid token instead of replaying a consumed one. + setOnTokensUpdated((tokens) => { + updateConfigOAuth(configPath, tokens) + }) + + const dbPath = config.db?.path || './data/ccg.db' + initDb(dbPath) + initMetrics() + log('info', `SQLite database: ${resolve(dbPath)}`) + if (countUsers() === 0) { + log('warn', 'No dashboard users yet. Create one with: npm run add-user ') + } // Initialize OAuth — uses existing access token if valid, only refreshes when expired await initOAuth(config.oauth) startProxy(config) + + // Hot-reload auth.tokens on config changes (poll-based — works with bind mounts) + const watchPath = resolve(configPath) + let lastTokenSig = JSON.stringify(config.auth.tokens) + watchFile(watchPath, { interval: 2000 }, () => { + try { + const next = loadConfig(configPath) + const sig = JSON.stringify(next.auth.tokens) + if (sig === lastTokenSig) return + initAuth(next) + lastTokenSig = sig + log('info', `Reloaded auth.tokens (${next.auth.tokens.length} entries: ${next.auth.tokens.map(t => t.name).join(', ')})`) + } catch (err) { + log('error', `Config reload failed, keeping existing tokens: ${err instanceof Error ? err.message : err}`) + } + }) } catch (err) { console.error(`Fatal: ${err instanceof Error ? err.message : err}`) process.exit(1) diff --git a/src/metrics.ts b/src/metrics.ts new file mode 100644 index 0000000..0201b2e --- /dev/null +++ b/src/metrics.ts @@ -0,0 +1,377 @@ +import { getDb } from './db.js' + +const SYNTHETIC_BLOCK_RE = /<(system-reminder|command-name|command-message|command-args|local-command-stdout|local-command-stderr|user-prompt-submit-hook)>[\s\S]*?<\/\1>/gi +function stripSyntheticBlocks(text: string): string { + return text.replace(SYNTHETIC_BLOCK_RE, '').replace(/\s+/g, ' ').trim() +} + +const MINUTE_MS = 60_000 +const HOUR_MS = 3_600_000 +const MINUTES_KEPT = 60 +const HOURS_KEPT = 24 +const RETENTION_DAYS = 30 +const RECENT_LIMIT = 50 + +export interface RequestRecord { + ts: number + client: string + method: string + path: string + status: number + durationMs: number + model?: string + inputTokens?: number + outputTokens?: number + cacheReadTokens?: number + cacheCreationTokens?: number + costUsd?: number + // Truncated last user message text — for at-a-glance dashboard display + userMessage?: string +} + +let startedAt = Date.now() +let cleanupTimer: NodeJS.Timeout | null = null + +export function initMetrics() { + startedAt = Date.now() + pruneOldRows() + if (cleanupTimer) clearInterval(cleanupTimer) + cleanupTimer = setInterval(pruneOldRows, HOUR_MS).unref() +} + +function pruneOldRows() { + const cutoff = Date.now() - RETENTION_DAYS * 24 * HOUR_MS + try { + getDb().prepare('DELETE FROM request_metrics WHERE ts < ?').run(cutoff) + } catch { + // DB may not be initialized yet during early startup + } +} + +export function recordRequest(rec: RequestRecord) { + try { + getDb() + .prepare( + `INSERT INTO request_metrics ( + ts, client, method, path, status, duration_ms, + model, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, cost_usd, + user_message + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + rec.ts, rec.client, rec.method, rec.path, rec.status, rec.durationMs, + rec.model || '', + rec.inputTokens || 0, + rec.outputTokens || 0, + rec.cacheReadTokens || 0, + rec.cacheCreationTokens || 0, + rec.costUsd || 0, + rec.userMessage || '', + ) + } catch (err) { + // Don't break proxying on metrics failures + } +} + +/** + * Sum of cost_usd for a client since the given timestamp. + * sinceTs = 0 means lifetime. + */ +export function getClientCostSince(client: string, sinceTs: number): number { + try { + const row = getDb() + .prepare( + 'SELECT SUM(cost_usd) as cost FROM request_metrics WHERE client = ? AND ts >= ?', + ) + .get(client, sinceTs) as { cost: number | null } + return row?.cost || 0 + } catch { + return 0 + } +} + +/** + * Resolve the start-of-window timestamp for a cost-limit period. + * UTC-based for stability across server timezones. + */ +export function periodStart(period: 'lifetime' | 'monthly' | 'daily' | undefined): number { + const now = new Date() + if (period === 'daily') { + return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + } + if (period === 'monthly') { + return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) + } + return 0 +} + +interface ClientRow { + client: string + total: number + errors: number + total_duration: number + first_seen: number + last_seen: number + s2xx: number + s3xx: number + s4xx: number + s5xx: number + m_get: number + m_post: number + m_put: number + m_delete: number + m_other: number + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_creation_tokens: number + cost_usd: number +} + +export function getMetricsSnapshot() { + const db = getDb() + const now = Date.now() + const currentMinute = Math.floor(now / MINUTE_MS) * MINUTE_MS + const currentHour = Math.floor(now / HOUR_MS) * HOUR_MS + + const totalsRow = db + .prepare( + `SELECT + COUNT(*) as total, + SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END) as errors, + SUM(input_tokens) as input_tokens, + SUM(output_tokens) as output_tokens, + SUM(cache_read_tokens) as cache_read_tokens, + SUM(cache_creation_tokens) as cache_creation_tokens, + SUM(cost_usd) as cost_usd + FROM request_metrics`, + ) + .get() as { + total: number + errors: number | null + input_tokens: number | null + output_tokens: number | null + cache_read_tokens: number | null + cache_creation_tokens: number | null + cost_usd: number | null + } + + const totals = { + total: totalsRow.total || 0, + errors: totalsRow.errors || 0, + inputTokens: totalsRow.input_tokens || 0, + outputTokens: totalsRow.output_tokens || 0, + cacheReadTokens: totalsRow.cache_read_tokens || 0, + cacheCreationTokens: totalsRow.cache_creation_tokens || 0, + costUsd: totalsRow.cost_usd || 0, + startedAt, + } + + const clientRows = db + .prepare( + `SELECT + client, + COUNT(*) as total, + SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END) as errors, + SUM(duration_ms) as total_duration, + MIN(ts) as first_seen, + MAX(ts) as last_seen, + SUM(CASE WHEN status >= 200 AND status < 300 THEN 1 ELSE 0 END) as s2xx, + SUM(CASE WHEN status >= 300 AND status < 400 THEN 1 ELSE 0 END) as s3xx, + SUM(CASE WHEN status >= 400 AND status < 500 THEN 1 ELSE 0 END) as s4xx, + SUM(CASE WHEN status >= 500 THEN 1 ELSE 0 END) as s5xx, + SUM(CASE WHEN method = 'GET' THEN 1 ELSE 0 END) as m_get, + SUM(CASE WHEN method = 'POST' THEN 1 ELSE 0 END) as m_post, + SUM(CASE WHEN method = 'PUT' THEN 1 ELSE 0 END) as m_put, + SUM(CASE WHEN method = 'DELETE' THEN 1 ELSE 0 END) as m_delete, + SUM(CASE WHEN method NOT IN ('GET','POST','PUT','DELETE') THEN 1 ELSE 0 END) as m_other, + SUM(input_tokens) as input_tokens, + SUM(output_tokens) as output_tokens, + SUM(cache_read_tokens) as cache_read_tokens, + SUM(cache_creation_tokens) as cache_creation_tokens, + SUM(cost_usd) as cost_usd + FROM request_metrics + GROUP BY client + ORDER BY total DESC`, + ) + .all() as ClientRow[] + + const modelRows = db + .prepare( + `SELECT + model, + COUNT(*) as total, + SUM(input_tokens) as input_tokens, + SUM(output_tokens) as output_tokens, + SUM(cache_read_tokens) as cache_read_tokens, + SUM(cache_creation_tokens) as cache_creation_tokens, + SUM(cost_usd) as cost_usd + FROM request_metrics + WHERE model != '' + GROUP BY model + ORDER BY cost_usd DESC`, + ) + .all() as Array<{ + model: string + total: number + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_creation_tokens: number + cost_usd: number + }> + + const clients = clientRows.map((r) => { + const byStatus: Record = {} + if (r.s2xx) byStatus['2xx'] = r.s2xx + if (r.s3xx) byStatus['3xx'] = r.s3xx + if (r.s4xx) byStatus['4xx'] = r.s4xx + if (r.s5xx) byStatus['5xx'] = r.s5xx + const byMethod: Record = {} + if (r.m_get) byMethod['GET'] = r.m_get + if (r.m_post) byMethod['POST'] = r.m_post + if (r.m_put) byMethod['PUT'] = r.m_put + if (r.m_delete) byMethod['DELETE'] = r.m_delete + if (r.m_other) byMethod['OTHER'] = r.m_other + return { + name: r.client, + total: r.total, + errors: r.errors, + totalDurationMs: r.total_duration, + avgDurationMs: r.total > 0 ? Math.round(r.total_duration / r.total) : 0, + firstSeen: r.first_seen, + lastSeen: r.last_seen, + byStatus, + byMethod, + inputTokens: r.input_tokens || 0, + outputTokens: r.output_tokens || 0, + cacheReadTokens: r.cache_read_tokens || 0, + cacheCreationTokens: r.cache_creation_tokens || 0, + costUsd: r.cost_usd || 0, + } + }) + + const models = modelRows.map((r) => ({ + model: r.model, + total: r.total, + inputTokens: r.input_tokens || 0, + outputTokens: r.output_tokens || 0, + cacheReadTokens: r.cache_read_tokens || 0, + cacheCreationTokens: r.cache_creation_tokens || 0, + costUsd: r.cost_usd || 0, + })) + + const minuteStart = currentMinute - (MINUTES_KEPT - 1) * MINUTE_MS + const hourStart = currentHour - (HOURS_KEPT - 1) * HOUR_MS + + const minuteRows = db + .prepare( + `SELECT client, (ts / ${MINUTE_MS}) * ${MINUTE_MS} as bucket, COUNT(*) as count + FROM request_metrics + WHERE ts >= ? + GROUP BY client, bucket`, + ) + .all(minuteStart) as Array<{ client: string; bucket: number; count: number }> + + const hourRows = db + .prepare( + `SELECT client, (ts / ${HOUR_MS}) * ${HOUR_MS} as bucket, COUNT(*) as count + FROM request_metrics + WHERE ts >= ? + GROUP BY client, bucket`, + ) + .all(hourStart) as Array<{ client: string; bucket: number; count: number }> + + const minuteSeries: Record> = {} + const hourSeries: Record> = {} + + for (const c of clients) { + minuteSeries[c.name] = [] + for (let i = MINUTES_KEPT - 1; i >= 0; i--) { + minuteSeries[c.name].push({ ts: currentMinute - i * MINUTE_MS, count: 0 }) + } + hourSeries[c.name] = [] + for (let i = HOURS_KEPT - 1; i >= 0; i--) { + hourSeries[c.name].push({ ts: currentHour - i * HOUR_MS, count: 0 }) + } + } + for (const r of minuteRows) { + const series = minuteSeries[r.client] + if (!series) continue + const point = series.find((p) => p.ts === r.bucket) + if (point) point.count = r.count + } + for (const r of hourRows) { + const series = hourSeries[r.client] + if (!series) continue + const point = series.find((p) => p.ts === r.bucket) + if (point) point.count = r.count + } + + const recent = (db + .prepare( + `SELECT + ts, client, method, path, status, + duration_ms as durationMs, + model, + input_tokens as inputTokens, + output_tokens as outputTokens, + cache_read_tokens as cacheReadTokens, + cache_creation_tokens as cacheCreationTokens, + cost_usd as costUsd, + user_message as userMessage + FROM request_metrics + ORDER BY ts DESC + LIMIT ?`, + ) + .all(RECENT_LIMIT) as RequestRecord[]) + // Strip Claude Code synthetic blocks from historical rows captured before + // the proxy started filtering them. New writes are already clean. + .map((r) => r.userMessage + ? { ...r, userMessage: stripSyntheticBlocks(r.userMessage) } + : r) + + // Period summaries — easy to read "today / 7d / 30d" cost & usage + const day = 24 * HOUR_MS + const periods = [ + { key: 'today', label: 'Today', since: now - day }, + { key: '7d', label: 'Last 7d', since: now - 7 * day }, + { key: '30d', label: 'Last 30d', since: now - 30 * day }, + ] + const periodStmt = db.prepare( + `SELECT + COUNT(*) as total, + SUM(input_tokens) as input_tokens, + SUM(output_tokens) as output_tokens, + SUM(cache_read_tokens) as cache_read_tokens, + SUM(cache_creation_tokens) as cache_creation_tokens, + SUM(cost_usd) as cost_usd + FROM request_metrics WHERE ts >= ?`, + ) + const periodSummary = periods.map((p) => { + const r = periodStmt.get(p.since) as any + return { + key: p.key, + label: p.label, + total: r.total || 0, + inputTokens: r.input_tokens || 0, + outputTokens: r.output_tokens || 0, + cacheReadTokens: r.cache_read_tokens || 0, + cacheCreationTokens: r.cache_creation_tokens || 0, + costUsd: r.cost_usd || 0, + } + }) + + return { + now, + uptimeMs: now - startedAt, + totals, + periods: periodSummary, + clients, + models, + minuteSeries, + hourSeries, + recent, + } +} diff --git a/src/oauth.ts b/src/oauth.ts index 0e482dc..fac9e78 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -19,6 +19,20 @@ type OAuthTokens = { } let cachedTokens: OAuthTokens | null = null +let onTokensUpdated: ((tokens: OAuthTokens) => void) | null = null + +export function setOnTokensUpdated(cb: (tokens: OAuthTokens) => void) { + onTokensUpdated = cb +} + +function persistTokens(tokens: OAuthTokens) { + if (!onTokensUpdated) return + try { + onTokensUpdated(tokens) + } catch (err) { + log('warn', `Token persist callback threw: ${err instanceof Error ? err.message : err}`) + } +} /** * Initialize OAuth. @@ -55,6 +69,7 @@ export async function initOAuth(oauth: { } cachedTokens = await refreshOAuthToken(oauth.refresh_token) + persistTokens(cachedTokens) log('info', `OAuth token acquired, expires at ${new Date(cachedTokens.expiresAt).toISOString()}`) scheduleRefresh(oauth.refresh_token) } @@ -71,6 +86,7 @@ function scheduleRefresh(refreshToken: string) { cachedTokens = await refreshOAuthToken( cachedTokens?.refreshToken || refreshToken, ) + persistTokens(cachedTokens) log('info', `OAuth token refreshed, expires at ${new Date(cachedTokens.expiresAt).toISOString()}`) scheduleRefresh(cachedTokens.refreshToken || refreshToken) } catch (err) { diff --git a/src/pricing.ts b/src/pricing.ts new file mode 100644 index 0000000..ea81f4b --- /dev/null +++ b/src/pricing.ts @@ -0,0 +1,69 @@ +// Anthropic public list prices in USD per 1M tokens, as of 2026-05. +// When a request reports a model id we don't recognise, we fall back to the +// most specific family match (sonnet/opus/haiku) and finally to sonnet rates. + +export interface Pricing { + input: number // per 1M input tokens + output: number // per 1M output tokens + cacheRead: number // per 1M cache-read input tokens + cacheWrite: number // per 1M cache-creation input tokens +} + +const RATES: Record = { + // Claude 4.x family (current) + 'claude-opus-4-8': { input: 5.00, output: 25.00, cacheRead: 0.50, cacheWrite: 6.25 }, + 'claude-opus-4-7': { input: 15.00, output: 75.00, cacheRead: 1.50, cacheWrite: 18.75 }, + 'claude-opus-4-6': { input: 15.00, output: 75.00, cacheRead: 1.50, cacheWrite: 18.75 }, + 'claude-opus-4-5': { input: 15.00, output: 75.00, cacheRead: 1.50, cacheWrite: 18.75 }, + 'claude-sonnet-4-7': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 3.75 }, + 'claude-sonnet-4-6': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 3.75 }, + 'claude-sonnet-4-5': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 3.75 }, + 'claude-haiku-4-5': { input: 0.80, output: 4.00, cacheRead: 0.08, cacheWrite: 1.00 }, + // 3.x family (older but may still appear) + 'claude-3-7-sonnet': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 3.75 }, + 'claude-3-5-sonnet': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 3.75 }, + 'claude-3-5-haiku': { input: 0.80, output: 4.00, cacheRead: 0.08, cacheWrite: 1.00 }, + 'claude-3-opus': { input: 15.00, output: 75.00, cacheRead: 1.50, cacheWrite: 18.75 }, +} + +function lookupRate(model: string): Pricing { + const key = model.toLowerCase() + // Exact match by stripping date suffix: "claude-sonnet-4-5-20250929" → "claude-sonnet-4-5" + for (const id of Object.keys(RATES)) { + if (key === id || key.startsWith(id + '-')) return RATES[id] + } + // Family fallback + if (key.includes('opus')) return RATES['claude-opus-4-8'] + if (key.includes('haiku')) return RATES['claude-haiku-4-5'] + return RATES['claude-sonnet-4-7'] +} + +export interface UsageBreakdown { + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheCreationTokens: number +} + +export function computeCost(model: string, u: UsageBreakdown): number { + const r = lookupRate(model || '') + return ( + (u.inputTokens / 1_000_000) * r.input + + (u.outputTokens / 1_000_000) * r.output + + (u.cacheReadTokens / 1_000_000) * r.cacheRead + + (u.cacheCreationTokens / 1_000_000) * r.cacheWrite + ) +} + +export function formatCost(usd: number): string { + if (usd < 0.0001) return '$0.0000' + if (usd < 1) return '$' + usd.toFixed(4) + if (usd < 100) return '$' + usd.toFixed(2) + return '$' + Math.round(usd).toLocaleString() +} + +export function formatTokens(n: number): string { + if (n < 1000) return String(n) + if (n < 1_000_000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k' + return (n / 1_000_000).toFixed(2).replace(/\.00$/, '') + 'M' +} diff --git a/src/proxy.ts b/src/proxy.ts index 03d54b3..2bb2050 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -3,12 +3,83 @@ import { createServer as createHttpServer, type IncomingMessage, type ServerResp import { readFileSync } from 'fs' import { request as httpsRequest } from 'https' import { URL } from 'url' +import zlib from 'zlib' import type { Config } from './config.js' -import { authenticate, initAuth } from './auth.js' +import { authenticate, initAuth, setAuthTokens } from './auth.js' import { getAccessToken } from './oauth.js' import { rewriteBody, rewriteHeaders } from './rewriter.js' import { audit, log } from './logger.js' import { getProxyAgent } from './proxy-agent.js' +import { recordRequest, getMetricsSnapshot, getClientCostSince, periodStart } from './metrics.js' +import { SSEUsageParser } from './usage-parser.js' +import { computeCost } from './pricing.js' +import { renderDashboard, renderLogin } from './dashboard.js' +import { authenticateUser } from './users.js' +import { + createSessionCookie, + setCookieHeader, + clearCookieHeader, + getSessionFromRequest, +} from './session.js' +import { addClient, listClients, removeClient, setClientLimit, buildLauncherScript, buildPowerShellLauncherScript } from './clients.js' +import type { CostLimitPeriod } from './config.js' + +const USER_MESSAGE_MAX = 200 + +/** Refresh in-memory token map from current config.yaml on disk. */ +function reloadAuthFromConfig(): void { + setAuthTokens(listClients()) +} + +// Claude Code wraps a lot of synthetic context (hook output, slash-command +// metadata, reminders) inside the user message stream. None of it is text the +// human typed, so it shouldn't appear in the dashboard preview. +const SYNTHETIC_BLOCK_RE = /<(system-reminder|command-name|command-message|command-args|local-command-stdout|local-command-stderr|user-prompt-submit-hook)>[\s\S]*?<\/\1>/gi + +function stripSyntheticBlocks(text: string): string { + return text.replace(SYNTHETIC_BLOCK_RE, '').replace(/\s+/g, ' ').trim() +} + +/** + * Pull the most recent user-authored text out of a /v1/messages request body. + * Returns truncated text suitable for a dashboard preview, or '' if not parseable. + */ +function extractLastUserMessage(body: Buffer): string { + if (!body.length) return '' + try { + const obj = JSON.parse(body.toString('utf-8')) + const msgs = obj?.messages + if (!Array.isArray(msgs)) return '' + for (let i = msgs.length - 1; i >= 0; i--) { + const m = msgs[i] + if (!m || m.role !== 'user') continue + let text = '' + if (typeof m.content === 'string') { + text = m.content + } else if (Array.isArray(m.content)) { + const parts: string[] = [] + for (const block of m.content) { + if (block?.type === 'text' && typeof block.text === 'string') { + parts.push(block.text) + } else if (block?.type === 'tool_result') { + // Skip tool_result blocks — keep walking back for an authored prompt + continue + } + } + text = parts.join('\n').trim() + } + const flat = stripSyntheticBlocks(text) + if (flat) { + return flat.length > USER_MESSAGE_MAX + ? flat.slice(0, USER_MESSAGE_MAX) + '…' + : flat + } + } + return '' + } catch { + return '' + } +} export function startProxy(config: Config) { initAuth(config) @@ -50,12 +121,14 @@ async function handleRequest( ) { const method = req.method || 'GET' const path = req.url || '/' + const pathname = path.split('?')[0] const clientIp = req.socket.remoteAddress || 'unknown' + const startedAt = Date.now() log('info', `← ${method} ${path} from ${clientIp}`) // Health check - no auth required - if (path === '/_health') { + if (pathname === '/_health') { const oauthOk = !!getAccessToken() const status = oauthOk ? 200 : 503 res.writeHead(status, { 'Content-Type': 'application/json' }) @@ -70,10 +143,24 @@ async function handleRequest( return } + // Login page + session-protected dashboard + if ( + pathname === '/login' || + pathname === '/logout' || + pathname === '/dashboard' || + pathname === '/' || + pathname === '/_metrics' || + pathname === '/api/clients' || + pathname.startsWith('/api/clients/') + ) { + await handleDashboardArea(req, res, pathname, method) + return + } + // Dry-run verification - shows what would be rewritten (auth required) - if (path === '/_verify') { - const clientName = authenticate(req) - if (!clientName) { + if (pathname === '/_verify') { + const entry = authenticate(req) + if (!entry) { res.writeHead(401, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Unauthorized' })) return @@ -85,13 +172,43 @@ async function handleRequest( } // Authenticate client (proxy-level auth) - const clientName = authenticate(req) - if (!clientName) { + const tokenEntry = authenticate(req) + if (!tokenEntry) { res.writeHead(401, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Unauthorized - provide client token via x-api-key header' })) log('warn', `Unauthorized request: ${method} ${path}`) return } + const clientName = tokenEntry.name + + // Cost limit enforcement: only gates billable inference (/v1/messages). + // Health, settings, event_logging, etc. are free and shouldn't be blocked. + if (pathname.startsWith('/v1/messages') && tokenEntry.cost_limit_usd && tokenEntry.cost_limit_usd > 0) { + const since = periodStart(tokenEntry.cost_limit_period) + const used = getClientCostSince(clientName, since) + if (used >= tokenEntry.cost_limit_usd) { + const periodLabel = tokenEntry.cost_limit_period || 'lifetime' + res.writeHead(429, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + error: 'Cost limit reached', + client: clientName, + period: periodLabel, + used_usd: Number(used.toFixed(4)), + limit_usd: tokenEntry.cost_limit_usd, + })) + log('warn', `Client "${clientName}" blocked: ${periodLabel} cost ${used.toFixed(4)} >= limit ${tokenEntry.cost_limit_usd}`) + recordRequest({ + ts: startedAt, + client: clientName, + method, + path: pathname, + status: 429, + durationMs: Date.now() - startedAt, + }) + if (config.logging.audit) audit(clientName, method, path, 429) + return + } + } log('info', `Client "${clientName}" → ${method} ${path}`) @@ -111,6 +228,14 @@ async function handleRequest( } let body = Buffer.concat(chunks) + // Capture last user message for dashboard preview before rewrite (rewrite + // doesn't touch authored content, but parsing pre-rewrite avoids re-parsing + // a serialized JSON we just produced). Best-effort — never blocks the call. + let userMessage = '' + if (body.length > 0 && pathname.startsWith('/v1/messages') && method === 'POST') { + userMessage = extractLastUserMessage(body) + } + // Rewrite identity fields in body if (body.length > 0) { try { @@ -126,9 +251,22 @@ async function handleRequest( config, ) - // Inject the real OAuth token via x-api-key (Anthropic uses this header for both - // API keys and OAuth tokens, distinguished by prefix: sk-ant-api03- vs sk-ant-oat01-) - rewrittenHeaders['x-api-key'] = oauthToken + // Inject the OAuth access_token. Anthropic OAuth tokens (sk-ant-oat01-) must + // be sent via Authorization: Bearer with the anthropic-beta: oauth-2025-04-20 + // flag — sending them via x-api-key returns 401 "Invalid authentication + // credentials". rewriteHeaders() already stripped any inbound auth headers. + delete rewrittenHeaders['x-api-key'] + rewrittenHeaders['authorization'] = `Bearer ${oauthToken}` + + const oauthBetaFlag = 'oauth-2025-04-20' + const existingBeta = rewrittenHeaders['anthropic-beta'] + if (existingBeta) { + if (!existingBeta.split(',').map((s) => s.trim()).includes(oauthBetaFlag)) { + rewrittenHeaders['anthropic-beta'] = `${existingBeta},${oauthBetaFlag}` + } + } else { + rewrittenHeaders['anthropic-beta'] = oauthBetaFlag + } // Forward to upstream const upstreamUrl = new URL(path, upstream) @@ -153,11 +291,83 @@ async function handleRequest( res.writeHead(status, responseHeaders) - // Stream response directly (SSE for Claude responses) - proxyRes.pipe(res) + // Tee the response: write to client (live streaming preserved) AND feed + // a parser that pulls token usage out of the SSE / JSON body. Only do + // this for /v1/messages where Anthropic emits usage; everything else + // just passes through directly to keep the hot path cheap. + const isMessages = pathname.startsWith('/v1/messages') + const parser = isMessages && status >= 200 && status < 300 ? new SSEUsageParser() : null - if (config.logging.audit) { - audit(clientName, method, path, status) + let finalized = false + const finalize = () => { + if (finalized) return + finalized = true + const usage = parser?.result() + const cost = usage && usage.model + ? computeCost(usage.model, usage) + : 0 + recordRequest({ + ts: startedAt, + client: clientName, + method, + path: pathname, + status, + durationMs: Date.now() - startedAt, + model: usage?.model, + inputTokens: usage?.inputTokens, + outputTokens: usage?.outputTokens, + cacheReadTokens: usage?.cacheReadTokens, + cacheCreationTokens: usage?.cacheCreationTokens, + costUsd: cost, + userMessage, + }) + if (config.logging.audit) { + audit(clientName, method, path, status) + } + } + + if (parser) { + // Forward raw upstream bytes to the client untouched so the response + // is byte-identical to a direct Anthropic call (preserves whatever + // Content-Encoding upstream chose). For the parser, decompress a + // local copy in-process so token counts stay readable regardless of + // gzip/br/deflate. + const encoding = String(proxyRes.headers['content-encoding'] || '') + .toLowerCase() + .trim() + let decoder: zlib.Gunzip | zlib.BrotliDecompress | zlib.Inflate | null = null + if (encoding === 'gzip') decoder = zlib.createGunzip() + else if (encoding === 'br') decoder = zlib.createBrotliDecompress() + else if (encoding === 'deflate') decoder = zlib.createInflate() + + if (decoder) { + decoder.on('data', (decoded: Buffer) => parser.feed(decoded)) + decoder.on('error', (err: Error) => { + log('warn', `Usage decoder failed (${encoding}): ${err.message}`) + }) + } + + proxyRes.on('data', (chunk: Buffer) => { + res.write(chunk) + if (decoder) decoder.write(chunk) + else parser.feed(chunk) + }) + proxyRes.on('end', () => { + if (decoder) decoder.end() + parser.end() + res.end() + finalize() + }) + proxyRes.on('error', (err) => { + log('error', `Upstream stream error: ${err.message}`) + if (decoder) decoder.destroy() + res.end() + finalize() + }) + } else { + proxyRes.pipe(res) + proxyRes.on('end', finalize) + proxyRes.on('close', finalize) } }, ) @@ -168,6 +378,15 @@ async function handleRequest( res.writeHead(502, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Bad gateway', detail: err.message })) } + recordRequest({ + ts: startedAt, + client: clientName, + method, + path: pathname, + status: 502, + durationMs: Date.now() - startedAt, + userMessage, + }) if (config.logging.audit) { audit(clientName, method, path, 502) } @@ -224,3 +443,328 @@ function buildVerificationPayload(config: Config) { }, } } + +function isSecureRequest(req: IncomingMessage): boolean { + const proto = req.headers['x-forwarded-proto'] + if (typeof proto === 'string' && proto.split(',')[0].trim() === 'https') return true + return (req.socket as { encrypted?: boolean }).encrypted === true +} + +async function readBody(req: IncomingMessage, limit = 64 * 1024): Promise { + const chunks: Buffer[] = [] + let total = 0 + for await (const chunk of req) { + const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk + total += buf.length + if (total > limit) throw new Error('body too large') + chunks.push(buf) + } + return Buffer.concat(chunks) +} + +async function handleDashboardArea( + req: IncomingMessage, + res: ServerResponse, + pathname: string, + method: string, +) { + const secure = isSecureRequest(req) + + // Root → redirect to dashboard or login + if (pathname === '/') { + const session = getSessionFromRequest(req) + res.writeHead(302, { Location: session ? '/dashboard' : '/login' }) + res.end() + return + } + + // Login: GET shows form, POST authenticates + if (pathname === '/login') { + if (method === 'GET') { + // If already logged in, send to dashboard + if (getSessionFromRequest(req)) { + res.writeHead(302, { Location: '/dashboard' }) + res.end() + return + } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(renderLogin()) + return + } + if (method === 'POST') { + let body: Buffer + try { + body = await readBody(req) + } catch { + res.writeHead(413, { 'Content-Type': 'text/plain' }) + res.end('Payload too large') + return + } + const params = new URLSearchParams(body.toString('utf-8')) + const username = (params.get('username') || '').trim() + const password = params.get('password') || '' + if (!username || !password) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(renderLogin('Username and password required')) + return + } + const user = authenticateUser(username, password) + if (!user) { + log('warn', `Failed login for "${username}" from ${req.socket.remoteAddress}`) + res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(renderLogin('Invalid username or password')) + return + } + const cookie = createSessionCookie(user.username) + log('info', `User "${user.username}" logged in`) + res.writeHead(302, { + Location: '/dashboard', + 'Set-Cookie': setCookieHeader(cookie, secure), + }) + res.end() + return + } + res.writeHead(405, { Allow: 'GET, POST' }) + res.end() + return + } + + // Logout: clear cookie, redirect to login + if (pathname === '/logout') { + res.writeHead(302, { + Location: '/login', + 'Set-Cookie': clearCookieHeader(), + }) + res.end() + return + } + + // Session-protected: dashboard + metrics + const session = getSessionFromRequest(req) + if (!session) { + if (pathname === '/_metrics') { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Unauthorized' })) + } else { + res.writeHead(302, { Location: '/login' }) + res.end() + } + return + } + + if (pathname === '/dashboard') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(renderDashboard()) + return + } + + if (pathname === '/_metrics') { + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }) + res.end(JSON.stringify(getMetricsSnapshot())) + return + } + + if (pathname === '/api/clients') { + if (method === 'GET') { + const clients = listClients().map((c) => { + const since = periodStart(c.cost_limit_period) + const used = c.cost_limit_usd ? getClientCostSince(c.name, since) : 0 + return { + name: c.name, + token_preview: c.token.slice(0, 8) + '…' + c.token.slice(-4), + cost_limit_usd: c.cost_limit_usd ?? null, + cost_limit_period: c.cost_limit_period ?? null, + cost_used_usd: c.cost_limit_usd ? Number(used.toFixed(4)) : null, + } + }) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ clients })) + return + } + if (method === 'POST') { + let body: Buffer + try { + body = await readBody(req) + } catch { + res.writeHead(413, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Payload too large' })) + return + } + let payload: { + name?: string + gateway_addr?: string + scheme?: string + format?: string + platform?: string + cost_limit_usd?: number | null + cost_limit_period?: CostLimitPeriod | null + } + try { + payload = JSON.parse(body.toString('utf-8')) + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Invalid JSON' })) + return + } + const name = (payload.name || '').trim() + const scheme = payload.scheme === 'http' ? 'http' : 'https' + const gatewayAddr = + (payload.gateway_addr || '').trim() || + (typeof req.headers.host === 'string' ? req.headers.host : 'localhost:8443') + if (!name) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'name required' })) + return + } + let entry + try { + entry = addClient(name, { + cost_limit_usd: payload.cost_limit_usd ?? undefined, + cost_limit_period: payload.cost_limit_period ?? undefined, + }) + } catch (err) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'failed' })) + return + } + reloadAuthFromConfig() + log('info', `User "${session.u}" added client "${entry.name}"`) + const isWindows = payload.platform === 'windows' + const opts = { + name: entry.name, + token: entry.token, + gatewayAddr, + scheme: scheme as 'http' | 'https', + } + const script = isWindows ? buildPowerShellLauncherScript(opts) : buildLauncherScript(opts) + if (payload.format === 'json') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ name: entry.name, token: entry.token, script })) + return + } + const filename = isWindows ? `cc-${entry.name}.ps1` : `cc-${entry.name}` + const ctype = isWindows + ? 'text/plain; charset=utf-8' + : 'application/x-shellscript; charset=utf-8' + res.writeHead(200, { + 'Content-Type': ctype, + 'Content-Disposition': `attachment; filename="${filename}"`, + 'X-Client-Token': entry.token, + }) + res.end(script) + return + } + res.writeHead(405, { Allow: 'GET, POST' }) + res.end() + return + } + + if (pathname.startsWith('/api/clients/')) { + const rest = pathname.slice('/api/clients/'.length) + + // GET /api/clients/:name/launcher — re-download launcher for an existing + // client (reuses token from config.yaml — does not rotate it). + if (rest.endsWith('/launcher')) { + if (method !== 'GET') { + res.writeHead(405, { Allow: 'GET' }) + res.end() + return + } + const name = decodeURIComponent(rest.slice(0, -('/launcher'.length))) + const existing = listClients().find((c) => c.name === name) + if (!existing) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'client not found' })) + return + } + const query = new URLSearchParams((req.url || '').split('?')[1] || '') + const platform = query.get('platform') || 'unix' + const scheme = query.get('scheme') === 'http' ? 'http' : 'https' + const gatewayAddr = + (query.get('gateway_addr') || '').trim() || + (typeof req.headers.host === 'string' ? req.headers.host : 'localhost:8443') + const isWindows = platform === 'windows' + const opts = { + name: existing.name, + token: existing.token, + gatewayAddr, + scheme: scheme as 'http' | 'https', + } + const script = isWindows ? buildPowerShellLauncherScript(opts) : buildLauncherScript(opts) + const filename = isWindows ? `cc-${existing.name}.ps1` : `cc-${existing.name}` + const ctype = isWindows + ? 'text/plain; charset=utf-8' + : 'application/x-shellscript; charset=utf-8' + log('info', `User "${session.u}" re-downloaded launcher for client "${name}"`) + res.writeHead(200, { + 'Content-Type': ctype, + 'Content-Disposition': `attachment; filename="${filename}"`, + }) + res.end(script) + return + } + + const name = decodeURIComponent(rest) + if (method === 'DELETE') { + let removed = false + try { + removed = removeClient(name) + } catch (err) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'failed' })) + return + } + if (!removed) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'not found' })) + return + } + reloadAuthFromConfig() + log('info', `User "${session.u}" removed client "${name}"`) + res.writeHead(204) + res.end() + return + } + if (method === 'PATCH') { + let body: Buffer + try { + body = await readBody(req) + } catch { + res.writeHead(413, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Payload too large' })) + return + } + let payload: { cost_limit_usd?: number | null; cost_limit_period?: CostLimitPeriod | null } + try { + payload = JSON.parse(body.toString('utf-8')) + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Invalid JSON' })) + return + } + let updated + try { + updated = setClientLimit(name, { + cost_limit_usd: payload.cost_limit_usd ?? null, + cost_limit_period: payload.cost_limit_period ?? null, + }) + } catch (err) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'failed' })) + return + } + reloadAuthFromConfig() + log('info', `User "${session.u}" updated limit on client "${name}"`) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + name: updated.name, + cost_limit_usd: updated.cost_limit_usd ?? null, + cost_limit_period: updated.cost_limit_period ?? null, + })) + return + } + res.writeHead(405, { Allow: 'DELETE, PATCH' }) + res.end() + return + } +} diff --git a/src/scripts/add-user.ts b/src/scripts/add-user.ts new file mode 100644 index 0000000..573af19 --- /dev/null +++ b/src/scripts/add-user.ts @@ -0,0 +1,88 @@ +import { loadConfig } from '../config.js' +import { initDb } from '../db.js' +import { createUser, findUser } from '../users.js' +import { stdin, stdout } from 'process' + +async function promptPassword(question: string): Promise { + return new Promise((resolve) => { + process.stdout.write(question) + let buf = '' + const wasRaw = stdin.isRaw + const onData = (ch: Buffer) => { + for (const byte of ch) { + if (byte === 0x0d || byte === 0x0a) { + stdin.removeListener('data', onData) + if (stdin.setRawMode) stdin.setRawMode(wasRaw) + stdin.pause() + process.stdout.write('\n') + resolve(buf) + return + } + if (byte === 0x03) { + process.stdout.write('\n') + process.exit(130) + } + if (byte === 0x7f || byte === 0x08) { + buf = buf.slice(0, -1) + continue + } + if (byte >= 0x20) { + buf += String.fromCharCode(byte) + } + } + } + if (stdin.setRawMode) stdin.setRawMode(true) + stdin.resume() + stdin.on('data', onData) + }) +} + +async function main() { + const username = process.argv[2] + if (!username) { + console.error('Usage: npm run add-user [-- ]') + console.error('Or set PASSWORD env var to skip the interactive prompt') + process.exit(1) + } + + const configPath = + process.argv[3] || + process.env.CCG_CONFIG_PATH || + '/app/data/config.yaml' + const config = loadConfig(configPath) + if (!config.db?.path) { + console.error('config: db.path is required') + process.exit(1) + } + + initDb(config.db.path) + + if (findUser(username)) { + console.error(`User "${username}" already exists`) + process.exit(1) + } + + let password = process.env.PASSWORD + if (!password) { + password = await promptPassword(`Password for ${username}: `) + const confirm = await promptPassword('Confirm password: ') + if (password !== confirm) { + console.error('Passwords do not match') + process.exit(1) + } + } + + if (!password || password.length < 8) { + console.error('Password must be at least 8 characters') + process.exit(1) + } + + const user = createUser(username, password) + console.log(`\nCreated user "${user.username}" (id=${user.id})`) + console.log(`Login at: https:///login`) +} + +main().catch((err) => { + console.error(`Error: ${err instanceof Error ? err.message : err}`) + process.exit(1) +}) diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..bce3d69 --- /dev/null +++ b/src/session.ts @@ -0,0 +1,82 @@ +import { createHmac, timingSafeEqual } from 'crypto' +import type { IncomingMessage } from 'http' +import { getSessionSecret } from './db.js' + +const COOKIE_NAME = 'ccg_session' +const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + +interface SessionPayload { + u: string // username + e: number // expiry timestamp ms +} + +function b64url(buf: Buffer): string { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function b64urlDecode(s: string): Buffer { + const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4)) + return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64') +} + +function sign(payload: string): string { + return b64url(createHmac('sha256', getSessionSecret()).update(payload).digest()) +} + +export function createSessionCookie(username: string): string { + const payload: SessionPayload = { u: username, e: Date.now() + SESSION_TTL_MS } + const body = b64url(Buffer.from(JSON.stringify(payload), 'utf-8')) + const sig = sign(body) + return `${body}.${sig}` +} + +export function verifySessionToken(token: string): SessionPayload | null { + const dot = token.indexOf('.') + if (dot === -1) return null + const body = token.slice(0, dot) + const sig = token.slice(dot + 1) + const expected = sign(body) + const sigBuf = Buffer.from(sig) + const expectedBuf = Buffer.from(expected) + if (sigBuf.length !== expectedBuf.length) return null + if (!timingSafeEqual(sigBuf, expectedBuf)) return null + try { + const payload = JSON.parse(b64urlDecode(body).toString('utf-8')) as SessionPayload + if (typeof payload.u !== 'string' || typeof payload.e !== 'number') return null + if (payload.e < Date.now()) return null + return payload + } catch { + return null + } +} + +export function getSessionFromRequest(req: IncomingMessage): SessionPayload | null { + const cookieHeader = req.headers.cookie + if (!cookieHeader) return null + const parts = cookieHeader.split(';').map(s => s.trim()) + for (const part of parts) { + const eq = part.indexOf('=') + if (eq === -1) continue + const name = part.slice(0, eq) + if (name !== COOKIE_NAME) continue + const value = part.slice(eq + 1) + return verifySessionToken(value) + } + return null +} + +export function setCookieHeader(token: string, secure: boolean): string { + const attrs = [ + `${COOKIE_NAME}=${token}`, + 'HttpOnly', + 'SameSite=Lax', + 'Path=/', + `Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`, + ] + if (secure) attrs.push('Secure') + return attrs.join('; ') +} + +export function clearCookieHeader(): string { + return `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0` +} diff --git a/src/usage-parser.ts b/src/usage-parser.ts new file mode 100644 index 0000000..5ea9b91 --- /dev/null +++ b/src/usage-parser.ts @@ -0,0 +1,149 @@ +/** + * Streaming SSE parser for Anthropic /v1/messages responses. + * + * Anthropic streams events like: + * event: message_start + * data: {"type":"message_start","message":{"id":"...","model":"claude-...","usage":{"input_tokens":N,...}}} + * + * event: content_block_delta + * data: {"type":"content_block_delta",...} + * + * event: message_delta + * data: {"type":"message_delta","usage":{"output_tokens":N}} + * + * event: message_stop + * data: {"type":"message_stop"} + * + * `message_start` carries the model id and initial usage (input + cache fields). + * `message_delta` is emitted just before stop and carries the final output_tokens. + * + * Non-streaming responses (rare for claude-code) return a single JSON body + * with `usage` at the root — handle both. + */ +export interface ParsedUsage { + model: string + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheCreationTokens: number +} + +export class SSEUsageParser { + private buffer = '' + private model = '' + private inputTokens = 0 + private outputTokens = 0 + private cacheReadTokens = 0 + private cacheCreationTokens = 0 + private isStreaming = false + + feed(chunk: Buffer | string): void { + this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf-8') + + // Detect SSE format on first useful bytes + if (!this.isStreaming) { + const trimmed = this.buffer.trimStart() + if (trimmed.startsWith('event:') || trimmed.startsWith('data:')) { + this.isStreaming = true + } + } + + if (this.isStreaming) { + this.flushSseEvents() + } + } + + /** Call when the response stream has ended. */ + end(): void { + if (this.isStreaming) { + // Drain any partial event left in buffer (shouldn't usually happen) + this.flushSseEvents() + return + } + // Non-streaming path: buffer holds a single JSON document + const text = this.buffer.trim() + if (!text) return + try { + const obj = JSON.parse(text) + this.absorbMessageObject(obj) + } catch { + // body wasn't JSON or got truncated — just leave usage at zero + } + } + + result(): ParsedUsage { + return { + model: this.model, + inputTokens: this.inputTokens, + outputTokens: this.outputTokens, + cacheReadTokens: this.cacheReadTokens, + cacheCreationTokens: this.cacheCreationTokens, + } + } + + // ── internals ────────────────────────────────────────────────────────── + + private flushSseEvents(): void { + let idx + while ((idx = this.buffer.indexOf('\n\n')) !== -1) { + const block = this.buffer.slice(0, idx) + this.buffer = this.buffer.slice(idx + 2) + this.parseEventBlock(block) + } + // Some servers use \r\n\r\n + while ((idx = this.buffer.indexOf('\r\n\r\n')) !== -1) { + const block = this.buffer.slice(0, idx) + this.buffer = this.buffer.slice(idx + 4) + this.parseEventBlock(block) + } + } + + private parseEventBlock(block: string): void { + // Block is one or more lines like "event: foo" / "data: {...}" + const lines = block.split(/\r?\n/) + let dataPayload = '' + for (const line of lines) { + if (line.startsWith('data:')) { + // Per SSE spec, multiple data: lines join with \n + dataPayload += (dataPayload ? '\n' : '') + line.slice(5).trimStart() + } + } + if (!dataPayload || dataPayload === '[DONE]') return + try { + const obj = JSON.parse(dataPayload) + this.absorbStreamingEvent(obj) + } catch { + // ignore malformed + } + } + + private absorbStreamingEvent(obj: any): void { + if (!obj || typeof obj !== 'object') return + if (obj.type === 'message_start' && obj.message) { + if (typeof obj.message.model === 'string') this.model = obj.message.model + this.absorbUsage(obj.message.usage) + } else if (obj.type === 'message_delta') { + this.absorbUsage(obj.usage) + } + } + + private absorbMessageObject(obj: any): void { + if (!obj || typeof obj !== 'object') return + if (typeof obj.model === 'string') this.model = obj.model + this.absorbUsage(obj.usage) + } + + private absorbUsage(u: any): void { + if (!u || typeof u !== 'object') return + // message_delta only sends fields that changed — take max so we keep the + // largest value seen for each counter (handles partial updates safely). + if (typeof u.input_tokens === 'number') + this.inputTokens = Math.max(this.inputTokens, u.input_tokens) + if (typeof u.output_tokens === 'number') + this.outputTokens = Math.max(this.outputTokens, u.output_tokens) + if (typeof u.cache_read_input_tokens === 'number') + this.cacheReadTokens = Math.max(this.cacheReadTokens, u.cache_read_input_tokens) + if (typeof u.cache_creation_input_tokens === 'number') + this.cacheCreationTokens = Math.max(this.cacheCreationTokens, u.cache_creation_input_tokens) + } +} diff --git a/src/users.ts b/src/users.ts new file mode 100644 index 0000000..bc5e77a --- /dev/null +++ b/src/users.ts @@ -0,0 +1,70 @@ +import { randomBytes, scryptSync, timingSafeEqual } from 'crypto' +import { getDb } from './db.js' + +const SCRYPT_N = 16384 +const SCRYPT_r = 8 +const SCRYPT_p = 1 +const KEY_LEN = 64 +const SALT_LEN = 16 + +export interface User { + id: number + username: string + password_hash: string + created_at: number +} + +function hashPassword(password: string): string { + const salt = randomBytes(SALT_LEN) + const derived = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_r, p: SCRYPT_p }) + return `scrypt$${SCRYPT_N}$${SCRYPT_r}$${SCRYPT_p}$${salt.toString('hex')}$${derived.toString('hex')}` +} + +function verifyPassword(password: string, stored: string): boolean { + const parts = stored.split('$') + if (parts.length !== 6 || parts[0] !== 'scrypt') return false + const N = parseInt(parts[1], 10) + const r = parseInt(parts[2], 10) + const p = parseInt(parts[3], 10) + const salt = Buffer.from(parts[4], 'hex') + const expected = Buffer.from(parts[5], 'hex') + const derived = scryptSync(password, salt, expected.length, { N, r, p }) + if (derived.length !== expected.length) return false + return timingSafeEqual(derived, expected) +} + +export function createUser(username: string, password: string): User { + if (!/^[a-zA-Z0-9_.-]{3,32}$/.test(username)) { + throw new Error('username must be 3-32 chars, [a-zA-Z0-9_.-]') + } + if (password.length < 8) { + throw new Error('password must be at least 8 characters') + } + const db = getDb() + const hash = hashPassword(password) + const now = Date.now() + const result = db + .prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)') + .run(username, hash, now) + return { id: Number(result.lastInsertRowid), username, password_hash: hash, created_at: now } +} + +export function findUser(username: string): User | null { + const row = getDb().prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined + return row ?? null +} + +export function authenticateUser(username: string, password: string): User | null { + const user = findUser(username) + if (!user) { + // Constant-time-ish: still run a hash to avoid leaking existence via timing + scryptSync(password, randomBytes(SALT_LEN), KEY_LEN, { N: SCRYPT_N, r: SCRYPT_r, p: SCRYPT_p }) + return null + } + return verifyPassword(password, user.password_hash) ? user : null +} + +export function countUsers(): number { + const row = getDb().prepare('SELECT COUNT(*) as c FROM users').get() as { c: number } + return row.c +}