Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 123 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 代你部署

Expand All @@ -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 上部署服务端

Expand Down Expand Up @@ -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
Expand All @@ -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 <name>` 或 `CODEX_GATEWAY_ENDPOINT=<name>` 指定。这是 client 侧 failover,不是服务端集群。
### 3. 手动接入

#### 方式 A:手动打通 tunnel 并设置代理环境变量
#### 方式 A:手动使用 `proxy mode`

先打通到 VPS 的本地隧道:

Expand All @@ -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>
```

先改:

- `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`:

Expand Down Expand Up @@ -163,13 +216,35 @@ kill -HUP <pid>

`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`。

## ✨ 核心特性

Expand All @@ -178,7 +253,8 @@ kill -HUP <pid>
- 出口约束:目标 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 代理有什么不同
Expand All @@ -190,16 +266,22 @@ kill -HUP <pid>
- 普通代理通常不做 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)
Expand Down
43 changes: 42 additions & 1 deletion cmd/codex-gateway/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package main

import (
"codex-gateway/internal/claudeclient"
"codex-gateway/internal/claudeoauth"
"context"
"errors"
"fmt"
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
Expand All @@ -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,
Expand All @@ -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(),
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions cmd/codex-gateway/runtime_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading