From 4de86d40796a087f0b750dc323330160901c06ec Mon Sep 17 00:00:00 2001 From: root Date: Tue, 5 May 2026 11:59:52 +0200 Subject: [PATCH 01/65] feat: hot-reload auth.tokens on config.yaml change Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/add-client.sh | 2 +- src/index.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/add-client.sh b/scripts/add-client.sh index a056dc1..e6d3ed3 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 diff --git a/src/index.ts b/src/index.ts index 13e822b..1647cc3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,10 @@ +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 { startProxy } from './proxy.js' +import { initAuth } from './auth.js' const configPath = process.argv[2] @@ -15,6 +18,22 @@ try { await initOAuth(config.oauth) startProxy(config) + + // Hot-reload auth.tokens on config changes (poll-based — works with bind mounts) + const watchPath = resolve(configPath || 'config.yaml') + 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) From 75cf6555203beabe00f8ac09cac5495e6bebd8b1 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 5 May 2026 11:59:57 +0200 Subject: [PATCH 02/65] chore: deploy via Traefik/Coolify reverse proxy Co-Authored-By: Claude Opus 4.7 (1M context) --- docker-compose.yml | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index e9c4d52..06d6ce1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,13 @@ services: gateway: build: . - ports: - - "8443:8443" + expose: + - "8443" volumes: - ./config.yaml:/app/config.yaml:ro - - ./certs:/app/certs: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 +18,20 @@ services: options: max-size: "10m" max-file: "3" + labels: + - "traefik.enable=true" + - "traefik.docker.network=coolify" + - "traefik.http.routers.ccg.rule=Host(`ccg.toolvn.io.vn`)" + - "traefik.http.routers.ccg.entrypoints=https" + - "traefik.http.routers.ccg.tls=true" + - "traefik.http.routers.ccg.tls.certresolver=letsencrypt" + - "traefik.http.services.ccg.loadbalancer.server.port=8443" + - "traefik.http.routers.ccg-http.rule=Host(`ccg.toolvn.io.vn`)" + - "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 From 1c0f54c331ae8dc8cb39135dd535a480045ca9aa Mon Sep 17 00:00:00 2001 From: vuluu Date: Wed, 6 May 2026 09:03:52 +0700 Subject: [PATCH 03/65] feat: dashboard with login, request metrics, in-app client management - SQLite-backed dashboard with scrypt password auth and HMAC session cookies - Per-client request metrics (rate, status, duration) persisted across restarts - Add/remove clients from the dashboard, launcher script auto-downloaded - Coolify-friendly compose using SERVICE_FQDN_GATEWAY magic variable - npm run add-user CLI for creating dashboard accounts Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + Dockerfile | 5 + config.example.yaml | 6 + docker-compose.yml | 21 +- package-lock.json | 465 ++++++++++++++++++++++++++++++++++++++- package.json | 3 + src/auth.ts | 16 +- src/clients.ts | 216 ++++++++++++++++++ src/config.ts | 3 + src/dashboard.ts | 477 ++++++++++++++++++++++++++++++++++++++++ src/db.ts | 66 ++++++ src/index.ts | 11 + src/metrics.ts | 203 +++++++++++++++++ src/proxy.ts | 274 ++++++++++++++++++++++- src/scripts/add-user.ts | 85 +++++++ src/session.ts | 82 +++++++ src/users.ts | 70 ++++++ 17 files changed, 1987 insertions(+), 17 deletions(-) create mode 100644 src/clients.ts create mode 100644 src/dashboard.ts create mode 100644 src/db.ts create mode 100644 src/metrics.ts create mode 100644 src/scripts/add-user.ts create mode 100644 src/session.ts create mode 100644 src/users.ts 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..c112d24 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,7 @@ 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"] 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 06d6ce1..c36a5f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,18 @@ services: gateway: build: . + # 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 + # Mounted read-write so the dashboard can append/remove client tokens. + # Hot-reload via watchFile picks up edits within ~2s. + - ./config.yaml:/app/config.yaml + - ccg_data:/app/data restart: unless-stopped networks: - coolify @@ -21,12 +29,14 @@ services: labels: - "traefik.enable=true" - "traefik.docker.network=coolify" - - "traefik.http.routers.ccg.rule=Host(`ccg.toolvn.io.vn`)" + - "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" - - "traefik.http.services.ccg.loadbalancer.server.port=8443" - - "traefik.http.routers.ccg-http.rule=Host(`ccg.toolvn.io.vn`)" + # 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" @@ -35,3 +45,6 @@ services: 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..9425e0f 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,16 @@ "dev": "tsx watch src/index.ts", "generate-token": "tsx src/scripts/generate-token.ts", "generate-identity": "tsx src/scripts/generate-identity.ts", + "add-user": "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/src/auth.ts b/src/auth.ts index 7f47de8..7d8c066 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -22,13 +22,15 @@ export function authenticate(req: IncomingMessage): string | null { if (entry) return entry.name } - // 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.name + } + } - const entry = tokenMap.get(match[1]) - return entry?.name ?? null + return null } diff --git a/src/clients.ts b/src/clients.ts new file mode 100644 index 0000000..04d9ec9 --- /dev/null +++ b/src/clients.ts @@ -0,0 +1,216 @@ +import { readFileSync, writeFileSync } from 'fs' +import { resolve } from 'path' +import { randomBytes } from 'crypto' +import { parseDocument, YAMLSeq, YAMLMap } from 'yaml' + +export interface ClientEntry { + name: string + token: string +} + +const NAME_RE = /^[a-zA-Z0-9_.-]{1,64}$/ + +function configPath(): string { + return resolve(process.argv[2] || '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 +} + +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 name = item.get('name') + const token = item.get('token') + if (typeof name === 'string' && typeof token === 'string') { + out.push({ name, token }) + } + } + } + return out +} + +export function addClient(name: string): 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) + tokens.add(entry) + + writeFileSync(path, doc.toString(), 'utf-8') + return { name, token } +} + +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 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 — ${opts.name} +# +# 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} native Run native claude (bypass gateway) + +GATEWAY_URL="${opts.scheme}://${opts.gatewayAddr}" +CLIENT_TOKEN="${opts.token}" +${tlsBypass} +INSTALL_PATH="/usr/local/bin/ccg" +SELF_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" +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" + +case "$1" in + install) + cp "$0" "$INSTALL_PATH" 2>/dev/null || sudo cp "$0" "$INSTALL_PATH" + chmod +x "$INSTALL_PATH" + echo "Installed as 'ccg'." + 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 + echo "Removed." + 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. Reopen terminal or: source $RC_FILE" + 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 claude 2>/dev/null + echo "Done." + else + echo "Nothing to undo." + fi + exit 0 + ;; + native) + shift + exec command claude "$@" + ;; + status) + echo "Gateway: $GATEWAY_URL" + if grep -q "$ALIAS_TAG" "$RC_FILE" 2>/dev/null; then + echo "Hijack: ON" + else + echo "Hijack: OFF" + fi + HEALTH=$(curl -sk --max-time 3 "\${GATEWAY_URL}/_health" 2>/dev/null) + [[ -n "$HEALTH" ]] && echo "Health: OK" || echo "Health: UNREACHABLE" + exit 0 + ;; + help|--help|-h) + echo "ccg — Claude Code Gateway Client" + echo "" + echo " ccg Start Claude Code through gateway" + echo " ccg install Install as 'ccg' system command" + echo " ccg uninstall Remove 'ccg'" + echo " ccg hijack Make 'claude' go through gateway" + echo " ccg release Restore native 'claude'" + echo " ccg native [args] Run native claude once" + echo " ccg status Show gateway status" + exit 0 + ;; +esac + +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 + +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 + +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 "" +fi + +exec claude "$@" +` +} diff --git a/src/config.ts b/src/config.ts index 3226203..5e82706 100644 --- a/src/config.ts +++ b/src/config.ts @@ -48,6 +48,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..6effd51 --- /dev/null +++ b/src/dashboard.ts @@ -0,0 +1,477 @@ +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 + + + + +
+

CC Gateway · Request Dashboard

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

Requests over time (per client)

+
+
+
+

+ Clients + +

+
+
+
+
+

Recent requests

+
+
+
+
+ + + + +` +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..b9cb468 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,66 @@ +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); + `) +} + +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 1647cc3..f42a6f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,9 @@ import { setLogLevel, log } from './logger.js' import { initOAuth } 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' const configPath = process.argv[2] @@ -14,6 +17,14 @@ try { log('info', 'CC Gateway starting...') + 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) diff --git a/src/metrics.ts b/src/metrics.ts new file mode 100644 index 0000000..4b0f90b --- /dev/null +++ b/src/metrics.ts @@ -0,0 +1,203 @@ +import { getDb } from './db.js' + +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 +} + +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) VALUES (?, ?, ?, ?, ?, ?)', + ) + .run(rec.ts, rec.client, rec.method, rec.path, rec.status, rec.durationMs) + } catch (err) { + // Don't break proxying on metrics failures + } +} + +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 +} + +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 + FROM request_metrics`, + ) + .get() as { total: number; errors: number | null } + + const totals = { + total: totalsRow.total || 0, + errors: totalsRow.errors || 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 + FROM request_metrics + GROUP BY client + ORDER BY total DESC`, + ) + .all() as ClientRow[] + + 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, + } + }) + + 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 + FROM request_metrics + ORDER BY ts DESC + LIMIT ?`, + ) + .all(RECENT_LIMIT) as RequestRecord[] + + return { + now, + uptimeMs: now - startedAt, + totals, + clients, + minuteSeries, + hourSeries, + recent, + } +} diff --git a/src/proxy.ts b/src/proxy.ts index 03d54b3..bb63e5a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -9,6 +9,16 @@ 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 } from './metrics.js' +import { renderDashboard, renderLogin } from './dashboard.js' +import { authenticateUser } from './users.js' +import { + createSessionCookie, + setCookieHeader, + clearCookieHeader, + getSessionFromRequest, +} from './session.js' +import { addClient, listClients, removeClient, buildLauncherScript } from './clients.js' export function startProxy(config: Config) { initAuth(config) @@ -50,12 +60,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,8 +82,22 @@ 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') { + if (pathname === '/_verify') { const clientName = authenticate(req) if (!clientName) { res.writeHead(401, { 'Content-Type': 'application/json' }) @@ -156,9 +182,24 @@ async function handleRequest( // Stream response directly (SSE for Claude responses) proxyRes.pipe(res) - if (config.logging.audit) { - audit(clientName, method, path, status) + let finalized = false + const finalize = () => { + if (finalized) return + finalized = true + recordRequest({ + ts: startedAt, + client: clientName, + method, + path: pathname, + status, + durationMs: Date.now() - startedAt, + }) + if (config.logging.audit) { + audit(clientName, method, path, status) + } } + proxyRes.on('end', finalize) + proxyRes.on('close', finalize) }, ) @@ -168,6 +209,14 @@ 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, + }) if (config.logging.audit) { audit(clientName, method, path, 502) } @@ -224,3 +273,220 @@ 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) => ({ + name: c.name, + token_preview: c.token.slice(0, 8) + '…' + c.token.slice(-4), + })) + 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 } + 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) + } catch (err) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'failed' })) + return + } + log('info', `User "${session.u}" added client "${entry.name}"`) + const script = buildLauncherScript({ + name: entry.name, + token: entry.token, + gatewayAddr, + scheme, + }) + if (payload.format === 'json') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ name: entry.name, token: entry.token, script })) + return + } + res.writeHead(200, { + 'Content-Type': 'application/x-shellscript; charset=utf-8', + 'Content-Disposition': `attachment; filename="cc-${entry.name}"`, + 'X-Client-Token': entry.token, + }) + res.end(script) + return + } + res.writeHead(405, { Allow: 'GET, POST' }) + res.end() + return + } + + if (pathname.startsWith('/api/clients/')) { + const name = decodeURIComponent(pathname.slice('/api/clients/'.length)) + 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 + } + log('info', `User "${session.u}" removed client "${name}"`) + res.writeHead(204) + res.end() + return + } + res.writeHead(405, { Allow: 'DELETE' }) + res.end() + return + } +} diff --git a/src/scripts/add-user.ts b/src/scripts/add-user.ts new file mode 100644 index 0000000..592fc84 --- /dev/null +++ b/src/scripts/add-user.ts @@ -0,0 +1,85 @@ +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] + 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/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 +} From b4f0306a30e4adb775f4bf97c2946e2a2505dffa Mon Sep 17 00:00:00 2001 From: vuluu Date: Wed, 6 May 2026 09:12:23 +0700 Subject: [PATCH 04/65] fix: drop bind mount for config.yaml so Coolify File Mount supplies it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind mount './config.yaml:/app/config.yaml' caused EISDIR on Coolify because config.yaml is gitignored — Docker created an empty directory at the missing source path. Provide the file via Coolify's Storage tab File Mount instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- docker-compose.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c36a5f5..512d5ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,9 +9,11 @@ services: expose: - "8443" volumes: - # Mounted read-write so the dashboard can append/remove client tokens. - # Hot-reload via watchFile picks up edits within ~2s. - - ./config.yaml:/app/config.yaml + # config.yaml is provided via Coolify's "File Mount" (Storage tab) so the + # secrets stay out of git. The dashboard can append/remove client tokens + # at runtime; watchFile picks up edits within ~2s. + # For local dev, copy config.example.yaml → config.yaml and uncomment: + # - ./config.yaml:/app/config.yaml - ccg_data:/app/data restart: unless-stopped networks: From 7e36fc8a101940b7ceb4bb4421ac847fd3b97dc4 Mon Sep 17 00:00:00 2001 From: vuluu Date: Wed, 6 May 2026 09:20:05 +0700 Subject: [PATCH 05/65] =?UTF-8?q?feat:=20add=20gen-config.sh=20=E2=80=94?= =?UTF-8?q?=20generate=20config.yaml=20from=20existing=20claude=20login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-friendly variant of quick-setup.sh that prints YAML to stdout or writes directly to a target path (--out), without trying to start the gateway. Supports both macOS Keychain and Linux credentials.json sources, and includes the new db.path field. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/gen-config.sh | 132 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100755 scripts/gen-config.sh 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 From 063f78ebfc745b42738dfbe3bcaf3c7d0a459ddb Mon Sep 17 00:00:00 2001 From: vuluu Date: Wed, 6 May 2026 09:23:17 +0700 Subject: [PATCH 06/65] feat: auto-bootstrap config.yaml on first start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Container now generates config.yaml on first start if missing, sourcing the OAuth refresh token from CCG_REFRESH_TOKEN env or a mounted credentials.json (CCG_CREDENTIALS_PATH or default /app/data/...). The file lands in the persistent ccg_data volume so device_id and tokens survive restarts. Coolify deploy now needs only: an env var or credentials mount — no more manual File Mount setup, no more EISDIR/ENOENT. Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 6 +- docker-compose.yml | 11 +-- src/bootstrap-config.ts | 163 ++++++++++++++++++++++++++++++++++++++++ src/clients.ts | 6 +- src/index.ts | 15 +++- src/scripts/add-user.ts | 5 +- 6 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 src/bootstrap-config.ts diff --git a/Dockerfile b/Dockerfile index c112d24..8833e33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,4 +18,8 @@ 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/docker-compose.yml b/docker-compose.yml index 512d5ae..525935e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,12 +9,13 @@ services: expose: - "8443" volumes: - # config.yaml is provided via Coolify's "File Mount" (Storage tab) so the - # secrets stay out of git. The dashboard can append/remove client tokens - # at runtime; watchFile picks up edits within ~2s. - # For local dev, copy config.example.yaml → config.yaml and uncomment: - # - ./config.yaml:/app/config.yaml + # ccg_data persists both the SQLite DB and the auto-generated config.yaml + # (so device_id + tokens survive restarts/redeploys). - ccg_data:/app/data + # Optional: mount your existing claude credentials so the gateway can + # bootstrap config.yaml on first start without setting CCG_REFRESH_TOKEN. + # Uncomment + adjust the host path: + # - /root/.claude/.credentials.json:/app/data/claude-credentials.json:ro restart: unless-stopped networks: - coolify diff --git a/src/bootstrap-config.ts b/src/bootstrap-config.ts new file mode 100644 index 0000000..2ac3537 --- /dev/null +++ b/src/bootstrap-config.ts @@ -0,0 +1,163 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs' +import { dirname, resolve } from 'path' +import { randomBytes } from 'crypto' +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 +} diff --git a/src/clients.ts b/src/clients.ts index 04d9ec9..f011b8e 100644 --- a/src/clients.ts +++ b/src/clients.ts @@ -11,7 +11,11 @@ export interface ClientEntry { const NAME_RE = /^[a-zA-Z0-9_.-]{1,64}$/ function configPath(): string { - return resolve(process.argv[2] || 'config.yaml') + return resolve( + process.argv[2] || + process.env.CCG_CONFIG_PATH || + '/app/data/config.yaml', + ) } function loadDoc(path: string) { diff --git a/src/index.ts b/src/index.ts index f42a6f5..4a02ee7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,14 +8,25 @@ import { initAuth } from './auth.js' import { initDb } from './db.js' import { initMetrics } from './metrics.js' import { countUsers } from './users.js' +import { bootstrapConfigIfMissing } 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) + } + const config = loadConfig(configPath) setLogLevel(config.logging.level) log('info', 'CC Gateway starting...') + log('info', `Config: ${resolve(configPath)}`) const dbPath = config.db?.path || './data/ccg.db' initDb(dbPath) @@ -31,7 +42,7 @@ try { startProxy(config) // Hot-reload auth.tokens on config changes (poll-based — works with bind mounts) - const watchPath = resolve(configPath || 'config.yaml') + const watchPath = resolve(configPath) let lastTokenSig = JSON.stringify(config.auth.tokens) watchFile(watchPath, { interval: 2000 }, () => { try { diff --git a/src/scripts/add-user.ts b/src/scripts/add-user.ts index 592fc84..573af19 100644 --- a/src/scripts/add-user.ts +++ b/src/scripts/add-user.ts @@ -45,7 +45,10 @@ async function main() { process.exit(1) } - const configPath = process.argv[3] + 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') From 41d83cbc9da268bf88f4d62ccdc75d55e5715a77 Mon Sep 17 00:00:00 2001 From: vuluu Date: Wed, 6 May 2026 09:31:20 +0700 Subject: [PATCH 07/65] chore: enable host credentials mount for Coolify auto-bootstrap Maps /root/.claude/.credentials.json (read-only) into the container so the first-start bootstrap can read the OAuth refresh token without any env var setup. Co-Authored-By: Claude Opus 4.7 (1M context) --- docker-compose.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 525935e..563d52b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,10 +12,9 @@ services: # ccg_data persists both the SQLite DB and the auto-generated config.yaml # (so device_id + tokens survive restarts/redeploys). - ccg_data:/app/data - # Optional: mount your existing claude credentials so the gateway can - # bootstrap config.yaml on first start without setting CCG_REFRESH_TOKEN. - # Uncomment + adjust the host path: - # - /root/.claude/.credentials.json:/app/data/claude-credentials.json:ro + # 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 From f22386e68a39e849961c4875f825968c8e4a56ce Mon Sep 17 00:00:00 2001 From: vuluu Date: Wed, 6 May 2026 09:40:37 +0700 Subject: [PATCH 08/65] fix: persist rotated OAuth tokens to config.yaml so restarts survive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic rotates the refresh_token on every refresh — the previous token is invalidated immediately. Before this change, the gateway only held the new token in memory; on container restart it would replay the already-consumed token from disk and crash with invalid_grant. Two fixes: - After every successful refresh, write access_token / refresh_token / expires_at back into config.yaml via yaml.parseDocument round-trip. - On startup, if a mounted credentials.json has a different refresh token than the one in config.yaml (host did its own claude login and rotated), copy it across before initOAuth runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bootstrap-config.ts | 67 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 20 ++++++++++-- src/oauth.ts | 16 ++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/bootstrap-config.ts b/src/bootstrap-config.ts index 2ac3537..2688f17 100644 --- a/src/bootstrap-config.ts +++ b/src/bootstrap-config.ts @@ -1,6 +1,7 @@ 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 { @@ -161,3 +162,69 @@ export function bootstrapConfigIfMissing(configPath: string): boolean { 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 credentials file is plausibly newer (different token). + * No-op if no credentials file is mounted, or tokens already match. + */ +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 + 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 + } catch { + return + } + + if (currentRefresh && currentRefresh === creds.refreshToken) return + + log('info', 'Mounted credentials have a different refresh_token — syncing into config.yaml') + updateConfigOAuth(absPath, { + accessToken: creds.accessToken || '', + refreshToken: creds.refreshToken, + expiresAt: creds.expiresAt || 0, + }) +} diff --git a/src/index.ts b/src/index.ts index 4a02ee7..5be6d62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,13 +2,17 @@ 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 } from './bootstrap-config.js' +import { + bootstrapConfigIfMissing, + syncOAuthFromCredentialsIfChanged, + updateConfigOAuth, +} from './bootstrap-config.js' // 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. @@ -22,12 +26,24 @@ try { 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() 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) { From 497f46ff3b5506ea34881e4f8a1170b047f20683 Mon Sep 17 00:00:00 2001 From: vuluu Date: Wed, 6 May 2026 09:53:14 +0700 Subject: [PATCH 09/65] fix: forward OAuth token via Authorization: Bearer + anthropic-beta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic's API rejects OAuth access tokens (sk-ant-oat01-) when sent through the x-api-key header — that header is for static API keys (sk-ant-api03-) only. The previous code's comment was wrong; the actual upstream behaviour is 401 "Invalid authentication credentials". Switch to Authorization: Bearer and ensure the request carries anthropic-beta: oauth-2025-04-20 (merging with any client-provided beta flags rather than overwriting). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/proxy.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index bb63e5a..24dac4c 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -152,9 +152,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) From 02e1504d8d9f037fc340ece00a4c9273d7bcc9b2 Mon Sep 17 00:00:00 2001 From: vuluu Date: Wed, 6 May 2026 10:00:12 +0700 Subject: [PATCH 10/65] fix: add-user npm script uses compiled JS so it runs inside the prod image The Docker runtime image only contains dist/ (no src/), so 'tsx src/scripts/add-user.ts' fails with ERR_MODULE_NOT_FOUND. Switch the 'add-user' script to 'node dist/scripts/add-user.js' and add a separate 'add-user:dev' for local TS workflow. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 9425e0f..7f0fb1b 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "dev": "tsx watch src/index.ts", "generate-token": "tsx src/scripts/generate-token.ts", "generate-identity": "tsx src/scripts/generate-identity.ts", - "add-user": "tsx src/scripts/add-user.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": { From ec48076d03fdf4f006ece2fed9ba2a45fed7be9d Mon Sep 17 00:00:00 2001 From: vuluu Date: Wed, 6 May 2026 10:07:13 +0700 Subject: [PATCH 11/65] fix: launcher script matches scripts/add-client.sh output 1:1 Restored the verbose install/uninstall/hijack/release/status/help text that scripts/add-client.sh emits, so the file dashboard generates is byte-equivalent to the bash version (only difference is the header comment substitutes the real client name instead of a placeholder). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/clients.ts | 72 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/src/clients.ts b/src/clients.ts index f011b8e..b10ad36 100644 --- a/src/clients.ts +++ b/src/clients.ts @@ -105,22 +105,25 @@ export interface LauncherOptions { } 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' : '' + 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 — ${opts.name} +# 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} native Run native claude (bypass gateway) - +# ./cc-${opts.name} native Run native claude (bypass gateway, one-time) GATEWAY_URL="${opts.scheme}://${opts.gatewayAddr}" CLIENT_TOKEN="${opts.token}" ${tlsBypass} INSTALL_PATH="/usr/local/bin/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" ;; @@ -129,22 +132,32 @@ case "$SHELL" in esac ALIAS_TAG="# cc-gateway alias" +# ── Subcommands ── + case "$1" in install) cp "$0" "$INSTALL_PATH" 2>/dev/null || sudo cp "$0" "$INSTALL_PATH" chmod +x "$INSTALL_PATH" echo "Installed as 'ccg'." + echo "" + echo " ccg Start Claude Code through gateway" + echo " ccg hijack Make 'claude' also go through gateway" + echo " ccg release Restore 'claude' 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 - echo "Removed." + 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." @@ -154,67 +167,96 @@ case "$1" in else echo "alias claude='ccg' $ALIAS_TAG" >> "$RC_FILE" fi - echo "Done. Reopen terminal or: source $RC_FILE" + 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." + echo "Done. 'claude' is back to native." else - echo "Nothing to undo." + echo "Nothing to undo — 'claude' is already native." fi exit 0 ;; + native) shift exec command claude "$@" ;; + status) echo "Gateway: $GATEWAY_URL" if grep -q "$ALIAS_TAG" "$RC_FILE" 2>/dev/null; then - echo "Hijack: ON" + echo "Hijack: ON (claude → gateway)" else - echo "Hijack: OFF" + echo "Hijack: OFF (claude = native)" fi HEALTH=$(curl -sk --max-time 3 "\${GATEWAY_URL}/_health" 2>/dev/null) - [[ -n "$HEALTH" ]] && echo "Health: OK" || echo "Health: UNREACHABLE" + 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'" + echo " ccg uninstall Remove 'ccg' and clean up" + echo "" + echo "Routing:" echo " ccg hijack Make 'claude' go through gateway" - echo " ccg release Restore native 'claude'" - echo " ccg native [args] Run native claude once" - echo " ccg status Show gateway status" + echo " ccg release Restore 'claude' to native" + echo " ccg native [args] Run native claude once (bypass gateway)" + 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 "$@" ` } From 10827b1b8eaaffe16fe0b1a8d8c66af861f80e49 Mon Sep 17 00:00:00 2001 From: vuluu Date: Wed, 6 May 2026 10:12:28 +0700 Subject: [PATCH 12/65] feat: dashboard explains itself and shows client install commands - Collapsible 'How to use this dashboard' section explains each card (stats / charts / clients / recent) and the post-add flow. - After 'Add client' succeeds, the modal switches to a success view with copy-to-clipboard snippets for both 'chmod +x ... && ./cc-name' and the install variant, instead of closing silently. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dashboard.ts | 124 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 108 insertions(+), 16 deletions(-) diff --git a/src/dashboard.ts b/src/dashboard.ts index 6effd51..f58808b 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -156,6 +156,20 @@ export function renderDashboard(): string { .modal-box input:focus, .modal-box select:focus { outline: none; border-color: var(--accent); } .error { background: rgba(248,81,73,.1); border: 1px solid rgba(248,81,73,.3); color: var(--err); padding: 8px 10px; border-radius: 6px; font-size: 13px; } .config-info { background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; padding: 8px 12px; font-size: 12px; color: var(--muted); font-family: var(--mono); } + .snippet-block { position: relative; } + .snippet-block pre { + background: var(--bg); border: 1px solid var(--border); border-radius: 6px; + padding: 10px 12px; font-family: var(--mono); font-size: 12px; + color: var(--fg); margin: 0; overflow-x: auto; white-space: pre; + } + .copy-btn { + position: absolute; top: 6px; right: 6px; + padding: 4px 10px; font-size: 11px; + background: var(--panel-2); color: var(--fg); + border: 1px solid var(--border); border-radius: 4px; cursor: pointer; + } + .copy-btn:hover { border-color: var(--accent); color: var(--accent); } + .copy-btn.copied { color: var(--ok); border-color: var(--ok); } @@ -176,6 +190,21 @@ export function renderDashboard(): string {
+
+ + How to use this dashboard + +
+

Stats row — totals across the lifetime of this gateway: total requests, active clients (with traffic), errors, and process uptime.

+

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

+

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

+

Recent requests — last 50 requests with status code and duration. Updates every 5 seconds.

+

After downloading cc-<name>, send it to the user. They run:

+
chmod +x cc-<name>
+./cc-<name> install      # install as 'ccg' system-wide (optional)
+./cc-<name>              # or run directly without installing
+
+

Requests over time (per client)

@@ -197,21 +226,44 @@ export function renderDashboard(): string {