diff --git a/README.md b/README.md index a89fe74..bcb65b7 100644 --- a/README.md +++ b/README.md @@ -10,19 +10,19 @@ ## ⚡ Quick Start -推荐架构:代理跑在 VPS,client 只连本地入口;出网、鉴权、目标限制和审计都留在 VPS。 +推荐架构:代理核心跑在 VPS,client 只连本地入口;出网、鉴权、目标限制和审计都留在 VPS。 -### 适合这种场景 +当前有两种 mode: + +- `proxy mode`:标准显式代理用法,适合 `Claude Code/Codex CLI` 和其他支持 `HTTP(S)_PROXY` 的 CLI +- `claude mode`:类似 `cc-gateway` 的集中 OAuth 方案,当前仅支持`Claude Code` + +### 适合场景 - 只想让 `codex`、`claude code` 这类命令走代理,不影响机器上的其他程序 - 想把模型访问统一收口到自己的 VPS,并加上白名单和审计 - 想保留 SSH 隧道接入,但比普通 SSH / SOCKS 多一层出口控制 - -### 不适合这种场景 - -- 只想要通用代理,普通 SSH / SOCKS 通常就够了 -- 需要厂商 API 兼容层、协议转换或 HTTPS MITM -- 需要服务端集群、共享状态或负载均衡 +- 想给 `Claude Code` 增加一个带集中 OAuth 的本地入口,同时尽量不改现有显式代理架构 ### 推荐:🤖 让 LLM 代你部署 @@ -35,7 +35,10 @@ ![Codex Gateway 架构图](./docs/architecture-cyberpunk-zh.svg) -两种接入方式共用同一套拓扑:LLM CLI 只连本地代理入口,VPS 负责转发和控制。 +两条链路共用同一个 VPS 出口控制核心: + +- `proxy mode`:CLI 只看到本地 `HTTP_PROXY` / `HTTPS_PROXY` +- `claude mode`:`Claude Code` 只看到本地 `ANTHROPIC_BASE_URL`,本地 `claude-client` 对接 VPS 上的 Claude OAuth broker,请求再通过现有显式代理出网 ### 1. VPS 上部署服务端 @@ -64,6 +67,13 @@ cp deploy/vps.example.yaml deploy/vps.yaml 如果 `Claude Code` 提示无法连接 `platform.claude.com`,说明你当前部署的白名单里还没有放行 `.claude.com`。把它加入 `runtime.dest_suffix_allowlist` 后重新执行部署。 +如果你要启用 `claude mode` 的集中 OAuth,再额外改: + +- `claude_oauth.enabled: true` +- `claude_oauth.refresh_token` + +启用后,VPS 会在 admin listener 上提供一个 Claude OAuth broker。它集中持有 `refresh_token`,本地 `claude-client` 通过 admin tunnel 拉取短期 `access_token`;这部分用法可以看作是对 `cc-gateway` 集中 OAuth 思路的简化复用。 + 执行部署: ```bash @@ -75,15 +85,62 @@ systemctl --user status codex-gateway.service --no-pager 这会生成 `.env`、`config/users.txt`、二进制和对应的 `systemd` 服务。 -如果你把这个代理给 `codex`、`claude code` 这类会并发开很多 HTTPS 隧道的 CLI 使用,不要把 `runtime.max_conns_per_ip` 设得太低。经验值建议从 `128` 起步;`16` 这类通用代理级别的限制很容易触发 429,然后在客户端重试时表现成“特别慢”。 +如果你把这个代理给 `codex`、`claude code` 这类会并发开很多 HTTPS 隧道的 CLI 使用,不要把 `runtime.max_conns_per_ip` 设得太低。经验值建议从 `128` 起步;`16` 这类通用代理级别的限制很容易触发 `429`,然后在客户端重试时表现成“特别慢”。 + +### 2. Client 端选择 mode + +先复制示例: + +```bash +cp deploy/client.example.yaml deploy/client.yaml +``` + +至少先改: + +- `ssh.user` +- `ssh.host` +- `proxy.password` +- 如果你改了服务端用户名,再把 `proxy.username` 一起改掉 + +然后根据要启用的 mode 选择: + +- `mode: proxy`:旧用法,只生成 tunnel + proxy env + `codex-gateway-proxy` +- `mode: claude`:只生成 Claude rewrite 相关组件 +- `mode: both`:同时生成两套入口 + +`claude mode` 会额外生成: + +- admin tunnel service +- 本地 `codex-gateway claude-client` service +- `claude.env` +- `claude-client.yaml` +- `codex-gateway-claude` wrapper + +如果你要接多台 VPS,`proxy mode` 可以在 `deploy/client.yaml` 里配置 `endpoints` 做 client 侧 failover。`claude mode` 当前只支持单个 endpoint。 + +执行部署: + +```bash +go run ./cmd/codex-gateway deploy client +``` + +如果本机不适合 `systemd --user`,也可以改成 `service_scope: system` 后以 root 安装。 + +如果只想生成文件,不立即 build / restart: + +```bash +go run ./cmd/codex-gateway deploy vps --write-only +go run ./cmd/codex-gateway deploy client --write-only +``` -### 2. Client 端选择一种接入方式 +默认会写到 `~/.config/codex-gateway/`,其中 `claude mode` 相关文件通常是: -两种方式都可以。区别在于:手动管理本地 tunnel 和代理环境变量,还是生成本地脚本。 +- `~/.config/codex-gateway/claude.env` +- `~/.config/codex-gateway/claude-client.yaml` -如果你要接多台 VPS,可在 `deploy/client.yaml` 里配置 `endpoints`。它会为每个入口生成独立 tunnel service;wrapper 默认选首个可用入口,也支持 `--endpoint ` 或 `CODEX_GATEWAY_ENDPOINT=` 指定。这是 client 侧 failover,不是服务端集群。 +### 3. 手动接入 -#### 方式 A:手动打通 tunnel 并设置代理环境变量 +#### 方式 A:手动使用 `proxy mode` 先打通到 VPS 的本地隧道: @@ -102,37 +159,33 @@ export HTTPS_PROXY="$HTTP_PROXY" 这种方式下,直接在当前 shell 启动 client,无需运行 `deploy client`。 -#### 方式 B:生成本地 tunnel + wrapper +#### 方式 B:手动使用 `claude mode` -如果你想把 SSH 隧道、代理环境变量和启动命令固化为本地脚本,就用这一种: +`claude mode` 需要两条本地 tunnel: ```bash -cp deploy/client.example.yaml deploy/client.yaml +ssh -NT \ + -L 127.0.0.1:8080:127.0.0.1:8080 \ + -L 127.0.0.1:19090:127.0.0.1:9090 \ + @ ``` -先改: - -- `ssh.user` -- `ssh.host` -- `proxy.password` 改成与服务端一致 -- 如果你改了用户名,再把 `proxy.username` 一起改掉 - -执行部署: +然后准备 `claude-client.yaml`,或者先用 `deploy client --write-only` 生成一份,再手工启动: ```bash -go run ./cmd/codex-gateway deploy client +codex-gateway claude-client -config ~/.config/codex-gateway/claude-client.yaml ``` -如果本机不适合 `systemd --user`,也可以改成 `service_scope: system` 后以 root 安装。 - -如果只想生成文件,不立即 build / restart: +最后给 `Claude Code` 注入本地入口: ```bash -go run ./cmd/codex-gateway deploy vps --write-only -go run ./cmd/codex-gateway deploy client --write-only +export ANTHROPIC_BASE_URL=http://127.0.0.1:11443 +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +export CLAUDE_CODE_OAUTH_TOKEN=gateway-managed +claude ``` -### 3. 动态更新白名单 +### 4. 动态更新白名单 如果只想热更新白名单、目标端口、源地址白名单或代理认证用户,不想重启代理进程,可以用 `SIGHUP`: @@ -163,13 +216,35 @@ kill -HUP `SIGHUP` 会重新加载 `.env`、`AUTH_USERS_FILE`、`SOURCE_ALLOWLIST_CIDRS`、`DEST_PORT_ALLOWLIST`、`DEST_HOST_ALLOWLIST`、`DEST_SUFFIX_ALLOWLIST` 和 `ALLOW_PRIVATE_DESTINATIONS`。监听地址、TLS、超时、日志、metrics 等非运行时配置仍然需要 restart 才会生效。 -### 4. 开始使用 +### 5. 开始使用 + +按 mode 启动: + +- `proxy mode`:在已经设置代理环境变量的 shell 里直接运行 `codex` +- `proxy mode` wrapper:通过 `~/.local/bin/codex-gateway-proxy codex` +- `proxy mode` 多入口:`~/.local/bin/codex-gateway-proxy --endpoint backup codex` +- `claude mode` wrapper:通过 `~/.local/bin/codex-gateway-claude claude` +- `claude mode` 手工 sidecar:先启动 `codex-gateway claude-client -config ...`,再运行 `claude` -按接入方式启动: +## 🧬 Claude Mode -- 方式 A:在已经设置代理环境变量的 shell 里直接运行 `codex` -- 方式 B:通过本地 wrapper 启动 `~/.local/bin/codex-gateway-proxy codex` -- 多入口指定:`~/.local/bin/codex-gateway-proxy --endpoint backup codex` +这个 mode 是可选的,而且是和现有 `proxy mode` 隔离实现的。旧的显式代理路径不需要改;只有在你明确启用 `claude mode` 时,client 才会多出一个本地 `claude-client`。 + +它的工作方式是: + +- `Claude Code` 请求先到 client 本地 `claude-client` +- `claude-client` 在本地完成 Claude Code 所需的请求 rewrite +- `claude-client` 通过 admin tunnel 从 VPS OAuth broker 拉短期 token +- 真正的上游请求仍然通过本地 proxy tunnel 进入现有 `codex-gateway` 显式代理,再出网到 `api.anthropic.com` + +可以把它理解为:`claude mode` 在不改现有显式代理数据面的前提下,提供了一个类似 `cc-gateway` 的集中 OAuth 入口,并顺带处理 Claude Code 需要的那部分 rewrite。 + +本地 `claude-client` 还提供: + +- `/_health` +- `/_verify` + +如果你已经习惯了现有 `proxy mode`,可以完全不启用这个能力;如果你需要 `Claude Code` 的集中 OAuth,再切到 `mode: claude` 或 `mode: both`。 ## ✨ 核心特性 @@ -178,7 +253,8 @@ kill -HUP - 出口约束:目标 host / suffix / port allowlist - SSRF 防护:DNS 解析后二次校验,默认拒绝私网和保留地址 - 可观测性:JSON 日志、`/healthz`、可选 `/metrics` -- 多入口接入:client 侧多 VPS failover,可按入口切换 +- 多入口接入:`proxy mode` 支持 client 侧多 VPS failover,可按入口切换 +- Claude 专用 mode:本地轻量 reverse proxy、集中 OAuth、请求 rewrite - 部署友好:单二进制、Docker、Compose、YAML 一键部署 ## 🔍 和普通 SSH / SOCKS 代理有什么不同 @@ -190,16 +266,22 @@ kill -HUP - 普通代理通常不做 DNS 结果复检;`codex-gateway` 默认拒绝解析到私网或保留地址的目标 - SSH 登录日志不是代理审计;`codex-gateway` 记录用户名、目标、状态码、字节数和耗时 - 内置 client wrapper,可让只有 `codex` 这类指定命令走代理;无需全局设置代理 +- 对 `Claude Code`,还可以额外加一层本地 `claude-client`,把集中 OAuth 收进可控链路 -换句话说:SSH 解决“怎么到 VPS”;`codex-gateway` 解决“放行什么、如何审计”。 +换句话说:SSH 解决“怎么到 VPS”;`codex-gateway` 解决“放行什么、如何审计”;`claude mode` 进一步补上了 `Claude Code` 的集中 OAuth。 ## 🧭 设计原则 -- 这是显式代理,不是厂商 API Gateway +- `proxy mode` 是显式代理,不是通用厂商 API Gateway - 默认监听 `127.0.0.1`,推荐通过 SSH / WireGuard / 私网入口访问 -- 不改写协议,不托管上游 API Key,不做 HTTPS MITM +- 不做 HTTPS MITM +- `claude mode` 只对 `Claude Code` 做本地 reverse proxy 和 rewrite,VPS 只集中管理 OAuth refresh token - 默认配置保守,先最小放行,再按需扩容 +## 🙏 引用与致谢 + +`claude mode` 的集中 OAuth 设计参考了 [`motiful/cc-gateway`](https://github.com/motiful/cc-gateway)。这里的实现保持了 `codex-gateway` 原有的显式代理数据面,只把 Claude 相关入口做成可选的本地 sidecar。 + ## ⚙️ 完整配置 - 环境变量方式:[.env.example](./.env.example) diff --git a/cmd/codex-gateway/main.go b/cmd/codex-gateway/main.go index 7e3780d..9a52dc5 100644 --- a/cmd/codex-gateway/main.go +++ b/cmd/codex-gateway/main.go @@ -1,6 +1,8 @@ package main import ( + "codex-gateway/internal/claudeclient" + "codex-gateway/internal/claudeoauth" "context" "errors" "fmt" @@ -34,6 +36,12 @@ func run(args []string) int { return 1 } return 0 + case "claude-client": + if err := claudeclient.Run(args[1:], os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 case "version", "--version", "-version": fmt.Fprintln(os.Stdout, version.Version) return 0 @@ -42,6 +50,7 @@ func run(args []string) int { fmt.Fprintln(os.Stdout, " codex-gateway") fmt.Fprintln(os.Stdout, " codex-gateway deploy vps [-config deploy/vps.yaml] [--write-only]") fmt.Fprintln(os.Stdout, " codex-gateway deploy client [-config deploy/client.yaml] [--write-only]") + fmt.Fprintln(os.Stdout, " codex-gateway claude-client [-config claude-client.yaml]") return 0 } } @@ -60,6 +69,20 @@ func run(args []string) int { } metrics := proxy.NewMetrics() + var broker *claudeoauth.Broker + if cfg.ClaudeOAuthEnabled { + broker, err = claudeoauth.New(claudeoauth.Config{ + Enabled: cfg.ClaudeOAuthEnabled, + RefreshToken: cfg.ClaudeOAuthRefreshToken, + ClientID: cfg.ClaudeOAuthClientID, + Scopes: cfg.ClaudeOAuthScopes, + TokenURL: cfg.ClaudeOAuthTokenURL, + }) + if err != nil { + slog.Error("initialize claude oauth broker failed", "error", err.Error()) + return 1 + } + } handler := proxy.NewHandler(proxy.Options{ AppLogger: loggers.App, @@ -76,6 +99,7 @@ func run(args []string) int { IdleTimeout: cfg.ServerIdleTimeout, TunnelIdleTimeout: cfg.TunnelIdleTimeout, }) + adminRuntime := admin.NewRuntime(runtimeConfig.AuthStore, broker) proxyServer := &http.Server{ Addr: cfg.ProxyListenAddress(), @@ -87,7 +111,7 @@ func run(args []string) int { adminServer := &http.Server{ Addr: cfg.AdminListenAddress(), - Handler: admin.NewHandler(admin.Options{MetricsEnabled: cfg.MetricsEnabled, Metrics: metrics, Version: version.Version}), + Handler: admin.NewHandler(admin.Options{MetricsEnabled: cfg.MetricsEnabled, Metrics: metrics, Version: version.Version, Runtime: adminRuntime}), ReadHeaderTimeout: cfg.ServerReadHeaderTimeout, IdleTimeout: cfg.ServerIdleTimeout, MaxHeaderBytes: cfg.MaxHeaderBytes, @@ -103,6 +127,9 @@ func run(args []string) int { go serveProxy(loggers.App, proxyServer, cfg, errCh) go serveHTTP(loggers.App, "admin", adminServer, errCh) + if broker != nil { + go warmBroker(loggers.App, broker) + } for { select { @@ -117,6 +144,7 @@ func run(args []string) int { } handler.UpdateRuntime(nextRuntime) + adminRuntime.UpdateAuthStore(nextRuntime.AuthStore) if fields := changedReloadableFields(cfg, nextCfg); len(fields) > 0 { loggers.App.Info("config reload applied", @@ -160,6 +188,19 @@ shutdown: return 0 } +func warmBroker(logger *slog.Logger, broker *claudeoauth.Broker) { + if broker == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := broker.Warmup(ctx); err != nil { + logger.Warn("claude oauth broker warmup failed", "error", err.Error()) + return + } + logger.Info("claude oauth broker ready") +} + func serveProxy(logger *slog.Logger, server *http.Server, cfg config.Config, errCh chan<- error) { logger.Info("proxy server starting", "addr", server.Addr, diff --git a/cmd/codex-gateway/runtime_loader.go b/cmd/codex-gateway/runtime_loader.go index 2392cac..7ebaef3 100644 --- a/cmd/codex-gateway/runtime_loader.go +++ b/cmd/codex-gateway/runtime_loader.go @@ -110,6 +110,11 @@ func immutableConfigChanges(current, next config.Config) []string { appendIfChanged("ALLOW_PUBLIC_ADMIN", current.AllowPublicAdmin != next.AllowPublicAdmin) appendIfChanged("ALLOW_EMPTY_DEST_ALLOWLIST", current.AllowEmptyDestACL != next.AllowEmptyDestACL) appendIfChanged("ALLOW_INSECURE_PUBLIC_PROXY", current.AllowInsecurePublicProxy != next.AllowInsecurePublicProxy) + appendIfChanged("CLAUDE_OAUTH_ENABLED", current.ClaudeOAuthEnabled != next.ClaudeOAuthEnabled) + appendIfChanged("CLAUDE_OAUTH_REFRESH_TOKEN", current.ClaudeOAuthRefreshToken != next.ClaudeOAuthRefreshToken) + appendIfChanged("CLAUDE_OAUTH_CLIENT_ID", current.ClaudeOAuthClientID != next.ClaudeOAuthClientID) + appendIfChanged("CLAUDE_OAUTH_SCOPES", !reflect.DeepEqual(current.ClaudeOAuthScopes, next.ClaudeOAuthScopes)) + appendIfChanged("CLAUDE_OAUTH_TOKEN_URL", current.ClaudeOAuthTokenURL != next.ClaudeOAuthTokenURL) return changes } diff --git a/deploy/client.example.yaml b/deploy/client.example.yaml index 0fd1820..295b0ca 100644 --- a/deploy/client.example.yaml +++ b/deploy/client.example.yaml @@ -1,6 +1,8 @@ # Copy to deploy/client.yaml and adjust values before running: # go run ./cmd/codex-gateway deploy client +project_root: . +mode: proxy service_name: codex-gateway-tunnel wrapper_name: codex-gateway-proxy # Optional: set to "system" for a system-level tunnel service when running as root. @@ -42,3 +44,40 @@ proxy: # host: vps-2.example.com # tunnel: # local_port: 8081 + +# Optional: Claude Code rewrite mode. +# Set mode: claude or mode: both to enable it. +# +# claude_code: +# service_name: codex-gateway-claude-client +# wrapper_name: codex-gateway-claude +# admin_service_name: codex-gateway-admin-tunnel +# listen_host: 127.0.0.1 +# listen_port: 11443 +# admin_local_host: 127.0.0.1 +# admin_local_port: 19090 +# admin_remote_host: 127.0.0.1 +# admin_remote_port: 9090 +# upstream_url: https://api.anthropic.com +# identity: +# device_id: your-canonical-device-id +# email: user@example.com +# env: +# platform: darwin +# platform_raw: darwin +# arch: arm64 +# node_version: v24.3.0 +# terminal: iTerm2.app +# package_managers: npm,pnpm +# runtimes: node +# 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 diff --git a/deploy/vps.example.yaml b/deploy/vps.example.yaml index 430df31..22ecc28 100644 --- a/deploy/vps.example.yaml +++ b/deploy/vps.example.yaml @@ -44,3 +44,18 @@ runtime: metrics_enabled: true env_overrides: {} + +# Optional: centralized Claude OAuth broker for `codex-gateway claude-client`. +# When enabled, the VPS holds the refresh token and serves short-lived access tokens +# via the admin listener. +claude_oauth: + enabled: false + refresh_token: "" + # client_id: 9d1c250a-e61b-44d9-88ed-5944d1962f5e + # token_url: https://platform.claude.com/v1/oauth/token + # scopes: + # - user:inference + # - user:profile + # - user:sessions:claude_code + # - user:mcp_servers + # - user:file_upload diff --git a/docs/architecture-cyberpunk-en.svg b/docs/architecture-cyberpunk-en.svg index 0627620..69c42be 100644 --- a/docs/architecture-cyberpunk-en.svg +++ b/docs/architecture-cyberpunk-en.svg @@ -117,28 +117,28 @@ - - - + + + LOCAL // EDGE ENTRY - LOCAL LOOP + LOCAL LOOP Proxy-aware CLIs only see one local entry. - + LLM CLI Claude Code / Codex / other CLI Only local HTTP(S)_PROXY - + - + Proxy Wrapper Injects local proxy env vars One local entry, no remote topology - + - + SSH Tunnel 127.0.0.1:8080 @@ -147,70 +147,78 @@ Encrypted hop, no public proxy - - - - + + + + VPS // CONTROL CORE - CONTROL CORE + CONTROL CORE Egress, auth, policy, and audit converge here. - + codex-gateway Explicit proxy core / HTTP + CONNECT Single-binary explicit proxy core - + - + Policy Gate Basic Auth / source IP / concurrency Host / suffix / port allowlists DNS re-check blocks private IPs - + Audit Surface JSON audit logs /healthz /metrics Decision traces and health stay on your side - - - - + + + + UPSTREAM // MODEL CLOUD - EGRESS LIST + EGRESS LIST Only approved domains and ports get through. - + Anthropic .anthropic.com - + OpenAI - .openai.com / .chatgpt.com + + .openai.com + .chatgpt.com + - + OpenRouter .openrouter.ai - + Tight defaults, expand later - - + + - SSH TUNNEL - ALLOWLIST EGRESS + + SSH + TUNNEL + + + ALLOWLIST + EGRESS - - + + diff --git a/docs/architecture-cyberpunk-zh.svg b/docs/architecture-cyberpunk-zh.svg index 12d78c9..1f0fd88 100644 --- a/docs/architecture-cyberpunk-zh.svg +++ b/docs/architecture-cyberpunk-zh.svg @@ -117,28 +117,28 @@ - - - + + + LOCAL // 本地入口 - LOCAL LOOP + LOCAL LOOP 代理感知 CLI 只看到一个本地入口 - + LLM CLI Claude Code / Codex / 其他 CLI 只连本地 HTTP(S)_PROXY - + - + Proxy Wrapper 启动时注入代理环境变量 统一本地入口,不暴露远端拓扑 - + - + SSH Tunnel 127.0.0.1:8080 @@ -147,70 +147,78 @@ SSH 加密跳板,默认不暴露公网 - - - - + + + + VPS // 出口控制核心 - CONTROL CORE + CONTROL CORE 出网、鉴权、约束、审计全部收口到这里 - + codex-gateway 显式代理核心 / HTTP + CONNECT 单二进制,接住标准代理流量 - + - + Policy Gate Basic Auth / 源 IP / 并发限制 目标 Host / Suffix / Port DNS 二次校验,默认拒绝私网 - + Audit Surface JSON 审计日志 /healthz /metrics 决策、失败、状态都留在你这边 - - - - + + + + UPSTREAM // 模型服务 - EGRESS LIST + EGRESS LIST 只放行允许的目标域名和端口 - + Anthropic .anthropic.com - + OpenAI - .openai.com / .chatgpt.com + + .openai.com + .chatgpt.com + - + OpenRouter .openrouter.ai - + 默认最小放行,可按需扩展 - - + + - SSH TUNNEL - ALLOWLIST EGRESS + + SSH + TUNNEL + + + ALLOWLIST + EGRESS - - + + diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 1b65c40..cc67376 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -1,8 +1,12 @@ package admin import ( + "codex-gateway/internal/auth" + "codex-gateway/internal/claudeoauth" + "encoding/json" "fmt" "net/http" + "sync/atomic" "time" "codex-gateway/internal/proxy" @@ -12,6 +16,39 @@ type Options struct { MetricsEnabled bool Metrics *proxy.Metrics Version string + Runtime *Runtime +} + +type Runtime struct { + authStore atomic.Value + broker *claudeoauth.Broker +} + +func NewRuntime(store auth.UserStore, broker *claudeoauth.Broker) *Runtime { + runtime := &Runtime{broker: broker} + if store != nil { + runtime.authStore.Store(store) + } + return runtime +} + +func (r *Runtime) UpdateAuthStore(store auth.UserStore) { + if r == nil || store == nil { + return + } + r.authStore.Store(store) +} + +func (r *Runtime) currentAuthStore() auth.UserStore { + if r == nil { + return nil + } + value := r.authStore.Load() + if value == nil { + return nil + } + store, _ := value.(auth.UserStore) + return store } func NewHandler(options Options) http.Handler { @@ -49,6 +86,33 @@ func NewHandler(options Options) http.Handler { }) } + if options.Runtime != nil && options.Runtime.broker != nil { + mux.HandleFunc("/claude/oauth/health", func(w http.ResponseWriter, r *http.Request) { + if !requireAdminAuth(w, r, options.Runtime.currentAuthStore()) { + return + } + status := options.Runtime.broker.Status() + httpStatus := http.StatusOK + if status.Enabled && !status.Ready { + httpStatus = http.StatusServiceUnavailable + } + writeJSON(w, httpStatus, status) + }) + mux.HandleFunc("/claude/oauth/token", func(w http.ResponseWriter, r *http.Request) { + if !requireAdminAuth(w, r, options.Runtime.currentAuthStore()) { + return + } + token, err := options.Runtime.broker.GetToken(r.Context()) + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{ + "error": err.Error(), + }) + return + } + writeJSON(w, http.StatusOK, token) + }) + } + return mux } @@ -63,3 +127,42 @@ func writeHistogram(w http.ResponseWriter, name string, snapshot proxy.Histogram _, _ = fmt.Fprintf(w, "%s_sum %.6f\n", name, snapshot.Sum) _, _ = fmt.Fprintf(w, "%s_count %d\n", name, snapshot.Count) } + +func requireAdminAuth(w http.ResponseWriter, r *http.Request, store auth.UserStore) bool { + if store == nil { + http.Error(w, "admin auth store unavailable", http.StatusServiceUnavailable) + return false + } + + headerValue := r.Header.Get("Authorization") + if headerValue == "" { + headerValue = r.Header.Get("Proxy-Authorization") + } + credentials, err := auth.ParseProxyAuthorization(headerValue) + if err != nil { + writeAdminAuthRequired(w) + return false + } + + ok, err := store.Authenticate(credentials.Username, credentials.Password) + if err != nil { + http.Error(w, "admin authentication failed", http.StatusInternalServerError) + return false + } + if !ok { + writeAdminAuthRequired(w) + return false + } + return true +} + +func writeAdminAuthRequired(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", `Basic realm="codex-gateway-admin"`) + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} diff --git a/internal/claudeclient/client.go b/internal/claudeclient/client.go new file mode 100644 index 0000000..7b1f14c --- /dev/null +++ b/internal/claudeclient/client.go @@ -0,0 +1,322 @@ +package claudeclient + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "flag" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "codex-gateway/internal/logging" +) + +type tokenManager struct { + brokerURL *url.URL + username string + password string + client *http.Client + + mu sync.Mutex + token string + expiresAt time.Time + lastErr error +} + +func newTokenManager(config Config) (*tokenManager, error) { + brokerURL, err := url.Parse(config.OAuthBrokerURL) + if err != nil { + return nil, fmt.Errorf("parse oauth broker url: %w", err) + } + return &tokenManager{ + brokerURL: brokerURL, + username: config.OAuthUsername, + password: config.OAuthPassword, + client: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (m *tokenManager) AccessToken(ctx context.Context) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.token != "" && time.Until(m.expiresAt) > 5*time.Minute { + return m.token, nil + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, m.brokerURL.String(), http.NoBody) + if err != nil { + return "", err + } + request.SetBasicAuth(m.username, m.password) + request.Header.Set("Accept", "application/json") + + response, err := m.client.Do(request) + if err != nil { + m.lastErr = err + return "", err + } + defer response.Body.Close() + + var payload struct { + AccessToken string `json:"access_token"` + ExpiresAt time.Time `json:"expires_at"` + Error string `json:"error"` + } + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + m.lastErr = err + return "", err + } + if response.StatusCode != http.StatusOK { + if payload.Error == "" { + payload.Error = http.StatusText(response.StatusCode) + } + m.lastErr = fmt.Errorf("oauth broker error: %s", payload.Error) + return "", m.lastErr + } + if strings.TrimSpace(payload.AccessToken) == "" { + m.lastErr = fmt.Errorf("oauth broker returned empty access_token") + return "", m.lastErr + } + + m.token = payload.AccessToken + m.expiresAt = payload.ExpiresAt + m.lastErr = nil + return m.token, nil +} + +func (m *tokenManager) Status() (bool, time.Time, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.token != "" && time.Now().Before(m.expiresAt) { + return true, m.expiresAt, nil + } + return false, m.expiresAt, m.lastErr +} + +type handler struct { + config Config + logger *slog.Logger + upstreamURL *url.URL + httpClient *http.Client + tokens *tokenManager +} + +func Run(args []string, stdout, stderr io.Writer) error { + fs := flag.NewFlagSet("claude-client", flag.ContinueOnError) + fs.SetOutput(stderr) + configPath := fs.String("config", "claude-client.yaml", "path to Claude client YAML") + if err := fs.Parse(args); err != nil { + return err + } + + config, err := LoadConfig(*configPath) + if err != nil { + return err + } + loggers, err := logging.New(config.LogLevel, config.LogFormat) + if err != nil { + return err + } + + handler, err := NewHandler(config, loggers.App) + if err != nil { + return err + } + + server := &http.Server{ + Addr: config.ListenAddr, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 90 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + errCh := make(chan error, 1) + go func() { + loggers.App.Info("claude client starting", + "addr", config.ListenAddr, + "upstream", config.UpstreamURL, + "proxy_url", config.ProxyURL, + "oauth_broker_url", config.OAuthBrokerURL, + ) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + select { + case <-ctx.Done(): + case err := <-errCh: + return err + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return server.Shutdown(shutdownCtx) +} + +func NewHandler(config Config, logger *slog.Logger) (http.Handler, error) { + upstreamURL, err := url.Parse(config.UpstreamURL) + if err != nil { + return nil, err + } + proxyURL, err := url.Parse(config.ProxyURL) + if err != nil { + return nil, err + } + tokens, err := newTokenManager(config) + if err != nil { + return nil, err + } + + transport := &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + ForceAttemptHTTP2: true, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: config.TLSInsecureSkipVerify, + }, + } + + return &handler{ + config: config, + logger: logger, + upstreamURL: upstreamURL, + httpClient: &http.Client{Transport: transport}, + tokens: tokens, + }, nil +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/_health": + h.handleHealth(w, r) + return + case "/_verify": + writeJSON(w, http.StatusOK, BuildVerificationPayload(h.config)) + return + } + + accessToken, err := h.tokens.AccessToken(r.Context()) + if err != nil { + h.logger.Error("claude oauth token unavailable", "error", err.Error()) + writeJSON(w, http.StatusServiceUnavailable, map[string]any{ + "error": "oauth token not available", + }) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "read request body failed"}) + return + } + if len(body) > 0 { + body = RewriteBody(body, requestPath(r), h.config) + } + + outboundURL := *r.URL + outboundURL.Scheme = h.upstreamURL.Scheme + outboundURL.Host = h.upstreamURL.Host + + outboundRequest, err := http.NewRequestWithContext(r.Context(), r.Method, outboundURL.String(), bytes.NewReader(body)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "build upstream request failed"}) + return + } + outboundRequest.Header = RewriteHeaders(r.Header, h.config, accessToken) + outboundRequest.Header.Set("Host", h.upstreamURL.Host) + outboundRequest.ContentLength = int64(len(body)) + outboundRequest.Host = h.upstreamURL.Host + + response, err := h.httpClient.Do(outboundRequest) + if err != nil { + h.logger.Error("claude upstream request failed", "error", err.Error(), "path", requestPath(r)) + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "bad gateway", "detail": err.Error()}) + return + } + defer response.Body.Close() + + copyHeaders(w.Header(), response.Header) + w.Header().Del("Transfer-Encoding") + w.WriteHeader(response.StatusCode) + if _, err := io.Copy(w, response.Body); err != nil { + h.logger.Warn("claude response stream failed", "error", err.Error(), "path", requestPath(r)) + } +} + +func (h *handler) handleHealth(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + _, tokenErr := h.tokens.AccessToken(ctx) + ok, expiresAt, cachedErr := h.tokens.Status() + if tokenErr != nil && cachedErr == nil { + cachedErr = tokenErr + } + + status := http.StatusOK + state := "ok" + if !ok { + status = http.StatusServiceUnavailable + state = "degraded" + } + + payload := map[string]any{ + "status": state, + "oauth": map[string]any{"ready": ok, "expires_at": expiresAt}, + "canonical_device": truncateID(h.config.Identity.DeviceID), + "canonical_platform": h.config.Env.Platform, + "upstream": h.config.UpstreamURL, + "proxy_url": h.config.ProxyURL, + "oauth_broker_url": h.config.OAuthBrokerURL, + } + if cachedErr != nil { + payload["oauth_error"] = cachedErr.Error() + } + + writeJSON(w, status, payload) +} + +func requestPath(r *http.Request) string { + if r.URL == nil { + return "/" + } + if r.URL.RawQuery == "" { + return r.URL.Path + } + return r.URL.Path + "?" + r.URL.RawQuery +} + +func copyHeaders(dst, src http.Header) { + for key, values := range src { + for _, value := range values { + dst.Add(key, value) + } + } +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func truncateID(value string) string { + value = strings.TrimSpace(value) + if len(value) <= 8 { + return value + } + return value[:8] + "..." +} diff --git a/internal/claudeclient/client_test.go b/internal/claudeclient/client_test.go new file mode 100644 index 0000000..b74b7a9 --- /dev/null +++ b/internal/claudeclient/client_test.go @@ -0,0 +1,117 @@ +package claudeclient + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func testClaudeConfig(upstreamURL, proxyURL, brokerURL string) Config { + return Config{ + ListenAddr: "127.0.0.1:11443", + UpstreamURL: upstreamURL, + ProxyURL: proxyURL, + OAuthBrokerURL: brokerURL, + OAuthUsername: "alice", + OAuthPassword: "secret", + LogLevel: "info", + LogFormat: "json", + Identity: Identity{ + DeviceID: "canonical-device", + Email: "shared@example.com", + }, + Env: EnvProfile{ + Platform: "darwin", + PlatformRaw: "darwin", + Arch: "arm64", + NodeVersion: "v24.3.0", + Terminal: "iTerm2.app", + PackageManagers: "npm,pnpm", + Runtimes: "node", + IsClaudeAIAuth: true, + Version: "2.1.81", + VersionBase: "2.1.81", + BuildTime: "2026-03-20T21:26:18Z", + DeploymentEnvironment: "unknown-darwin", + VCS: "git", + }, + PromptEnv: PromptProfile{ + Platform: "darwin", + Shell: "zsh", + OSVersion: "Darwin 24.4.0", + WorkingDir: "/Users/jack/projects", + }, + Process: ProcessProfile{ + ConstrainedMemory: 34359738368, + RSSRange: [2]int64{300000000, 300000100}, + HeapTotalRange: [2]int64{40000000, 40000010}, + HeapUsedRange: [2]int64{100000000, 100000010}, + }, + } +} + +func TestRewriteBodyMessages(t *testing.T) { + config := testClaudeConfig("https://api.anthropic.com", "http://127.0.0.1:8080", "http://127.0.0.1:19090/claude/oauth/token") + body := []byte(`{"metadata":{"user_id":"{\"device_id\":\"real-device\"}"},"system":[{"text":"Platform: linux\nShell: bash\nOS Version: Linux 6.5\nWorking directory: /home/bob/project"}]}`) + + rewritten := RewriteBody(body, "/v1/messages", config) + text := string(rewritten) + if !bytes.Contains(rewritten, []byte(`canonical-device`)) { + t.Fatalf("rewritten body missing canonical device: %s", text) + } + if !bytes.Contains(rewritten, []byte(`Platform: darwin`)) { + t.Fatalf("rewritten body missing platform rewrite: %s", text) + } + if !bytes.Contains(rewritten, []byte(`/Users/jack/projects`)) { + t.Fatalf("rewritten body missing working dir rewrite: %s", text) + } +} + +func TestHandlerInjectsBrokerTokenAndRewritesRequest(t *testing.T) { + broker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "alice" || password != "secret" { + t.Fatalf("unexpected broker auth: %v %q %q", ok, username, password) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "broker-token", + "expires_at": time.Now().Add(time.Hour).UTC(), + }) + })) + defer broker.Close() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer broker-token" { + t.Fatalf("authorization = %q, want %q", got, "Bearer broker-token") + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + if !bytes.Contains(body, []byte("canonical-device")) { + t.Fatalf("body missing canonical device: %s", string(body)) + } + _, _ = w.Write([]byte("ok")) + })) + defer upstream.Close() + + config := testClaudeConfig(upstream.URL, upstream.URL, broker.URL) + handler, err := NewHandler(config, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("NewHandler() error = %v", err) + } + + request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString(`{"metadata":{"user_id":"{\"device_id\":\"real-device\"}"}}`)) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request.WithContext(context.Background())) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", response.Code, http.StatusOK) + } +} diff --git a/internal/claudeclient/config.go b/internal/claudeclient/config.go new file mode 100644 index 0000000..1c591b2 --- /dev/null +++ b/internal/claudeclient/config.go @@ -0,0 +1,161 @@ +package claudeclient + +import ( + "errors" + "fmt" + "net" + "net/url" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +type Config struct { + ListenAddr string `yaml:"listen_addr"` + UpstreamURL string `yaml:"upstream_url"` + ProxyURL string `yaml:"proxy_url"` + OAuthBrokerURL string `yaml:"oauth_broker_url"` + OAuthUsername string `yaml:"oauth_username"` + OAuthPassword string `yaml:"oauth_password"` + TLSInsecureSkipVerify bool `yaml:"tls_insecure_skip_verify"` + LogLevel string `yaml:"log_level"` + LogFormat string `yaml:"log_format"` + Identity Identity `yaml:"identity"` + Env EnvProfile `yaml:"env"` + PromptEnv PromptProfile `yaml:"prompt_env"` + Process ProcessProfile `yaml:"process"` +} + +type Identity struct { + DeviceID string `yaml:"device_id"` + Email string `yaml:"email"` +} + +type EnvProfile struct { + Platform string `yaml:"platform"` + PlatformRaw string `yaml:"platform_raw"` + Arch string `yaml:"arch"` + NodeVersion string `yaml:"node_version"` + Terminal string `yaml:"terminal"` + PackageManagers string `yaml:"package_managers"` + Runtimes string `yaml:"runtimes"` + IsRunningWithBun bool `yaml:"is_running_with_bun"` + IsClaudeAIAuth bool `yaml:"is_claude_ai_auth"` + Version string `yaml:"version"` + VersionBase string `yaml:"version_base"` + BuildTime string `yaml:"build_time"` + DeploymentEnvironment string `yaml:"deployment_environment"` + VCS string `yaml:"vcs"` +} + +type PromptProfile struct { + Platform string `yaml:"platform"` + Shell string `yaml:"shell"` + OSVersion string `yaml:"os_version"` + WorkingDir string `yaml:"working_dir"` +} + +type ProcessProfile struct { + ConstrainedMemory int64 `yaml:"constrained_memory"` + RSSRange [2]int64 `yaml:"rss_range"` + HeapTotalRange [2]int64 `yaml:"heap_total_range"` + HeapUsedRange [2]int64 `yaml:"heap_used_range"` +} + +func LoadConfig(path string) (Config, error) { + content, err := os.ReadFile(path) + if err != nil { + return Config{}, fmt.Errorf("read %s: %w", path, err) + } + + var config Config + if err := yaml.Unmarshal(content, &config); err != nil { + return Config{}, fmt.Errorf("parse %s: %w", path, err) + } + + applyClaudeDefaults(&config) + if err := config.Validate(); err != nil { + return Config{}, err + } + + return config, nil +} + +func applyClaudeDefaults(config *Config) { + if strings.TrimSpace(config.ListenAddr) == "" { + config.ListenAddr = "127.0.0.1:11443" + } + if strings.TrimSpace(config.UpstreamURL) == "" { + config.UpstreamURL = "https://api.anthropic.com" + } + if strings.TrimSpace(config.LogLevel) == "" { + config.LogLevel = "info" + } + if strings.TrimSpace(config.LogFormat) == "" { + config.LogFormat = "json" + } + if strings.TrimSpace(config.Env.PlatformRaw) == "" { + config.Env.PlatformRaw = config.Env.Platform + } + if strings.TrimSpace(config.Env.VersionBase) == "" { + config.Env.VersionBase = config.Env.Version + } + if !config.Env.IsClaudeAIAuth { + config.Env.IsClaudeAIAuth = true + } +} + +func (c Config) Validate() error { + var problems []string + + if _, _, err := net.SplitHostPort(c.ListenAddr); err != nil { + problems = append(problems, "listen_addr must be host:port") + } + if _, err := parseURL(c.UpstreamURL); err != nil { + problems = append(problems, "invalid upstream_url: "+err.Error()) + } + if _, err := parseURL(c.ProxyURL); err != nil { + problems = append(problems, "invalid proxy_url: "+err.Error()) + } + if _, err := parseURL(c.OAuthBrokerURL); err != nil { + problems = append(problems, "invalid oauth_broker_url: "+err.Error()) + } + if strings.TrimSpace(c.OAuthUsername) == "" || strings.TrimSpace(c.OAuthPassword) == "" { + problems = append(problems, "oauth_username and oauth_password are required") + } + if strings.TrimSpace(c.Identity.DeviceID) == "" { + problems = append(problems, "identity.device_id is required") + } + if strings.TrimSpace(c.Env.Version) == "" { + problems = append(problems, "env.version is required") + } + if strings.TrimSpace(c.PromptEnv.WorkingDir) == "" { + problems = append(problems, "prompt_env.working_dir is required") + } + if c.Process.RSSRange[1] < c.Process.RSSRange[0] { + problems = append(problems, "process.rss_range must be [min,max]") + } + if c.Process.HeapTotalRange[1] < c.Process.HeapTotalRange[0] { + problems = append(problems, "process.heap_total_range must be [min,max]") + } + if c.Process.HeapUsedRange[1] < c.Process.HeapUsedRange[0] { + problems = append(problems, "process.heap_used_range must be [min,max]") + } + + if len(problems) > 0 { + return errors.New(strings.Join(problems, "; ")) + } + return nil +} + +func parseURL(raw string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return nil, err + } + if parsed.Scheme == "" || parsed.Host == "" { + return nil, errors.New("expected absolute URL") + } + return parsed, nil +} diff --git a/internal/claudeclient/rewrite.go b/internal/claudeclient/rewrite.go new file mode 100644 index 0000000..76a6acc --- /dev/null +++ b/internal/claudeclient/rewrite.go @@ -0,0 +1,327 @@ +package claudeclient + +import ( + "encoding/base64" + "encoding/json" + "math/rand" + "net/http" + "regexp" + "strings" + "time" +) + +var ( + billingVersionPattern = regexp.MustCompile(`cc_version=[\d.]+\.[a-f0-9]{3}`) + platformPattern = regexp.MustCompile(`Platform:\s*\S+`) + shellPattern = regexp.MustCompile(`Shell:\s*\S+`) + osVersionPattern = regexp.MustCompile(`OS Version:\s*[^\n<]+`) + workDirPattern = regexp.MustCompile(`((?:Primary )?[Ww]orking directory:\s*)/\S+`) + homePathPattern = regexp.MustCompile(`/((?:Users|home))/[^/\s]+/`) +) + +func RewriteBody(body []byte, path string, config Config) []byte { + var parsed any + if err := json.Unmarshal(body, &parsed); err != nil { + return body + } + + switch { + case strings.HasPrefix(path, "/v1/messages"): + rewriteMessagesBody(parsed, config) + case strings.Contains(path, "/event_logging/batch"): + rewriteEventBatch(parsed, config) + case strings.Contains(path, "/policy_limits"), strings.Contains(path, "/settings"): + rewriteGenericIdentity(parsed, config) + } + + rewritten, err := json.Marshal(parsed) + if err != nil { + return body + } + return rewritten +} + +func RewriteHeaders(headers http.Header, config Config, accessToken string) http.Header { + out := make(http.Header) + + for key, values := range headers { + lower := strings.ToLower(strings.TrimSpace(key)) + switch lower { + case "host", "connection", "proxy-authorization", "proxy-connection", "transfer-encoding", "authorization", "content-length": + continue + case "user-agent": + out.Set("User-Agent", "claude-code/"+config.Env.Version+" (external, cli)") + case "x-anthropic-billing-header": + out.Set("X-Anthropic-Billing-Header", billingVersionPattern.ReplaceAllString(strings.Join(values, ", "), "cc_version="+config.Env.Version+".000")) + default: + for _, value := range values { + out.Add(key, value) + } + } + } + + out.Set("Authorization", "Bearer "+accessToken) + return out +} + +func BuildVerificationPayload(config Config) map[string]any { + sampleInput := map[string]any{ + "metadata": map[string]any{ + "user_id": `{"device_id":"REAL_DEVICE_ID_FROM_CLIENT_abc123","account_uuid":"shared-account-uuid","session_id":"session-xxx"}`, + }, + "system": []map[string]any{ + { + "type": "text", + "text": "x-anthropic-billing-header: cc_version=2.1.81.a1b; cc_entrypoint=cli;", + }, + { + "type": "text", + "text": "Here is useful information about the environment:\n\nWorking directory: /home/bob/myproject\nPlatform: linux\nShell: bash\nOS Version: Linux 6.5.0-generic\n", + }, + }, + "messages": []map[string]any{ + {"role": "user", "content": "hello"}, + }, + } + + beforeUserID := map[string]any{} + _ = json.Unmarshal([]byte(sampleInput["metadata"].(map[string]any)["user_id"].(string)), &beforeUserID) + rewritten := map[string]any{} + _ = json.Unmarshal(RewriteBody(mustJSON(sampleInput), "/v1/messages", config), &rewritten) + + afterUserID := map[string]any{} + metadata := rewritten["metadata"].(map[string]any) + _ = json.Unmarshal([]byte(metadata["user_id"].(string)), &afterUserID) + system := rewritten["system"].([]any) + + return map[string]any{ + "_info": "This shows how the gateway rewrites a sample request", + "before": map[string]any{ + "metadata.user_id": beforeUserID, + "system_prompt_env": sampleInput["system"].([]map[string]any)[1]["text"], + "billing_header": sampleInput["system"].([]map[string]any)[0]["text"], + }, + "after": map[string]any{ + "metadata.user_id": afterUserID, + "system_prompt_env": system[1].(map[string]any)["text"], + "billing_header": system[0].(map[string]any)["text"], + }, + } +} + +func mustJSON(value any) []byte { + content, _ := json.Marshal(value) + return content +} + +func rewriteMessagesBody(body any, config Config) { + root, ok := body.(map[string]any) + if !ok { + return + } + + if metadata, ok := root["metadata"].(map[string]any); ok { + if raw, ok := metadata["user_id"].(string); ok && raw != "" { + var userID map[string]any + if err := json.Unmarshal([]byte(raw), &userID); err == nil { + userID["device_id"] = config.Identity.DeviceID + if config.Identity.Email != "" { + userID["email"] = config.Identity.Email + } + metadata["user_id"] = string(mustJSON(userID)) + } + } + } + + switch system := root["system"].(type) { + case string: + root["system"] = rewritePromptText(system, config) + case []any: + for _, item := range system { + if block, ok := item.(map[string]any); ok { + if text, ok := block["text"].(string); ok { + block["text"] = rewritePromptText(text, config) + } + } + } + } + + if messages, ok := root["messages"].([]any); ok { + for _, entry := range messages { + message, ok := entry.(map[string]any) + if !ok { + continue + } + switch content := message["content"].(type) { + case string: + message["content"] = rewritePromptText(content, config) + case []any: + for _, block := range content { + if value, ok := block.(map[string]any); ok { + if text, ok := value["text"].(string); ok { + value["text"] = rewritePromptText(text, config) + } + } + } + } + } + } +} + +func rewritePromptText(text string, config Config) string { + result := text + result = billingVersionPattern.ReplaceAllString(result, "cc_version="+config.Env.Version+".000") + result = platformPattern.ReplaceAllString(result, "Platform: "+config.PromptEnv.Platform) + result = shellPattern.ReplaceAllString(result, "Shell: "+config.PromptEnv.Shell) + result = osVersionPattern.ReplaceAllString(result, "OS Version: "+config.PromptEnv.OSVersion) + result = workDirPattern.ReplaceAllString(result, "${1}"+config.PromptEnv.WorkingDir) + + homePrefix := "/Users/user/" + if matches := regexp.MustCompile(`^/[^/]+/[^/]+/`).FindString(config.PromptEnv.WorkingDir + "/"); matches != "" { + homePrefix = matches + } + result = homePathPattern.ReplaceAllString(result, homePrefix) + return result +} + +func rewriteEventBatch(body any, config Config) { + root, ok := body.(map[string]any) + if !ok { + return + } + events, ok := root["events"].([]any) + if !ok { + return + } + + for _, entry := range events { + event, ok := entry.(map[string]any) + if !ok { + continue + } + data, ok := event["event_data"].(map[string]any) + if !ok { + continue + } + if _, ok := data["device_id"]; ok { + data["device_id"] = config.Identity.DeviceID + } + if _, ok := data["email"]; ok && config.Identity.Email != "" { + data["email"] = config.Identity.Email + } + if _, ok := data["env"]; ok { + data["env"] = buildCanonicalEnv(config) + } + if original, ok := data["process"]; ok { + data["process"] = buildCanonicalProcess(original, config) + } + delete(data, "baseUrl") + delete(data, "base_url") + delete(data, "gateway") + if raw, ok := data["additional_metadata"].(string); ok && raw != "" { + data["additional_metadata"] = rewriteAdditionalMetadata(raw) + } + } +} + +func rewriteGenericIdentity(body any, config Config) { + root, ok := body.(map[string]any) + if !ok { + return + } + if _, ok := root["device_id"]; ok { + root["device_id"] = config.Identity.DeviceID + } + if _, ok := root["email"]; ok && config.Identity.Email != "" { + root["email"] = config.Identity.Email + } +} + +func buildCanonicalEnv(config Config) map[string]any { + return map[string]any{ + "platform": config.Env.Platform, + "platform_raw": config.Env.PlatformRaw, + "arch": config.Env.Arch, + "node_version": config.Env.NodeVersion, + "terminal": config.Env.Terminal, + "package_managers": config.Env.PackageManagers, + "runtimes": config.Env.Runtimes, + "is_running_with_bun": config.Env.IsRunningWithBun, + "is_ci": false, + "is_claubbit": false, + "is_claude_code_remote": false, + "is_local_agent_mode": false, + "is_conductor": false, + "is_github_action": false, + "is_claude_code_action": false, + "is_claude_ai_auth": config.Env.IsClaudeAIAuth, + "version": config.Env.Version, + "version_base": config.Env.VersionBase, + "build_time": config.Env.BuildTime, + "deployment_environment": config.Env.DeploymentEnvironment, + "vcs": config.Env.VCS, + } +} + +func buildCanonicalProcess(original any, config Config) any { + switch value := original.(type) { + case string: + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return original + } + var process map[string]any + if err := json.Unmarshal(decoded, &process); err != nil { + return original + } + rewritten := rewriteProcessFields(process, config) + content, err := json.Marshal(rewritten) + if err != nil { + return original + } + return base64.StdEncoding.EncodeToString(content) + case map[string]any: + return rewriteProcessFields(value, config) + default: + return original + } +} + +func rewriteProcessFields(process map[string]any, config Config) map[string]any { + out := make(map[string]any, len(process)+4) + for key, value := range process { + out[key] = value + } + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + out["constrainedMemory"] = config.Process.ConstrainedMemory + out["rss"] = randomInRange(rng, config.Process.RSSRange) + out["heapTotal"] = randomInRange(rng, config.Process.HeapTotalRange) + out["heapUsed"] = randomInRange(rng, config.Process.HeapUsedRange) + return out +} + +func rewriteAdditionalMetadata(raw string) string { + decoded, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return raw + } + var payload map[string]any + if err := json.Unmarshal(decoded, &payload); err != nil { + return raw + } + delete(payload, "baseUrl") + delete(payload, "base_url") + delete(payload, "gateway") + content, err := json.Marshal(payload) + if err != nil { + return raw + } + return base64.StdEncoding.EncodeToString(content) +} + +func randomInRange(rng *rand.Rand, bounds [2]int64) int64 { + if bounds[1] <= bounds[0] { + return bounds[0] + } + return bounds[0] + rng.Int63n(bounds[1]-bounds[0]+1) +} diff --git a/internal/claudeoauth/broker.go b/internal/claudeoauth/broker.go new file mode 100644 index 0000000..4370e1f --- /dev/null +++ b/internal/claudeoauth/broker.go @@ -0,0 +1,222 @@ +package claudeoauth + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" +) + +const ( + DefaultTokenURL = "https://platform.claude.com/v1/oauth/token" + DefaultClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" +) + +var DefaultScopes = []string{ + "user:inference", + "user:profile", + "user:sessions:claude_code", + "user:mcp_servers", + "user:file_upload", +} + +type Config struct { + Enabled bool + RefreshToken string + ClientID string + Scopes []string + TokenURL string + HTTPClient *http.Client +} + +type Token struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresAt time.Time `json:"expires_at"` +} + +type Status struct { + Enabled bool `json:"enabled"` + Ready bool `json:"ready"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + LastRefresh time.Time `json:"last_refresh,omitempty"` + LastError string `json:"last_error,omitempty"` +} + +type Broker struct { + client *http.Client + + mu sync.Mutex + enabled bool + refreshToken string + clientID string + scopes []string + tokenURL string + token Token + lastRefresh time.Time + lastErr error +} + +func New(config Config) (*Broker, error) { + broker := &Broker{ + enabled: config.Enabled, + refreshToken: strings.TrimSpace(config.RefreshToken), + clientID: strings.TrimSpace(config.ClientID), + scopes: append([]string(nil), config.Scopes...), + tokenURL: strings.TrimSpace(config.TokenURL), + client: config.HTTPClient, + } + + if broker.client == nil { + broker.client = &http.Client{Timeout: 15 * time.Second} + } + if broker.clientID == "" { + broker.clientID = DefaultClientID + } + if broker.tokenURL == "" { + broker.tokenURL = DefaultTokenURL + } + if len(broker.scopes) == 0 { + broker.scopes = append([]string(nil), DefaultScopes...) + } + + if !broker.enabled { + return broker, nil + } + if broker.refreshToken == "" { + return nil, errors.New("claude oauth refresh token is required when broker is enabled") + } + + return broker, nil +} + +func (b *Broker) Enabled() bool { + if b == nil { + return false + } + return b.enabled +} + +func (b *Broker) Warmup(ctx context.Context) error { + if !b.Enabled() { + return nil + } + _, err := b.GetToken(ctx) + return err +} + +func (b *Broker) Status() Status { + if b == nil { + return Status{} + } + + b.mu.Lock() + defer b.mu.Unlock() + + status := Status{ + Enabled: b.enabled, + LastRefresh: b.lastRefresh, + } + if b.lastErr != nil { + status.LastError = b.lastErr.Error() + } + if b.token.AccessToken != "" { + status.ExpiresAt = b.token.ExpiresAt + status.Ready = time.Now().Before(b.token.ExpiresAt) + } + + return status +} + +func (b *Broker) GetToken(ctx context.Context) (Token, error) { + if !b.Enabled() { + return Token{}, errors.New("claude oauth broker is disabled") + } + + b.mu.Lock() + defer b.mu.Unlock() + + if tokenStillValid(b.token) { + return b.token, nil + } + + token, err := b.refreshLocked(ctx) + if err != nil { + b.lastErr = err + return Token{}, err + } + + b.token = token + b.lastRefresh = time.Now().UTC() + b.lastErr = nil + if token.RefreshToken != "" { + b.refreshToken = token.RefreshToken + } + + return b.token, nil +} + +func tokenStillValid(token Token) bool { + if token.AccessToken == "" { + return false + } + return time.Until(token.ExpiresAt) > 5*time.Minute +} + +func (b *Broker) refreshLocked(ctx context.Context) (Token, error) { + body, err := json.Marshal(map[string]string{ + "grant_type": "refresh_token", + "refresh_token": b.refreshToken, + "client_id": b.clientID, + "scope": strings.Join(b.scopes, " "), + }) + if err != nil { + return Token{}, fmt.Errorf("marshal oauth request: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, b.tokenURL, bytes.NewReader(body)) + if err != nil { + return Token{}, fmt.Errorf("build oauth request: %w", err) + } + request.Header.Set("Content-Type", "application/json") + + response, err := b.client.Do(request) + if err != nil { + return Token{}, fmt.Errorf("refresh oauth token: %w", err) + } + defer response.Body.Close() + + var payload struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + Error string `json:"error"` + } + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + return Token{}, fmt.Errorf("decode oauth response: %w", err) + } + if response.StatusCode != http.StatusOK { + message := payload.Error + if message == "" { + message = http.StatusText(response.StatusCode) + } + return Token{}, fmt.Errorf("oauth refresh failed: %s", message) + } + if strings.TrimSpace(payload.AccessToken) == "" { + return Token{}, errors.New("oauth refresh returned empty access_token") + } + if payload.ExpiresIn <= 0 { + payload.ExpiresIn = 3600 + } + + return Token{ + AccessToken: payload.AccessToken, + RefreshToken: payload.RefreshToken, + ExpiresAt: time.Now().Add(time.Duration(payload.ExpiresIn) * time.Second).UTC(), + }, nil +} diff --git a/internal/claudeoauth/broker_test.go b/internal/claudeoauth/broker_test.go new file mode 100644 index 0000000..7316bb1 --- /dev/null +++ b/internal/claudeoauth/broker_test.go @@ -0,0 +1,71 @@ +package claudeoauth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestBrokerRefreshesAndCachesToken(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "access-token", + "refresh_token": "refresh-token-next", + "expires_in": 3600, + }) + })) + defer server.Close() + + broker, err := New(Config{ + Enabled: true, + RefreshToken: "refresh-token", + TokenURL: server.URL, + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + first, err := broker.GetToken(context.Background()) + if err != nil { + t.Fatalf("GetToken() error = %v", err) + } + second, err := broker.GetToken(context.Background()) + if err != nil { + t.Fatalf("GetToken() second error = %v", err) + } + + if first.AccessToken != "access-token" || second.AccessToken != "access-token" { + t.Fatalf("unexpected token values: %#v %#v", first, second) + } + if requests != 1 { + t.Fatalf("requests = %d, want %d", requests, 1) + } + status := broker.Status() + if !status.Ready { + t.Fatalf("status.Ready = false, want true") + } + if status.ExpiresAt.Before(time.Now()) { + t.Fatalf("status.ExpiresAt = %s, want future time", status.ExpiresAt) + } +} + +func TestBrokerDisabled(t *testing.T) { + broker, err := New(Config{}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if broker.Enabled() { + t.Fatalf("broker.Enabled() = true, want false") + } + if _, err := broker.GetToken(context.Background()); err == nil { + t.Fatalf("GetToken() error = nil, want non-nil") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 5c94d9a..935dca7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "codex-gateway/internal/claudeoauth" "errors" "fmt" "net" @@ -64,6 +65,12 @@ type Config struct { AllowEmptyDestACL bool AllowInsecurePublicProxy bool + + ClaudeOAuthEnabled bool + ClaudeOAuthRefreshToken string + ClaudeOAuthClientID string + ClaudeOAuthScopes []string + ClaudeOAuthTokenURL string } func LoadFromEnv() (Config, error) { @@ -105,6 +112,10 @@ func loadFromLookup(lookup func(string) string) (Config, error) { AllowPublicAdmin: getenvBool(lookup, "ALLOW_PUBLIC_ADMIN", false), AllowEmptyDestACL: getenvBool(lookup, "ALLOW_EMPTY_DEST_ALLOWLIST", false), AllowInsecurePublicProxy: getenvBool(lookup, "ALLOW_INSECURE_PUBLIC_PROXY", false), + ClaudeOAuthEnabled: getenvBool(lookup, "CLAUDE_OAUTH_ENABLED", false), + ClaudeOAuthRefreshToken: strings.TrimSpace(lookup("CLAUDE_OAUTH_REFRESH_TOKEN")), + ClaudeOAuthClientID: strings.TrimSpace(getenvDefault(lookup, "CLAUDE_OAUTH_CLIENT_ID", claudeoauth.DefaultClientID)), + ClaudeOAuthTokenURL: strings.TrimSpace(getenvDefault(lookup, "CLAUDE_OAUTH_TOKEN_URL", claudeoauth.DefaultTokenURL)), } var err error @@ -120,6 +131,7 @@ func loadFromLookup(lookup func(string) string) (Config, error) { cfg.DestHosts = parseHostSet(lookup("DEST_HOST_ALLOWLIST")) cfg.DestSuffixes = parseSuffixes(lookup("DEST_SUFFIX_ALLOWLIST")) + cfg.ClaudeOAuthScopes = splitCSV(lookup("CLAUDE_OAUTH_SCOPES")) if err := cfg.Validate(); err != nil { return Config{}, err @@ -194,6 +206,9 @@ func (c Config) Validate() error { if isPublicBindHost(c.ProxyListenAddr) && !c.ProxyTLSEnabled && !c.AllowInsecurePublicProxy { problems = append(problems, "public proxy listener without TLS is refused; enable PROXY_TLS_ENABLED or set ALLOW_INSECURE_PUBLIC_PROXY=true only when access is restricted elsewhere") } + if c.ClaudeOAuthEnabled && c.ClaudeOAuthRefreshToken == "" { + problems = append(problems, "CLAUDE_OAUTH_REFRESH_TOKEN is required when CLAUDE_OAUTH_ENABLED=true") + } if len(problems) > 0 { return errors.New(strings.Join(problems, "; ")) diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go index fb12d08..68873a8 100644 --- a/internal/deploy/deploy.go +++ b/internal/deploy/deploy.go @@ -89,7 +89,7 @@ func runClient(args []string, stdout io.Writer, stderr io.Writer) error { for index, servicePath := range result.ServicePaths { _, _ = fmt.Fprintf(stdout, " tunnel service %d: %s\n", index+1, servicePath) } - } else { + } else if result.ServicePath != "" { _, _ = fmt.Fprintf(stdout, " tunnel service: %s\n", result.ServicePath) } if len(result.EnvPaths) > 1 { @@ -97,10 +97,30 @@ func runClient(args []string, stdout io.Writer, stderr io.Writer) error { for index, envPath := range result.EnvPaths { _, _ = fmt.Fprintf(stdout, " endpoint env %d: %s\n", index+1, envPath) } - } else { + } else if result.EnvPath != "" { _, _ = fmt.Fprintf(stdout, " env file: %s\n", result.EnvPath) } - _, _ = fmt.Fprintf(stdout, " wrapper: %s\n", result.WrapperPath) + if result.WrapperPath != "" { + _, _ = fmt.Fprintf(stdout, " wrapper: %s\n", result.WrapperPath) + } + if result.ClaudeAdminServicePath != "" { + _, _ = fmt.Fprintf(stdout, " claude admin tunnel: %s\n", result.ClaudeAdminServicePath) + } + if result.ClaudeServicePath != "" { + _, _ = fmt.Fprintf(stdout, " claude service: %s\n", result.ClaudeServicePath) + } + if result.ClaudeConfigPath != "" { + _, _ = fmt.Fprintf(stdout, " claude config: %s\n", result.ClaudeConfigPath) + } + if result.ClaudeEnvPath != "" { + _, _ = fmt.Fprintf(stdout, " claude env: %s\n", result.ClaudeEnvPath) + } + if result.ClaudeWrapperPath != "" { + _, _ = fmt.Fprintf(stdout, " claude wrapper: %s\n", result.ClaudeWrapperPath) + } + if result.ClaudeBinaryPath != "" { + _, _ = fmt.Fprintf(stdout, " claude binary: %s\n", result.ClaudeBinaryPath) + } return nil } @@ -111,7 +131,7 @@ func printUsage(w io.Writer) { Targets: vps Render runtime files, build the binary, and install the local VPS service - client Render the SSH tunnel service and a proxy wrapper script on the client + client Render the SSH tunnel service plus optional Claude rewrite client artifacts `) } diff --git a/internal/deploy/deploy_test.go b/internal/deploy/deploy_test.go index 72698c6..e9bb734 100644 --- a/internal/deploy/deploy_test.go +++ b/internal/deploy/deploy_test.go @@ -36,6 +36,13 @@ func TestRenderVPSEnvUsesAbsoluteRuntimePaths(t *testing.T) { LogLevel: "info", LogFormat: "json", }, + ClaudeOAuth: VPSClaudeOAuth{ + Enabled: true, + RefreshToken: "refresh-token", + ClientID: "client-id", + TokenURL: "https://platform.claude.com/v1/oauth/token", + Scopes: []string{"user:inference"}, + }, } content, err := RenderVPSEnv(spec) @@ -52,6 +59,12 @@ func TestRenderVPSEnvUsesAbsoluteRuntimePaths(t *testing.T) { if !strings.Contains(text, "DEST_HOST_ALLOWLIST=storage.googleapis.com") { t.Fatalf("env missing host allowlist: %s", text) } + if !strings.Contains(text, "CLAUDE_OAUTH_ENABLED=true") { + t.Fatalf("env missing claude oauth flag: %s", text) + } + if !strings.Contains(text, "CLAUDE_OAUTH_REFRESH_TOKEN=refresh-token") { + t.Fatalf("env missing claude oauth refresh token: %s", text) + } } func TestRenderVPSUsersHashesPlaintextPasswords(t *testing.T) { @@ -121,6 +134,129 @@ func TestRenderClientArtifacts(t *testing.T) { } } +func TestNormalizeClientConfigWithClaudeMode(t *testing.T) { + spec := ClientConfig{ + ProjectRoot: ".", + Mode: ClientModeBoth, + SSH: ClientSSH{ + User: "admin", + Host: "your-vps.example.com", + }, + Tunnel: ClientTunnel{ + LocalHost: "127.0.0.1", + LocalPort: 8080, + RemoteHost: "127.0.0.1", + RemotePort: 8080, + }, + Proxy: ClientProxy{ + Username: "alice", + Password: "secret", + }, + ClaudeCode: ClientClaudeCode{ + Identity: ClaudeIdentity{ + DeviceID: "canonical-device", + }, + Env: ClaudeEnvProfile{ + Version: "2.1.81", + }, + PromptEnv: ClaudePromptProfile{ + WorkingDir: "/Users/jack/projects", + }, + }, + } + + normalized, err := normalizeClientConfig(spec) + if err != nil { + t.Fatalf("normalizeClientConfig() error = %v", err) + } + if normalized.ClaudeCode.ListenPort != 11443 { + t.Fatalf("claude listen port = %d, want %d", normalized.ClaudeCode.ListenPort, 11443) + } + if normalized.ClaudeCode.AdminLocalPort != 19090 { + t.Fatalf("claude admin local port = %d, want %d", normalized.ClaudeCode.AdminLocalPort, 19090) + } + if normalized.ClaudeCode.BinaryOutput == "" { + t.Fatalf("claude binary output is empty") + } +} + +func TestRenderClaudeClientArtifacts(t *testing.T) { + spec := ClientConfig{ + InstallDir: "/home/test/.config/codex-gateway", + ServiceName: "codex-gateway-tunnel", + WrapperName: "codex-gateway-proxy", + Mode: ClientModeBoth, + SSH: ClientSSH{ + User: "admin", + Host: "your-vps.example.com", + ServerAliveInterval: 60, + ServerAliveCountMax: 3, + }, + Tunnel: ClientTunnel{ + LocalHost: "127.0.0.1", + LocalPort: 8080, + RemoteHost: "127.0.0.1", + RemotePort: 8080, + }, + Proxy: ClientProxy{ + Username: "alice", + Password: "secret", + }, + ClaudeCode: ClientClaudeCode{ + ServiceName: "codex-gateway-claude-client", + WrapperName: "codex-gateway-claude", + AdminServiceName: "codex-gateway-admin-tunnel", + ListenHost: "127.0.0.1", + ListenPort: 11443, + AdminLocalHost: "127.0.0.1", + AdminLocalPort: 19090, + AdminRemoteHost: "127.0.0.1", + AdminRemotePort: 9090, + UpstreamURL: "https://api.anthropic.com", + BinaryOutput: "/home/test/.config/codex-gateway/bin/codex-gateway", + Identity: ClaudeIdentity{ + DeviceID: "canonical-device", + }, + Env: ClaudeEnvProfile{ + Platform: "darwin", + PlatformRaw: "darwin", + Version: "2.1.81", + VersionBase: "2.1.81", + }, + PromptEnv: ClaudePromptProfile{ + Platform: "darwin", + Shell: "zsh", + OSVersion: "Darwin 24.4.0", + WorkingDir: "/Users/jack/projects", + }, + Process: ClaudeProcessProfile{ + ConstrainedMemory: 34359738368, + RSSRange: [2]int64{300000000, 500000000}, + HeapTotalRange: [2]int64{40000000, 80000000}, + HeapUsedRange: [2]int64{100000000, 200000000}, + }, + }, + } + + envText := string(RenderClaudeClientEnv(spec)) + if !strings.Contains(envText, "ANTHROPIC_BASE_URL='http://127.0.0.1:11443'") { + t.Fatalf("claude env missing ANTHROPIC_BASE_URL: %s", envText) + } + + configText, err := RenderClaudeClientConfig(spec, primaryClientEndpoint(spec)) + if err != nil { + t.Fatalf("RenderClaudeClientConfig() error = %v", err) + } + if !strings.Contains(string(configText), "oauth_broker_url: http://127.0.0.1:19090/claude/oauth/token") { + t.Fatalf("claude config missing oauth broker url: %s", string(configText)) + } + + serviceText := string(RenderClaudeClientService(spec, "/home/test/.config/codex-gateway/claude-client.yaml", "codex-gateway-tunnel", "codex-gateway-admin-tunnel")) + if !strings.Contains(serviceText, "codex-gateway claude-client -config /home/test/.config/codex-gateway/claude-client.yaml") { + t.Fatalf("claude service missing ExecStart: %s", serviceText) + } +} + func TestRenderServicesUseScopeSpecificTargets(t *testing.T) { vpsSpec := VPSConfig{ ProjectRoot: "/srv/codex-gateway", diff --git a/internal/deploy/install.go b/internal/deploy/install.go index 2c8bbdd..8b0902d 100644 --- a/internal/deploy/install.go +++ b/internal/deploy/install.go @@ -15,11 +15,17 @@ type VPSInstallResult struct { } type ClientInstallResult struct { - EnvPath string - EnvPaths []string - WrapperPath string - ServicePath string - ServicePaths []string + EnvPath string + EnvPaths []string + WrapperPath string + ServicePath string + ServicePaths []string + ClaudeEnvPath string + ClaudeWrapperPath string + ClaudeConfigPath string + ClaudeServicePath string + ClaudeAdminServicePath string + ClaudeBinaryPath string } func InstallVPS(spec VPSConfig) (VPSInstallResult, error) { @@ -89,10 +95,6 @@ func InstallVPS(spec VPSConfig) (VPSInstallResult, error) { func InstallClient(spec ClientConfig) (ClientInstallResult, error) { endpoints := clientEndpoints(spec) envPath := filepath.Join(spec.InstallDir, "proxy.env") - wrapperPath, err := clientWrapperPath(spec.WrapperName) - if err != nil { - return ClientInstallResult{}, err - } serviceInstall, err := newSystemdInstall(spec.ServiceScope, clientServiceName(spec, endpoints[0])) if err != nil { return ClientInstallResult{}, err @@ -104,36 +106,46 @@ func InstallClient(spec ClientConfig) (ClientInstallResult, error) { if err := os.MkdirAll(spec.InstallDir, 0o755); err != nil { return ClientInstallResult{}, fmt.Errorf("mkdir install dir: %w", err) } - if err := os.MkdirAll(filepath.Dir(wrapperPath), 0o755); err != nil { - return ClientInstallResult{}, fmt.Errorf("mkdir wrapper dir: %w", err) - } if err := os.MkdirAll(filepath.Dir(serviceInstall.servicePath), 0o755); err != nil { return ClientInstallResult{}, fmt.Errorf("mkdir service dir: %w", err) } + var wrapperPath string + if clientModeIncludes(spec.Mode, ClientModeProxy) { + wrapperPath, err = clientWrapperPath(spec.WrapperName) + if err != nil { + return ClientInstallResult{}, err + } + if err := os.MkdirAll(filepath.Dir(wrapperPath), 0o755); err != nil { + return ClientInstallResult{}, fmt.Errorf("mkdir wrapper dir: %w", err) + } + } + envPaths := make([]string, 0, len(endpoints)) servicePaths := make([]string, 0, len(endpoints)) wrapperEndpoints := make([]clientWrapperEndpoint, 0, len(endpoints)) for index, endpoint := range endpoints { - endpointEnvPath := envPath - if len(endpoints) > 1 { - endpointEnvPath = filepath.Join(spec.InstallDir, "proxy-"+endpoint.Name+".env") - } - if err := os.WriteFile(endpointEnvPath, RenderClientEnvForEndpoint(spec.Proxy, endpoint), 0o600); err != nil { - return ClientInstallResult{}, fmt.Errorf("write client env for %s: %w", endpoint.Name, err) - } - if len(endpoints) > 1 && index == 0 { - if err := os.WriteFile(envPath, RenderClientEnvForEndpoint(spec.Proxy, endpoint), 0o600); err != nil { - return ClientInstallResult{}, fmt.Errorf("write default client env: %w", err) + if clientModeIncludes(spec.Mode, ClientModeProxy) { + endpointEnvPath := envPath + if len(endpoints) > 1 { + endpointEnvPath = filepath.Join(spec.InstallDir, "proxy-"+endpoint.Name+".env") } + if err := os.WriteFile(endpointEnvPath, RenderClientEnvForEndpoint(spec.Proxy, endpoint), 0o600); err != nil { + return ClientInstallResult{}, fmt.Errorf("write client env for %s: %w", endpoint.Name, err) + } + if len(endpoints) > 1 && index == 0 { + if err := os.WriteFile(envPath, RenderClientEnvForEndpoint(spec.Proxy, endpoint), 0o600); err != nil { + return ClientInstallResult{}, fmt.Errorf("write default client env: %w", err) + } + } + envPaths = append(envPaths, endpointEnvPath) + wrapperEndpoints = append(wrapperEndpoints, clientWrapperEndpoint{ + Name: endpoint.Name, + EnvPath: endpointEnvPath, + LocalHost: endpoint.Tunnel.LocalHost, + LocalPort: endpoint.Tunnel.LocalPort, + }) } - envPaths = append(envPaths, endpointEnvPath) - wrapperEndpoints = append(wrapperEndpoints, clientWrapperEndpoint{ - Name: endpoint.Name, - EnvPath: endpointEnvPath, - LocalHost: endpoint.Tunnel.LocalHost, - LocalPort: endpoint.Tunnel.LocalPort, - }) endpointServiceName := clientServiceName(spec, endpoint) endpointServiceInstall, err := newSystemdInstall(spec.ServiceScope, endpointServiceName) @@ -148,11 +160,91 @@ func InstallClient(spec ClientConfig) (ClientInstallResult, error) { } servicePaths = append(servicePaths, endpointServiceInstall.servicePath) } - if err := os.WriteFile(wrapperPath, RenderClientWrapperForEndpoints(spec, wrapperEndpoints), 0o755); err != nil { - return ClientInstallResult{}, fmt.Errorf("write wrapper: %w", err) + + if clientModeIncludes(spec.Mode, ClientModeProxy) { + if err := os.WriteFile(wrapperPath, RenderClientWrapperForEndpoints(spec, wrapperEndpoints), 0o755); err != nil { + return ClientInstallResult{}, fmt.Errorf("write wrapper: %w", err) + } + } + + result := ClientInstallResult{ + ServicePath: servicePaths[0], + ServicePaths: servicePaths, + } + if clientModeIncludes(spec.Mode, ClientModeProxy) { + result.EnvPath = envPath + result.EnvPaths = envPaths + result.WrapperPath = wrapperPath + } + + if clientModeIncludes(spec.Mode, ClientModeClaude) { + endpoint := endpoints[0] + claudeEnvPath := filepath.Join(spec.InstallDir, "claude.env") + claudeConfigPath := filepath.Join(spec.InstallDir, "claude-client.yaml") + claudeWrapperPath, err := clientWrapperPath(spec.ClaudeCode.WrapperName) + if err != nil { + return ClientInstallResult{}, err + } + if err := os.MkdirAll(filepath.Dir(claudeWrapperPath), 0o755); err != nil { + return ClientInstallResult{}, fmt.Errorf("mkdir claude wrapper dir: %w", err) + } + if err := os.MkdirAll(filepath.Dir(spec.ClaudeCode.BinaryOutput), 0o755); err != nil { + return ClientInstallResult{}, fmt.Errorf("mkdir claude binary dir: %w", err) + } + + claudeAdminInstall, err := newSystemdInstall(spec.ServiceScope, spec.ClaudeCode.AdminServiceName) + if err != nil { + return ClientInstallResult{}, err + } + if err := os.MkdirAll(filepath.Dir(claudeAdminInstall.servicePath), 0o755); err != nil { + return ClientInstallResult{}, fmt.Errorf("mkdir claude admin service dir: %w", err) + } + + claudeServiceInstall, err := newSystemdInstall(spec.ServiceScope, spec.ClaudeCode.ServiceName) + if err != nil { + return ClientInstallResult{}, err + } + if err := os.MkdirAll(filepath.Dir(claudeServiceInstall.servicePath), 0o755); err != nil { + return ClientInstallResult{}, fmt.Errorf("mkdir claude service dir: %w", err) + } + + claudeConfigContent, err := RenderClaudeClientConfig(spec, endpoint) + if err != nil { + return ClientInstallResult{}, fmt.Errorf("render claude client config: %w", err) + } + if err := os.WriteFile(claudeEnvPath, RenderClaudeClientEnv(spec), 0o600); err != nil { + return ClientInstallResult{}, fmt.Errorf("write claude env: %w", err) + } + if err := os.WriteFile(claudeConfigPath, claudeConfigContent, 0o600); err != nil { + return ClientInstallResult{}, fmt.Errorf("write claude config: %w", err) + } + if err := os.WriteFile(claudeWrapperPath, RenderClaudeClientWrapper(spec, claudeEnvPath), 0o755); err != nil { + return ClientInstallResult{}, fmt.Errorf("write claude wrapper: %w", err) + } + if err := os.WriteFile(claudeAdminInstall.servicePath, RenderClaudeAdminService(spec, endpoint), 0o644); err != nil { + return ClientInstallResult{}, fmt.Errorf("write claude admin service: %w", err) + } + if err := os.WriteFile(claudeServiceInstall.servicePath, RenderClaudeClientService(spec, claudeConfigPath, clientServiceName(spec, endpoint), spec.ClaudeCode.AdminServiceName), 0o644); err != nil { + return ClientInstallResult{}, fmt.Errorf("write claude client service: %w", err) + } + + result.ClaudeEnvPath = claudeEnvPath + result.ClaudeWrapperPath = claudeWrapperPath + result.ClaudeConfigPath = claudeConfigPath + result.ClaudeServicePath = claudeServiceInstall.servicePath + result.ClaudeAdminServicePath = claudeAdminInstall.servicePath + result.ClaudeBinaryPath = spec.ClaudeCode.BinaryOutput } if !spec.WriteOnly { + if clientModeIncludes(spec.Mode, ClientModeClaude) { + if err := ensureGoToolchain(); err != nil { + return ClientInstallResult{}, err + } + if err := buildBinary(spec.ProjectRoot, spec.ClaudeCode.BinaryOutput); err != nil { + return ClientInstallResult{}, err + } + } if err := runCommand("systemctl", serviceInstall.systemctlArgs("daemon-reload")...); err != nil { return ClientInstallResult{}, err } @@ -162,15 +254,17 @@ func InstallClient(spec ClientConfig) (ClientInstallResult, error) { return ClientInstallResult{}, err } } + if clientModeIncludes(spec.Mode, ClientModeClaude) { + if err := runCommand("systemctl", serviceInstall.systemctlArgs("enable", "--now", spec.ClaudeCode.AdminServiceName+".service")...); err != nil { + return ClientInstallResult{}, err + } + if err := runCommand("systemctl", serviceInstall.systemctlArgs("enable", "--now", spec.ClaudeCode.ServiceName+".service")...); err != nil { + return ClientInstallResult{}, err + } + } } - return ClientInstallResult{ - EnvPath: envPath, - EnvPaths: envPaths, - WrapperPath: wrapperPath, - ServicePath: servicePaths[0], - ServicePaths: servicePaths, - }, nil + return result, nil } func buildBinary(projectRoot, output string) error { diff --git a/internal/deploy/render.go b/internal/deploy/render.go index b16a680..d810400 100644 --- a/internal/deploy/render.go +++ b/internal/deploy/render.go @@ -2,6 +2,7 @@ package deploy import ( "fmt" + "net" "path/filepath" "sort" "strconv" @@ -9,6 +10,7 @@ import ( "codex-gateway/internal/auth" "codex-gateway/internal/config" + "gopkg.in/yaml.v3" ) type clientWrapperEndpoint struct { @@ -50,6 +52,11 @@ func RenderVPSEnv(spec VPSConfig) ([]byte, error) { "METRICS_ENABLED": strconv.FormatBool(spec.Runtime.MetricsEnabled), "ALLOW_PUBLIC_ADMIN": strconv.FormatBool(spec.Runtime.AllowPublicAdmin), "ALLOW_INSECURE_PUBLIC_PROXY": strconv.FormatBool(spec.Runtime.AllowInsecurePublicProxy), + "CLAUDE_OAUTH_ENABLED": strconv.FormatBool(spec.ClaudeOAuth.Enabled), + "CLAUDE_OAUTH_REFRESH_TOKEN": spec.ClaudeOAuth.RefreshToken, + "CLAUDE_OAUTH_CLIENT_ID": spec.ClaudeOAuth.ClientID, + "CLAUDE_OAUTH_SCOPES": strings.Join(spec.ClaudeOAuth.Scopes, ","), + "CLAUDE_OAUTH_TOKEN_URL": spec.ClaudeOAuth.TokenURL, } for key, value := range spec.EnvOverrides { env[key] = value @@ -89,6 +96,11 @@ func RenderVPSEnv(spec VPSConfig) ([]byte, error) { "METRICS_ENABLED", "ALLOW_PUBLIC_ADMIN", "ALLOW_INSECURE_PUBLIC_PROXY", + "CLAUDE_OAUTH_ENABLED", + "CLAUDE_OAUTH_REFRESH_TOKEN", + "CLAUDE_OAUTH_CLIENT_ID", + "CLAUDE_OAUTH_SCOPES", + "CLAUDE_OAUTH_TOKEN_URL", } extraKeys := make([]string, 0, len(env)) for key := range env { @@ -174,6 +186,15 @@ func RenderClientEnvForEndpoint(proxy ClientProxy, endpoint ClientEndpoint) []by return []byte(builder.String()) } +func RenderClaudeClientEnv(spec ClientConfig) []byte { + var builder strings.Builder + builder.WriteString("# Generated by codex-gateway deploy client\n") + builder.WriteString("export ANTHROPIC_BASE_URL=" + shellQuote(fmt.Sprintf("http://%s:%d", spec.ClaudeCode.ListenHost, spec.ClaudeCode.ListenPort)) + "\n") + builder.WriteString("export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC='1'\n") + builder.WriteString("export CLAUDE_CODE_OAUTH_TOKEN='gateway-managed'\n") + return []byte(builder.String()) +} + func RenderClientWrapper(spec ClientConfig, envPath string) []byte { primary := primaryClientEndpoint(spec) return renderClientWrapper(spec.WrapperName, []clientWrapperEndpoint{{ @@ -188,6 +209,15 @@ func RenderClientWrapperForEndpoints(spec ClientConfig, endpoints []clientWrappe return renderClientWrapper(spec.WrapperName, endpoints) } +func RenderClaudeClientWrapper(spec ClientConfig, envPath string) []byte { + return renderClientWrapper(spec.ClaudeCode.WrapperName, []clientWrapperEndpoint{{ + Name: "primary", + EnvPath: envPath, + LocalHost: spec.ClaudeCode.ListenHost, + LocalPort: spec.ClaudeCode.ListenPort, + }}) +} + func renderClientWrapper(wrapperName string, endpoints []clientWrapperEndpoint) []byte { usage := fmt.Sprintf("usage: %s [--endpoint name] [args...]", wrapperName) if len(endpoints) == 0 { @@ -259,11 +289,30 @@ func RenderClientService(spec ClientConfig) []byte { } func RenderClientServiceForEndpoint(spec ClientConfig, endpoint ClientEndpoint) []byte { + description := "SSH tunnel to Codex Gateway" + if isMultiEndpointClient(spec) { + description = fmt.Sprintf("SSH tunnel to Codex Gateway (%s)", endpoint.Name) + } + return renderTunnelService(spec.ServiceScope, endpoint, endpoint.Tunnel, description) +} + +func RenderClaudeAdminService(spec ClientConfig, endpoint ClientEndpoint) []byte { + description := "SSH admin tunnel to Codex Gateway" + adminTunnel := ClientTunnel{ + LocalHost: spec.ClaudeCode.AdminLocalHost, + LocalPort: spec.ClaudeCode.AdminLocalPort, + RemoteHost: spec.ClaudeCode.AdminRemoteHost, + RemotePort: spec.ClaudeCode.AdminRemotePort, + } + return renderTunnelService(spec.ServiceScope, endpoint, adminTunnel, description) +} + +func renderTunnelService(scope string, endpoint ClientEndpoint, tunnel ClientTunnel, description string) []byte { sshArgs := []string{ "/usr/bin/ssh", "-N", "-L", - fmt.Sprintf("%s:%d:%s:%d", endpoint.Tunnel.LocalHost, endpoint.Tunnel.LocalPort, endpoint.Tunnel.RemoteHost, endpoint.Tunnel.RemotePort), + fmt.Sprintf("%s:%d:%s:%d", tunnel.LocalHost, tunnel.LocalPort, tunnel.RemoteHost, tunnel.RemotePort), "-o", "ExitOnForwardFailure=yes", "-o", @@ -280,13 +329,9 @@ func RenderClientServiceForEndpoint(spec ClientConfig, endpoint ClientEndpoint) sshArgs = append(sshArgs, endpoint.SSH.ExtraArgs...) sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", endpoint.SSH.User, endpoint.SSH.Host)) wantedBy := "default.target" - if spec.ServiceScope == ServiceScopeSystem { + if scope == ServiceScopeSystem { wantedBy = "multi-user.target" } - description := "SSH tunnel to Codex Gateway" - if isMultiEndpointClient(spec) { - description = fmt.Sprintf("SSH tunnel to Codex Gateway (%s)", endpoint.Name) - } return []byte(fmt.Sprintf(`[Unit] Description=%s @@ -304,6 +349,77 @@ WantedBy=%s `, description, strings.Join(quoteSystemdArgs(sshArgs), " "), wantedBy)) } +func RenderClaudeClientConfig(spec ClientConfig, endpoint ClientEndpoint) ([]byte, error) { + payload := map[string]any{ + "listen_addr": net.JoinHostPort(spec.ClaudeCode.ListenHost, strconv.Itoa(spec.ClaudeCode.ListenPort)), + "upstream_url": spec.ClaudeCode.UpstreamURL, + "proxy_url": fmt.Sprintf("http://%s:%s@%s:%d", spec.Proxy.Username, spec.Proxy.Password, endpoint.Tunnel.LocalHost, endpoint.Tunnel.LocalPort), + "oauth_broker_url": fmt.Sprintf("http://%s:%d/claude/oauth/token", spec.ClaudeCode.AdminLocalHost, spec.ClaudeCode.AdminLocalPort), + "oauth_username": spec.Proxy.Username, + "oauth_password": spec.Proxy.Password, + "tls_insecure_skip_verify": spec.ClaudeCode.TLSInsecureSkipVerify, + "log_level": "info", + "log_format": "json", + "identity": map[string]any{ + "device_id": spec.ClaudeCode.Identity.DeviceID, + "email": spec.ClaudeCode.Identity.Email, + }, + "env": map[string]any{ + "platform": spec.ClaudeCode.Env.Platform, + "platform_raw": spec.ClaudeCode.Env.PlatformRaw, + "arch": spec.ClaudeCode.Env.Arch, + "node_version": spec.ClaudeCode.Env.NodeVersion, + "terminal": spec.ClaudeCode.Env.Terminal, + "package_managers": spec.ClaudeCode.Env.PackageManagers, + "runtimes": spec.ClaudeCode.Env.Runtimes, + "is_running_with_bun": spec.ClaudeCode.Env.IsRunningWithBun, + "is_claude_ai_auth": spec.ClaudeCode.Env.IsClaudeAIAuth, + "version": spec.ClaudeCode.Env.Version, + "version_base": spec.ClaudeCode.Env.VersionBase, + "build_time": spec.ClaudeCode.Env.BuildTime, + "deployment_environment": spec.ClaudeCode.Env.DeploymentEnvironment, + "vcs": spec.ClaudeCode.Env.VCS, + }, + "prompt_env": map[string]any{ + "platform": spec.ClaudeCode.PromptEnv.Platform, + "shell": spec.ClaudeCode.PromptEnv.Shell, + "os_version": spec.ClaudeCode.PromptEnv.OSVersion, + "working_dir": spec.ClaudeCode.PromptEnv.WorkingDir, + }, + "process": map[string]any{ + "constrained_memory": spec.ClaudeCode.Process.ConstrainedMemory, + "rss_range": spec.ClaudeCode.Process.RSSRange, + "heap_total_range": spec.ClaudeCode.Process.HeapTotalRange, + "heap_used_range": spec.ClaudeCode.Process.HeapUsedRange, + }, + } + content, err := yaml.Marshal(payload) + if err != nil { + return nil, err + } + return content, nil +} + +func RenderClaudeClientService(spec ClientConfig, configPath, proxyServiceName, adminServiceName string) []byte { + wantedBy := "default.target" + if spec.ServiceScope == ServiceScopeSystem { + wantedBy = "multi-user.target" + } + var builder strings.Builder + builder.WriteString("[Unit]\n") + builder.WriteString("Description=Codex Gateway Claude rewrite client\n") + builder.WriteString("After=network-online.target " + proxyServiceName + ".service " + adminServiceName + ".service\n") + builder.WriteString("Wants=network-online.target " + proxyServiceName + ".service " + adminServiceName + ".service\n\n") + builder.WriteString("[Service]\n") + builder.WriteString("Type=simple\n") + builder.WriteString("ExecStart=" + strings.Join(quoteSystemdArgs([]string{spec.ClaudeCode.BinaryOutput, "claude-client", "-config", configPath}), " ") + "\n") + builder.WriteString("Restart=always\n") + builder.WriteString("RestartSec=5\n\n") + builder.WriteString("[Install]\n") + builder.WriteString("WantedBy=" + wantedBy + "\n") + return []byte(builder.String()) +} + func joinInts(values []int) string { out := make([]string, 0, len(values)) for _, value := range values { diff --git a/internal/deploy/spec.go b/internal/deploy/spec.go index e5d05c6..f0d5026 100644 --- a/internal/deploy/spec.go +++ b/internal/deploy/spec.go @@ -19,6 +19,7 @@ type VPSConfig struct { WriteOnly bool `yaml:"write_only"` Users []ProxyUser `yaml:"users"` Runtime VPSRuntime `yaml:"runtime"` + ClaudeOAuth VPSClaudeOAuth `yaml:"claude_oauth"` EnvOverrides map[string]string `yaml:"env_overrides"` } @@ -58,16 +59,27 @@ type VPSRuntime struct { AllowInsecurePublicProxy bool `yaml:"allow_insecure_public_proxy"` } +type VPSClaudeOAuth struct { + Enabled bool `yaml:"enabled"` + RefreshToken string `yaml:"refresh_token"` + ClientID string `yaml:"client_id"` + TokenURL string `yaml:"token_url"` + Scopes []string `yaml:"scopes"` +} + type ClientConfig struct { + ProjectRoot string `yaml:"project_root"` InstallDir string `yaml:"install_dir"` ServiceName string `yaml:"service_name"` WrapperName string `yaml:"wrapper_name"` ServiceScope string `yaml:"service_scope"` WriteOnly bool `yaml:"write_only"` + Mode string `yaml:"mode"` SSH ClientSSH `yaml:"ssh"` Tunnel ClientTunnel `yaml:"tunnel"` Endpoints []ClientEndpoint `yaml:"endpoints"` Proxy ClientProxy `yaml:"proxy"` + ClaudeCode ClientClaudeCode `yaml:"claude_code"` } type ClientEndpoint struct { @@ -99,6 +111,67 @@ type ClientProxy struct { NoProxy []string `yaml:"no_proxy"` } +type ClientClaudeCode struct { + ServiceName string `yaml:"service_name"` + WrapperName string `yaml:"wrapper_name"` + AdminServiceName string `yaml:"admin_service_name"` + ListenHost string `yaml:"listen_host"` + ListenPort int `yaml:"listen_port"` + AdminLocalHost string `yaml:"admin_local_host"` + AdminLocalPort int `yaml:"admin_local_port"` + AdminRemoteHost string `yaml:"admin_remote_host"` + AdminRemotePort int `yaml:"admin_remote_port"` + UpstreamURL string `yaml:"upstream_url"` + BinaryOutput string `yaml:"binary_output"` + TLSInsecureSkipVerify bool `yaml:"tls_insecure_skip_verify"` + Identity ClaudeIdentity `yaml:"identity"` + Env ClaudeEnvProfile `yaml:"env"` + PromptEnv ClaudePromptProfile `yaml:"prompt_env"` + Process ClaudeProcessProfile `yaml:"process"` +} + +type ClaudeIdentity struct { + DeviceID string `yaml:"device_id"` + Email string `yaml:"email"` +} + +type ClaudeEnvProfile struct { + Platform string `yaml:"platform"` + PlatformRaw string `yaml:"platform_raw"` + Arch string `yaml:"arch"` + NodeVersion string `yaml:"node_version"` + Terminal string `yaml:"terminal"` + PackageManagers string `yaml:"package_managers"` + Runtimes string `yaml:"runtimes"` + IsRunningWithBun bool `yaml:"is_running_with_bun"` + IsClaudeAIAuth bool `yaml:"is_claude_ai_auth"` + Version string `yaml:"version"` + VersionBase string `yaml:"version_base"` + BuildTime string `yaml:"build_time"` + DeploymentEnvironment string `yaml:"deployment_environment"` + VCS string `yaml:"vcs"` +} + +type ClaudePromptProfile struct { + Platform string `yaml:"platform"` + Shell string `yaml:"shell"` + OSVersion string `yaml:"os_version"` + WorkingDir string `yaml:"working_dir"` +} + +type ClaudeProcessProfile struct { + ConstrainedMemory int64 `yaml:"constrained_memory"` + RSSRange [2]int64 `yaml:"rss_range"` + HeapTotalRange [2]int64 `yaml:"heap_total_range"` + HeapUsedRange [2]int64 `yaml:"heap_used_range"` +} + +const ( + ClientModeProxy = "proxy" + ClientModeClaude = "claude" + ClientModeBoth = "both" +) + func LoadVPSConfig(path string) (VPSConfig, error) { var spec VPSConfig if err := loadYAML(path, &spec); err != nil { @@ -215,6 +288,26 @@ func normalizeVPSConfig(spec VPSConfig) (VPSConfig, error) { runtime.LogFormat = "json" } spec.Runtime = runtime + if spec.ClaudeOAuth.Enabled { + if strings.TrimSpace(spec.ClaudeOAuth.ClientID) == "" { + spec.ClaudeOAuth.ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + } + if strings.TrimSpace(spec.ClaudeOAuth.TokenURL) == "" { + spec.ClaudeOAuth.TokenURL = "https://platform.claude.com/v1/oauth/token" + } + if len(spec.ClaudeOAuth.Scopes) == 0 { + spec.ClaudeOAuth.Scopes = []string{ + "user:inference", + "user:profile", + "user:sessions:claude_code", + "user:mcp_servers", + "user:file_upload", + } + } + if strings.TrimSpace(spec.ClaudeOAuth.RefreshToken) == "" { + return VPSConfig{}, fmt.Errorf("claude_oauth.refresh_token is required when claude_oauth.enabled=true") + } + } if len(spec.Users) == 0 { return VPSConfig{}, fmt.Errorf("at least one proxy user is required") @@ -232,10 +325,20 @@ func normalizeVPSConfig(spec VPSConfig) (VPSConfig, error) { } func normalizeClientConfig(spec ClientConfig) (ClientConfig, error) { + cwd, err := os.Getwd() + if err != nil { + return ClientConfig{}, fmt.Errorf("getwd: %w", err) + } home, err := os.UserHomeDir() if err != nil { return ClientConfig{}, fmt.Errorf("home dir: %w", err) } + if strings.TrimSpace(spec.ProjectRoot) == "" { + spec.ProjectRoot = cwd + } + if !filepath.IsAbs(spec.ProjectRoot) { + spec.ProjectRoot = filepath.Join(cwd, spec.ProjectRoot) + } if strings.TrimSpace(spec.InstallDir) == "" { spec.InstallDir = filepath.Join(home, ".config", "codex-gateway") } @@ -248,6 +351,10 @@ func normalizeClientConfig(spec ClientConfig) (ClientConfig, error) { if strings.TrimSpace(spec.WrapperName) == "" { spec.WrapperName = "codex-gateway-proxy" } + spec.Mode, err = normalizeClientMode(spec.Mode) + if err != nil { + return ClientConfig{}, err + } spec.ServiceScope, err = normalizeServiceScope(spec.ServiceScope) if err != nil { return ClientConfig{}, err @@ -267,6 +374,21 @@ func normalizeClientConfig(spec ClientConfig) (ClientConfig, error) { spec.Endpoints = endpoints spec.SSH = endpoints[0].SSH spec.Tunnel = endpoints[0].Tunnel + if clientModeIncludes(spec.Mode, ClientModeClaude) { + if len(spec.Endpoints) > 1 { + return ClientConfig{}, fmt.Errorf("claude mode currently supports a single endpoint") + } + spec.ClaudeCode = normalizeClientClaudeCode(spec) + if strings.TrimSpace(spec.ClaudeCode.Identity.DeviceID) == "" { + return ClientConfig{}, fmt.Errorf("claude_code.identity.device_id is required when mode includes claude") + } + if strings.TrimSpace(spec.ClaudeCode.Env.Version) == "" { + return ClientConfig{}, fmt.Errorf("claude_code.env.version is required when mode includes claude") + } + if strings.TrimSpace(spec.ClaudeCode.PromptEnv.WorkingDir) == "" { + return ClientConfig{}, fmt.Errorf("claude_code.prompt_env.working_dir is required when mode includes claude") + } + } return spec, nil } @@ -453,3 +575,84 @@ func normalizeClientEndpointName(raw string, index int) (string, error) { } return name, nil } + +func normalizeClientMode(raw string) (string, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", ClientModeProxy: + return ClientModeProxy, nil + case ClientModeClaude: + return ClientModeClaude, nil + case ClientModeBoth: + return ClientModeBoth, nil + default: + return "", fmt.Errorf("unsupported mode %q; expected %q, %q, or %q", raw, ClientModeProxy, ClientModeClaude, ClientModeBoth) + } +} + +func clientModeIncludes(mode, target string) bool { + if mode == ClientModeBoth { + return true + } + return mode == target +} + +func normalizeClientClaudeCode(spec ClientConfig) ClientClaudeCode { + claude := spec.ClaudeCode + if strings.TrimSpace(claude.ServiceName) == "" { + claude.ServiceName = "codex-gateway-claude-client" + } + if strings.TrimSpace(claude.WrapperName) == "" { + claude.WrapperName = "codex-gateway-claude" + } + if strings.TrimSpace(claude.AdminServiceName) == "" { + claude.AdminServiceName = "codex-gateway-admin-tunnel" + } + if strings.TrimSpace(claude.ListenHost) == "" { + claude.ListenHost = "127.0.0.1" + } + if claude.ListenPort == 0 { + claude.ListenPort = 11443 + } + if strings.TrimSpace(claude.AdminLocalHost) == "" { + claude.AdminLocalHost = "127.0.0.1" + } + if claude.AdminLocalPort == 0 { + claude.AdminLocalPort = 19090 + } + if strings.TrimSpace(claude.AdminRemoteHost) == "" { + claude.AdminRemoteHost = "127.0.0.1" + } + if claude.AdminRemotePort == 0 { + claude.AdminRemotePort = 9090 + } + if strings.TrimSpace(claude.UpstreamURL) == "" { + claude.UpstreamURL = "https://api.anthropic.com" + } + if strings.TrimSpace(claude.BinaryOutput) == "" { + claude.BinaryOutput = filepath.Join(spec.InstallDir, "bin", "codex-gateway") + } else if !filepath.IsAbs(claude.BinaryOutput) { + claude.BinaryOutput = filepath.Join(spec.InstallDir, claude.BinaryOutput) + } + if strings.TrimSpace(claude.Env.PlatformRaw) == "" { + claude.Env.PlatformRaw = claude.Env.Platform + } + if strings.TrimSpace(claude.Env.VersionBase) == "" { + claude.Env.VersionBase = claude.Env.Version + } + if !claude.Env.IsClaudeAIAuth { + claude.Env.IsClaudeAIAuth = true + } + if claude.Process.ConstrainedMemory == 0 { + claude.Process.ConstrainedMemory = 34359738368 + } + if claude.Process.RSSRange == [2]int64{} { + claude.Process.RSSRange = [2]int64{300000000, 500000000} + } + if claude.Process.HeapTotalRange == [2]int64{} { + claude.Process.HeapTotalRange = [2]int64{40000000, 80000000} + } + if claude.Process.HeapUsedRange == [2]int64{} { + claude.Process.HeapUsedRange = [2]int64{100000000, 200000000} + } + return claude +}