Skip to content
Closed
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
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,70 @@ ocx service [install|start|stop|status|uninstall] # install/update/start backg
ocx update [--tag preview] # update opencodex; preview installs stay on @preview
```

### Public API through Cloudflare Tunnel

The dashboard's **API Access** page can publish the local Responses endpoint without changing the
loopback bind. A **Named Tunnel is the default**: it provides a stable hostname and supports normal
Server-Sent Events (SSE) streaming. Install Cloudflare's official
[`cloudflared` client](https://developers.cloudflare.com/tunnel/downloads/), create at least one
opencodex API key on the API Access page, and choose one of the two setup methods:

1. **Configure automatically.** Enter the Cloudflare account ID, zone ID, public hostname, and an
optional tunnel name, then paste a temporary scoped Cloudflare API token. opencodex creates the
remotely managed tunnel, published application, DNS record, and local connector. The API token
is used only for this setup request: it is cleared from the page and is never written to the
opencodex config or credential file. You may revoke it after setup.
2. **Use an existing tunnel.** Create or select a remotely managed tunnel, map its published
application to `http://127.0.0.1:<configured-opencodex-port>`, then enter its HTTPS public URL on
the API Access page. Paste either the tunnel runner token or the complete `cloudflared` install
command copied from Cloudflare; opencodex extracts the runner token from the command.

After a Named Tunnel is configured, **Reconfigure** defaults to the existing-tunnel method so a
runner token can be rotated without creating another Tunnel. Choosing automatic setup again creates
a new Tunnel and DNS record; it requires explicit confirmation, does not delete the previous
Cloudflare resources, and reminds you to remove them manually after verifying the new endpoint.

For automatic setup, scope the temporary API token to the one account and zone you intend to use,
with only these permissions:

- **Account · Cloudflare Tunnel · Edit**
- **Zone · DNS · Edit**

See Cloudflare's official [Tunnel setup guide](https://developers.cloudflare.com/tunnel/setup/) and
[API token guide](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/).
For either UI method, only the tunnel-specific runner token remains locally. It is stored in the
fixed `cloudflare-tunnel-token` file in the opencodex config directory (by default
`~/.opencodex/cloudflare-tunnel-token`) with `0600` permissions; its fingerprint, rather than the
secret, is stored in `config.json`. The token is not placed in the `cloudflared` command line or
returned by the management API.

After setup, click **Enable public access**. While the tunnel is running, the endpoint and copyable
curl example switch to its HTTPS URL; disabling it restores the local URL. Named tunnels use the
fixed configured opencodex port, so public access is refused if startup had to fall back to a
different port.

For headless or externally managed installations, the same Named Tunnel can instead be supplied
through environment variables:

```bash
export OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN="..."
export OPENCODEX_CLOUDFLARE_PUBLIC_URL="https://ocx.example.com"
ocx start
```

With `cloudflared` 2025.4.0 or newer, you can set `OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN_FILE` instead of putting the token in the environment,
and `OPENCODEX_CLOUDFLARED_PATH` when the executable is not on `PATH`.

Quick Tunnel is an explicit **advanced development mode**, never the default. To opt in, set
`"cloudflareTunnel": { "mode": "quick" }` in `~/.opencodex/config.json` before using the API Access
toggle. It creates a temporary random `trycloudflare.com` URL, has no SLA, is limited to 200
in-flight requests, and does **not** support SSE. Use it only for short non-streaming tests (or a
supported WebSocket client when opencodex WebSockets are enabled). See Cloudflare's
[Quick Tunnel limitations](https://developers.cloudflare.com/tunnel/setup/#quick-tunnels-development).

The Cloudflare ingress is intentionally data-plane-only: it serves `/v1/*` with a valid
`X-OpenCodex-API-Key`, while the dashboard and `/api/*` management routes remain local.

### Autostart: service vs shim

opencodex has two ways to auto-start the proxy:
Expand Down
56 changes: 56 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,62 @@ ocx service [install|start|stop|status|uninstall] # 安装/更新/启动后台
ocx update [--tag preview] # 更新 opencodex;preview 安装保持 @preview
```

### 通过 Cloudflare Tunnel 开启公网 API

仪表盘的 **API 访问** 页面可以在不改变 loopback 监听的情况下发布本地 Responses 端点。
**Named Tunnel 是默认模式**,它提供稳定域名,并支持常规 Server-Sent Events(SSE)流式传输。
先安装 Cloudflare 官方
[`cloudflared` 客户端](https://developers.cloudflare.com/tunnel/downloads/),在 API 访问页面至少创建一个
opencodex API 密钥,再选择下列一种配置方式:

1. **自动配置。** 填入 Cloudflare 账户 ID、区域 ID、公网域名和可选的 Tunnel 名称,然后粘贴一个
临时的最小权限 Cloudflare API Token。opencodex 会自动创建远程管理的 Tunnel、Published
application、DNS 记录和本地连接器。API Token 只用于这一次配置请求:页面会立即清空,
opencodex 不会将它写入配置或凭据文件。配置完成后可以撤销该 Token。
2. **使用已有 Tunnel。** 创建或选择一个远程管理的 Tunnel,将 Published application 映射到
`http://127.0.0.1:<opencodex 固定端口>`,然后在 API 访问页面填入 HTTPS 公网地址。可以粘贴
Tunnel runner token,也可以直接粘贴从 Cloudflare 复制的完整 `cloudflared` 安装命令;
opencodex 会从命令中提取 runner token。

Named Tunnel 配置完成后,点击**重新配置**会默认进入“使用已有 Tunnel”,可以轮换 runner token,
不会额外创建 Tunnel。若再次选择自动配置,系统会新建 Tunnel 和 DNS 记录,并要求明确确认;旧的
Cloudflare 资源不会自动删除,请在验证新端点后手动清理。

用于自动配置的临时 API Token 应只限定到本次使用的账户和区域,并且只授予以下权限:

- **Account · Cloudflare Tunnel · Edit**
- **Zone · DNS · Edit**

参阅 Cloudflare 官方 [Tunnel 配置指南](https://developers.cloudflare.com/tunnel/setup/) 和
[API Token 指南](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/)。两种 UI 配置方式最终都只会在本地
保留 Tunnel 专用的 runner token。它会写入 opencodex 配置目录下固定的 `cloudflare-tunnel-token`
文件(默认为 `~/.opencodex/cloudflare-tunnel-token`),权限设为 `0600`;`config.json` 只保存指纹,
不保存凭据原文。runner token 不会进入 `cloudflared` 命令行,也不会由管理 API 返回。

配置完成后点击**开启公网访问**。Tunnel 运行时,页面端点和可复制的 curl 示例会自动切换到
HTTPS 公网地址;关闭后恢复本地地址。Named Tunnel 使用固定的 opencodex 配置端口;如果启动时
因端口占用而切换到备用端口,系统会拒绝开启公网访问。

无界面或外部管理的部署也可以通过环境变量提供同一个 Named Tunnel:

```bash
export OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN="..."
export OPENCODEX_CLOUDFLARE_PUBLIC_URL="https://ocx.example.com"
ocx start
```

使用 `cloudflared` 2025.4.0 或更高版本时,也可以通过 `OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN_FILE` 代替环境中的 token;如果可执行文件不在
`PATH`,设置 `OPENCODEX_CLOUDFLARED_PATH`。

Quick Tunnel 只作为显式的**高级开发模式**,永远不会默认开启。如需使用,先在
`~/.opencodex/config.json` 中设置 `"cloudflareTunnel": { "mode": "quick" }`,再使用 API 访问页面的开关。
它会创建临时的随机 `trycloudflare.com` 地址,无 SLA、最多 200 个同时进行的请求,并且
**不支持 SSE**。它只适合短期非流式测试(或在开启 opencodex WebSocket 时供支持的 WebSocket 客户端使用)。
详见 Cloudflare [Quick Tunnel 限制](https://developers.cloudflare.com/tunnel/setup/#quick-tunnels-development)。

Cloudflare 公网入口只开放数据面:有效的 `X-OpenCodex-API-Key` 可以访问 `/v1/*`,仪表盘和
`/api/*` 管理接口仍然只允许本地访问。

### 自动启动:service vs shim

opencodex 提供两种自动启动代理的方式:
Expand Down
240 changes: 240 additions & 0 deletions gui/src/cloudflare-tunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
export type CloudflareTunnelStatus = "stopped" | "starting" | "running" | "stopping" | "error";
export type CloudflareTunnelMode = "quick" | "named";
export type CloudflareTunnelSetupMethod = "api" | "token";

export interface CloudflareTunnelState {
status: CloudflareTunnelStatus;
mode: CloudflareTunnelMode;
publicUrl: string | null;
supportsSse: boolean;
enabled: boolean;
canEnable: boolean;
canConfigure: boolean;
configured: boolean;
setupRequired: boolean;
configurationSource: string | null;
configurationEditable: boolean;
configuredPublicUrl: string | null;
originUrl: string | null;
setupError?: string;
error?: string;
}

export const STOPPED_CLOUDFLARE_TUNNEL: CloudflareTunnelState = {
status: "stopped",
mode: "named",
publicUrl: null,
supportsSse: true,
enabled: false,
canEnable: false,
canConfigure: false,
configured: false,
setupRequired: true,
configurationSource: null,
configurationEditable: true,
configuredPublicUrl: null,
originUrl: null,
};

export interface CloudflareTunnelApiSetupInput {
accountId: string;
zoneId: string;
hostname: string;
apiToken: string;
tunnelName?: string;
replaceExisting?: boolean;
}

export interface CloudflareTunnelTokenSetupInput {
publicUrl: string;
tunnelToken: string;
}

export type CloudflareTunnelSetupRequest =
| ({ method: "api"; enable: true } & CloudflareTunnelApiSetupInput)
| ({ method: "token"; enable: true } & CloudflareTunnelTokenSetupInput);

export interface CloudflareTunnelToggleRequest {
enabled: boolean;
mode?: CloudflareTunnelMode;
}

const STATUSES = new Set<CloudflareTunnelStatus>(["stopped", "starting", "running", "stopping", "error"]);
const MODES = new Set<CloudflareTunnelMode>(["quick", "named"]);

function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null ? value as Record<string, unknown> : null;
}

/** Accepts both the nested /api/keys shape and the direct tunnel endpoint shape. */
export function tunnelFromApiPayload(
payload: unknown,
fallback: CloudflareTunnelState = STOPPED_CLOUDFLARE_TUNNEL,
): CloudflareTunnelState {
const root = asRecord(payload);
const candidate = asRecord(root?.tunnel) ?? root;
if (!candidate) return { ...fallback };

const status = typeof candidate.status === "string" && STATUSES.has(candidate.status as CloudflareTunnelStatus)
? candidate.status as CloudflareTunnelStatus
: fallback.status;
const mode = typeof candidate.mode === "string" && MODES.has(candidate.mode as CloudflareTunnelMode)
? candidate.mode as CloudflareTunnelMode
: fallback.mode;
const publicUrl = typeof candidate.publicUrl === "string" || candidate.publicUrl === null
? candidate.publicUrl
: fallback.publicUrl;
const supportsSse = typeof candidate.supportsSse === "boolean"
? candidate.supportsSse
: fallback.supportsSse;
const enabled = typeof candidate.enabled === "boolean"
? candidate.enabled
: status === "starting" || status === "running" || status === "stopping" || publicUrl !== null
? true
: fallback.enabled;
const canEnable = typeof candidate.canEnable === "boolean"
? candidate.canEnable
: fallback.canEnable;
const canConfigure = typeof candidate.canConfigure === "boolean"
? candidate.canConfigure
: typeof candidate.canEnable === "boolean"
? candidate.canEnable
: fallback.canConfigure;
const configuredPublicUrl = typeof candidate.configuredPublicUrl === "string" || candidate.configuredPublicUrl === null
? candidate.configuredPublicUrl
: fallback.configuredPublicUrl;
const originUrl = typeof candidate.originUrl === "string" || candidate.originUrl === null
? candidate.originUrl
: fallback.originUrl;
const configurationSource = typeof candidate.configurationSource === "string" && candidate.configurationSource.trim()
? candidate.configurationSource.trim()
: candidate.configurationSource === null
? null
: fallback.configurationSource;
const configurationEditable = typeof candidate.configurationEditable === "boolean"
? candidate.configurationEditable
: fallback.configurationEditable;
const configured = typeof candidate.configured === "boolean"
? candidate.configured
: mode === "quick" || (mode === "named" && (configuredPublicUrl !== null || publicUrl !== null))
? true
: fallback.configured;
const setupRequired = typeof candidate.setupRequired === "boolean"
? candidate.setupRequired
: !configured;
const error = typeof candidate.error === "string" && candidate.error.trim()
? candidate.error.trim()
: undefined;
const setupError = typeof candidate.setupError === "string" && candidate.setupError.trim()
? candidate.setupError.trim()
: undefined;

return {
status,
mode,
publicUrl,
supportsSse,
enabled,
canEnable,
canConfigure,
configured,
setupRequired,
configurationSource,
configurationEditable,
configuredPublicUrl,
originUrl,
...(error ? { error } : {}),
...(setupError ? { setupError } : {}),
};
}

export function buildCloudflareTunnelSetupRequest(
method: "api",
input: CloudflareTunnelApiSetupInput,
): CloudflareTunnelSetupRequest;
export function buildCloudflareTunnelSetupRequest(
method: "token",
input: CloudflareTunnelTokenSetupInput,
): CloudflareTunnelSetupRequest;
export function buildCloudflareTunnelSetupRequest(
method: CloudflareTunnelSetupMethod,
input: CloudflareTunnelApiSetupInput | CloudflareTunnelTokenSetupInput,
): CloudflareTunnelSetupRequest {
if (method === "api") {
const api = input as CloudflareTunnelApiSetupInput;
const tunnelName = api.tunnelName?.trim();
return {
method,
accountId: api.accountId.trim(),
zoneId: api.zoneId.trim(),
hostname: api.hostname.trim(),
apiToken: api.apiToken.trim(),
...(tunnelName ? { tunnelName } : {}),
...(api.replaceExisting ? { replaceExisting: true } : {}),
enable: true,
};
}

const token = input as CloudflareTunnelTokenSetupInput;
return {
method,
publicUrl: token.publicUrl.trim(),
tunnelToken: token.tunnelToken.trim(),
enable: true,
};
}

export function buildCloudflareTunnelToggleRequest(
enabled: boolean,
mode?: CloudflareTunnelMode,
): CloudflareTunnelToggleRequest {
return {
enabled,
...(enabled && mode ? { mode } : {}),
};
}

/** The management API is the only authority for the currently advertised endpoint. */
export function endpointFromApiPayload(payload: unknown, fallback = ""): string {
const endpoint = asRecord(payload)?.endpoint;
return typeof endpoint === "string" && endpoint.trim() ? endpoint : fallback;
}

export function isTunnelTransitioning(status: CloudflareTunnelStatus): boolean {
return status === "starting" || status === "stopping";
}

export function isTunnelEnabled(tunnel: CloudflareTunnelState): boolean {
return tunnel.enabled;
}

export function canToggleTunnel(
tunnel: CloudflareTunnelState,
requestPending: boolean,
): boolean {
if (requestPending || isTunnelTransitioning(tunnel.status)) return false;
if (isTunnelEnabled(tunnel)) return true;
return shouldOpenTunnelSetup(tunnel) ? tunnel.canConfigure : tunnel.canEnable;
}

export function shouldOpenTunnelSetup(tunnel: CloudflareTunnelState): boolean {
return !isTunnelEnabled(tunnel) && (tunnel.setupRequired || !tunnel.configured);
}

export function canReconfigureTunnel(tunnel: CloudflareTunnelState, requestPending: boolean): boolean {
return tunnel.configured
&& !isTunnelEnabled(tunnel)
&& tunnel.configurationSource !== "environment"
&& tunnel.canConfigure
&& tunnel.configurationEditable
&& !requestPending;
}

export function tunnelStatusTone(
status: CloudflareTunnelStatus,
): "green" | "amber" | "red" | "muted" {
if (status === "running") return "green";
if (status === "starting" || status === "stopping") return "amber";
if (status === "error") return "red";
return "muted";
}
Loading
Loading