Add Cloudflare Quick Tunnel controls to endpoint page

This commit is contained in:
R.D.
2026-03-29 14:04:50 -04:00
parent 8af9bd1ac3
commit 3cb34ad827
9 changed files with 1052 additions and 41 deletions

View File

@@ -876,6 +876,14 @@ docker compose --profile base up -d
docker compose --profile cli up -d
```
Dashboard support for Docker deployments now includes a one-click **Cloudflare Quick Tunnel** on `Dashboard → Endpoints`. The first enable downloads `cloudflared` only when needed, starts a temporary tunnel to your current `/v1` endpoint, and shows the generated `https://*.trycloudflare.com/v1` URL directly below your normal public URL.
Notes:
- Quick Tunnel URLs are temporary and change after every restart.
- Managed install currently supports Linux, macOS, and Windows on `x64` / `arm64`.
- Set `CLOUDFLARED_BIN=/absolute/path/to/cloudflared` if you want OmniRoute to use an existing binary instead of downloading one.
**Using Docker Compose with Caddy (HTTPS Auto-TLS):**
OmniRoute can be securely exposed using Caddy's automatic SSL provisioning. Ensure your domain's DNS A record points to your server's IP.

View File

@@ -216,23 +216,23 @@ Response example:
### Settings
| Endpoint | Method | Description |
| ------------------------------- | ------- | ---------------------- |
| `/api/settings` | GET/PUT/PATCH | General settings |
| `/api/settings/proxy` | GET/PUT | Network proxy config |
| `/api/settings/proxy/test` | POST | Test proxy connection |
| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist |
| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget |
| `/api/settings/system-prompt` | GET/PUT | Global system prompt |
| Endpoint | Method | Description |
| ------------------------------- | ------------- | ---------------------- |
| `/api/settings` | GET/PUT/PATCH | General settings |
| `/api/settings/proxy` | GET/PUT | Network proxy config |
| `/api/settings/proxy/test` | POST | Test proxy connection |
| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist |
| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget |
| `/api/settings/system-prompt` | GET/PUT | Global system prompt |
### Monitoring
| Endpoint | Method | Description |
| ------------------------ | ---------- | ----------------------- |
| `/api/sessions` | GET | Active session tracking |
| `/api/rate-limits` | GET | Per-account rate limits |
| Endpoint | Method | Description |
| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------- |
| `/api/sessions` | GET | Active session tracking |
| `/api/rate-limits` | GET | Per-account rate limits |
| `/api/monitoring/health` | GET | Health check + provider summary (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) |
| `/api/cache/stats` | GET/DELETE | Cache stats / clear |
| `/api/cache/stats` | GET/DELETE | Cache stats / clear |
### Backup & Export/Import
@@ -253,6 +253,13 @@ Response example:
| `/api/sync/initialize` | POST | Initialize sync |
| `/api/cloud/*` | Various | Cloud management |
### Tunnels
| Endpoint | Method | Description |
| -------------------------- | ------ | ----------------------------------------------------------------------- |
| `/api/tunnels/cloudflared` | GET | Read Cloudflare Quick Tunnel install/runtime status for the dashboard |
| `/api/tunnels/cloudflared` | POST | Enable or disable the Cloudflare Quick Tunnel (`action=enable/disable`) |
### CLI Tools
| Endpoint | Method | Description |
@@ -277,12 +284,12 @@ GET response includes `agents[]` (id, name, binary, version, installed, protocol
### Resilience & Rate Limits
| Endpoint | Method | Description |
| ----------------------- | ------- | ------------------------------- |
| `/api/resilience` | GET/PATCH | Get/update resilience profiles |
| `/api/resilience/reset` | POST | Reset circuit breakers |
| `/api/rate-limits` | GET | Per-account rate limit status |
| `/api/rate-limit` | GET | Global rate limit configuration |
| Endpoint | Method | Description |
| ----------------------- | --------- | ------------------------------- |
| `/api/resilience` | GET/PATCH | Get/update resilience profiles |
| `/api/resilience/reset` | POST | Reset circuit breakers |
| `/api/rate-limits` | GET | Per-account rate limit status |
| `/api/rate-limit` | GET | Global rate limit configuration |
### Evals

View File

@@ -507,24 +507,25 @@ post_install() {
### Environment Variables
| Variable | Default | Description |
| ------------------------- | ------------------------------------ | ------------------------------------------------------- |
| `JWT_SECRET` | `omniroute-default-secret-change-me` | JWT signing secret (**change in production**) |
| `INITIAL_PASSWORD` | `123456` | First login password |
| `DATA_DIR` | `~/.omniroute` | Data directory (db, usage, logs) |
| `PORT` | framework default | Service port (`20128` in examples) |
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
| `NODE_ENV` | runtime default | Set `production` for deploy |
| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL |
| `CLOUD_URL` | `https://omniroute.dev` | Cloud sync endpoint base URL |
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys |
| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` |
| `ALLOW_API_KEY_REVEAL` | `false` | Allow Api Manager to copy full API keys on demand |
| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs |
| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (behind HTTPS reverse proxy) |
| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit in MB |
| `PROMPT_CACHE_MAX_SIZE` | `50` | Max prompt cache entries |
| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max semantic cache entries |
| Variable | Default | Description |
| ------------------------- | ------------------------------------ | ---------------------------------------------------------------- |
| `JWT_SECRET` | `omniroute-default-secret-change-me` | JWT signing secret (**change in production**) |
| `INITIAL_PASSWORD` | `123456` | First login password |
| `DATA_DIR` | `~/.omniroute` | Data directory (db, usage, logs) |
| `PORT` | framework default | Service port (`20128` in examples) |
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
| `NODE_ENV` | runtime default | Set `production` for deploy |
| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL |
| `CLOUD_URL` | `https://omniroute.dev` | Cloud sync endpoint base URL |
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys |
| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` |
| `ALLOW_API_KEY_REVEAL` | `false` | Allow Api Manager to copy full API keys on demand |
| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs |
| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (behind HTTPS reverse proxy) |
| `CLOUDFLARED_BIN` | unset | Use an existing `cloudflared` binary instead of managed download |
| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit in MB |
| `PROMPT_CACHE_MAX_SIZE` | `50` | Max prompt cache entries |
| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max semantic cache entries |
For the full environment variable reference, see the [README](../README.md).
@@ -639,6 +640,14 @@ Returns models grouped by provider with types (`chat`, `embedding`, `image`).
- Automatic background sync with timeout + fail-fast
- Prefer server-side `BASE_URL`/`CLOUD_URL` in production
### Cloudflare Quick Tunnel
- Available in **Dashboard → Endpoints** for Docker and other self-hosted deployments
- Creates a temporary `https://*.trycloudflare.com` URL that forwards to your current OpenAI-compatible `/v1` endpoint
- First enable installs `cloudflared` only when needed; later restarts reuse the same managed binary
- Tunnel URLs are ephemeral and change every time you stop/start the tunnel
- Set `CLOUDFLARED_BIN` if you prefer using a preinstalled `cloudflared` binary instead of the managed download
### LLM Gateway Intelligence (Phase 9)
- **Semantic Cache** — Auto-caches non-streaming, temperature=0 responses (bypass with `X-OmniRoute-No-Cache: true`)

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useCallback } from "react";
import PropTypes from "prop-types";
import Link from "next/link";
import { Card, Button, Input, Modal, CardSkeleton, SegmentedControl } from "@/shared/components";
@@ -11,6 +11,36 @@ import { useTranslations } from "next-intl";
const BUILD_TIME_CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL || null;
const CLOUD_ACTION_TIMEOUT_MS = 15000;
type TranslationValues = Record<string, string | number | boolean | Date>;
type CloudflaredTunnelPhase =
| "unsupported"
| "not_installed"
| "stopped"
| "starting"
| "running"
| "error";
type CloudflaredTunnelStatus = {
supported: boolean;
installed: boolean;
managedInstall: boolean;
installSource: string | null;
binaryPath: string | null;
running: boolean;
pid: number | null;
publicUrl: string | null;
apiUrl: string | null;
targetUrl: string;
phase: CloudflaredTunnelPhase;
lastError: string | null;
logPath: string;
};
type TunnelNotice = {
type: "success" | "error" | "info";
message: string;
};
export default function APIPageClient({ machineId }) {
const [resolvedMachineId, setResolvedMachineId] = useState(machineId || "");
const t = useTranslations("endpoint");
@@ -36,9 +66,27 @@ export default function APIPageClient({ machineId }) {
const [mcpStatus, setMcpStatus] = useState<any>(null);
const [a2aStatus, setA2aStatus] = useState<any>(null);
const [searchProviders, setSearchProviders] = useState<any[]>([]);
const [cloudflaredStatus, setCloudflaredStatus] = useState<CloudflaredTunnelStatus | null>(null);
const [cloudflaredBusy, setCloudflaredBusy] = useState(false);
const [cloudflaredNotice, setCloudflaredNotice] = useState<TunnelNotice | null>(null);
const { copied, copy } = useCopyToClipboard();
const translateOrFallback = useCallback(
(key: string, fallback: string, values?: TranslationValues) => {
try {
const message = values ? t(key as never, values as never) : t(key as never);
if (!message || message === key || message === `endpoint.${key}`) {
return fallback;
}
return message;
} catch {
return fallback;
}
},
[t]
);
const fetchSearchProviders = async () => {
try {
const res = await fetch("/api/search/providers");
@@ -51,16 +99,53 @@ export default function APIPageClient({ machineId }) {
}
};
const fetchCloudflaredStatus = useCallback(
async (silent = false) => {
try {
const res = await fetch("/api/tunnels/cloudflared", { cache: "no-store" });
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(
data?.error ||
translateOrFallback(
"cloudflaredRequestFailed",
"Failed to load Cloudflare tunnel status"
)
);
}
setCloudflaredStatus(data);
return data as CloudflaredTunnelStatus;
} catch (error) {
if (!silent) {
setCloudflaredNotice({
type: "error",
message:
error instanceof Error
? error.message
: translateOrFallback(
"cloudflaredRequestFailed",
"Failed to load Cloudflare tunnel status"
),
});
}
return null;
}
},
[translateOrFallback]
);
useEffect(() => {
Promise.allSettled([
loadCloudSettings(),
fetchModels(),
fetchProtocolStatus(),
fetchSearchProviders(),
fetchCloudflaredStatus(true),
]).finally(() => {
setLoading(false);
});
}, []);
}, [fetchCloudflaredStatus]);
const fetchModels = async () => {
try {
@@ -177,9 +262,19 @@ export default function APIPageClient({ machineId }) {
}, [cloudStatus]);
useEffect(() => {
const interval = setInterval(fetchProtocolStatus, 30000);
if (cloudflaredNotice) {
const timer = setTimeout(() => setCloudflaredNotice(null), 5000);
return () => clearTimeout(timer);
}
}, [cloudflaredNotice]);
useEffect(() => {
const interval = setInterval(() => {
void fetchProtocolStatus();
void fetchCloudflaredStatus(true);
}, 30000);
return () => clearInterval(interval);
}, []);
}, [fetchCloudflaredStatus]);
const dispatchCloudChange = () => {
globalThis.dispatchEvent(new Event("cloud-status-changed"));
@@ -275,6 +370,50 @@ export default function APIPageClient({ machineId }) {
}
};
const handleCloudflaredAction = async (action: "enable" | "disable") => {
setCloudflaredBusy(true);
setCloudflaredNotice(null);
try {
const res = await fetch("/api/tunnels/cloudflared", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(
data?.error ||
translateOrFallback("cloudflaredRequestFailed", "Failed to update Cloudflare tunnel")
);
}
if (data?.status) {
setCloudflaredStatus(data.status);
}
setCloudflaredNotice({
type: "success",
message:
action === "enable"
? translateOrFallback("cloudflaredStarted", "Cloudflare tunnel started")
: translateOrFallback("cloudflaredStopped", "Cloudflare tunnel stopped"),
});
} catch (error) {
setCloudflaredNotice({
type: "error",
message:
error instanceof Error
? error.message
: translateOrFallback("cloudflaredRequestFailed", "Failed to update Cloudflare tunnel"),
});
} finally {
setCloudflaredBusy(false);
await fetchCloudflaredStatus(true);
}
};
const [baseUrl, setBaseUrl] = useState("/v1");
const normalizedCloudBaseUrl = cloudBaseUrl
? resolvedMachineId && !cloudBaseUrl.endsWith(`/${resolvedMachineId}`)
@@ -305,6 +444,43 @@ export default function APIPageClient({ machineId }) {
const a2aOnline = a2aStatus?.status === "ok";
const mcpToolCount = Number(mcpStatus?.heartbeat?.toolCount || 0);
const a2aActiveStreams = Number(a2aStatus?.tasks?.activeStreams || 0);
const cloudflaredPhase = cloudflaredStatus?.phase || "not_installed";
const cloudflaredPhaseMeta: Record<CloudflaredTunnelPhase, { label: string; className: string }> =
{
running: {
label: translateOrFallback("cloudflaredRunning", "Running"),
className: "bg-green-500/10 border-green-500/30 text-green-400",
},
starting: {
label: translateOrFallback("cloudflaredStarting", "Starting"),
className: "bg-blue-500/10 border-blue-500/30 text-blue-400",
},
stopped: {
label: translateOrFallback("cloudflaredStoppedState", "Stopped"),
className: "bg-surface border-border/70 text-text-muted",
},
not_installed: {
label: translateOrFallback("cloudflaredNotInstalled", "Not installed"),
className: "bg-surface border-border/70 text-text-muted",
},
unsupported: {
label: translateOrFallback("cloudflaredUnsupported", "Unsupported"),
className: "bg-amber-500/10 border-amber-500/30 text-amber-400",
},
error: {
label: translateOrFallback("cloudflaredError", "Error"),
className: "bg-red-500/10 border-red-500/30 text-red-400",
},
};
const cloudflaredActionLabel = cloudflaredStatus?.running
? translateOrFallback("cloudflaredDisable", "Stop Tunnel")
: cloudflaredStatus?.installed
? translateOrFallback("cloudflaredEnable", "Enable Tunnel")
: translateOrFallback("cloudflaredInstallAndEnable", "Install & Enable");
const cloudflaredUrlNotice = translateOrFallback(
"cloudflaredUrlNotice",
"Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
);
return (
<div className="flex flex-col gap-8">
@@ -407,6 +583,99 @@ export default function APIPageClient({ machineId }) {
{copied === "endpoint_url" ? tc("copied") : tc("copy")}
</Button>
</div>
<div className="rounded-xl border border-border/70 bg-surface/40 p-4">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">
{translateOrFallback("cloudflaredTitle", "Cloudflare Quick Tunnel")}
</h3>
<span
className={`inline-flex items-center rounded-full border px-2 py-1 text-xs font-medium ${cloudflaredPhaseMeta[cloudflaredPhase].className}`}
>
{cloudflaredPhaseMeta[cloudflaredPhase].label}
</span>
</div>
</div>
{cloudflaredStatus?.supported !== false && (
<Button
size="sm"
variant={cloudflaredStatus?.running ? "secondary" : "primary"}
icon={cloudflaredStatus?.running ? "cloud_off" : "cloud_upload"}
onClick={() =>
handleCloudflaredAction(cloudflaredStatus?.running ? "disable" : "enable")
}
loading={cloudflaredBusy}
className={
cloudflaredStatus?.running
? "border-border/70! text-text-muted! hover:text-text!"
: "bg-linear-to-r from-primary to-cyan-500 hover:from-primary-hover hover:to-cyan-600"
}
>
{cloudflaredActionLabel}
</Button>
)}
</div>
{cloudflaredNotice && (
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
cloudflaredNotice.type === "success"
? "border-green-500/30 bg-green-500/10 text-green-400"
: cloudflaredNotice.type === "info"
? "border-blue-500/30 bg-blue-500/10 text-blue-400"
: "border-red-500/30 bg-red-500/10 text-red-400"
}`}
>
<span className="material-symbols-outlined text-[18px]">
{cloudflaredNotice.type === "success"
? "check_circle"
: cloudflaredNotice.type === "info"
? "info"
: "error"}
</span>
<span className="flex-1">{cloudflaredNotice.message}</span>
<button
onClick={() => setCloudflaredNotice(null)}
className="rounded p-0.5 transition-colors hover:bg-white/10"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
)}
<p className="text-xs text-text-muted">{cloudflaredUrlNotice}</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={cloudflaredStatus?.apiUrl || ""}
readOnly
placeholder="https://*.trycloudflare.com/v1"
className="flex-1 min-w-0 font-mono text-sm"
/>
<Button
variant="secondary"
icon={copied === "cloudflared_url" ? "check" : "content_copy"}
onClick={() =>
cloudflaredStatus?.apiUrl && copy(cloudflaredStatus.apiUrl, "cloudflared_url")
}
disabled={!cloudflaredStatus?.apiUrl}
className="shrink-0 self-start sm:self-auto"
>
{copied === "cloudflared_url" ? tc("copied") : tc("copy")}
</Button>
</div>
{cloudflaredStatus?.lastError && (
<p className="text-xs text-red-400">
{translateOrFallback("cloudflaredLastError", "Last error: {error}", {
error: cloudflaredStatus.lastError,
})}
</p>
)}
</div>
</div>
</Card>
<Card>

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import {
getCloudflaredTunnelStatus,
startCloudflaredTunnel,
stopCloudflaredTunnel,
} from "@/lib/cloudflaredTunnel";
export const dynamic = "force-dynamic";
const actionSchema = z.object({
action: z.enum(["enable", "disable"]),
});
function unauthorized() {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
export async function GET(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return unauthorized();
}
try {
const status = await getCloudflaredTunnelStatus();
return NextResponse.json(status);
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to load cloudflared tunnel status",
},
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return unauthorized();
}
let payload: unknown;
try {
payload = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = actionSchema.safeParse(payload);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
try {
const status =
parsed.data.action === "enable"
? await startCloudflaredTunnel()
: await stopCloudflaredTunnel();
return NextResponse.json({
success: true,
action: parsed.data.action,
status,
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to update cloudflared tunnel",
},
{ status: 500 }
);
}
}

View File

@@ -1031,6 +1031,25 @@
"cloudWorkerUnreachable": "Could not reach cloud worker. Make sure the cloud service is running (npm run dev in /cloud).",
"connectionFailed": "Connection failed",
"syncFailed": "Failed to sync cloud data",
"cloudflaredTitle": "Cloudflare Quick Tunnel",
"cloudflaredDescription": "Create a Cloudflare Quick Tunnel for this endpoint.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart.",
"cloudflaredEnable": "Enable Tunnel",
"cloudflaredInstallAndEnable": "Install & Enable",
"cloudflaredDisable": "Stop Tunnel",
"cloudflaredRunning": "Running",
"cloudflaredStarting": "Starting",
"cloudflaredStoppedState": "Stopped",
"cloudflaredNotInstalled": "Not installed",
"cloudflaredUnsupported": "Unsupported",
"cloudflaredError": "Error",
"cloudflaredStarted": "Cloudflare tunnel started",
"cloudflaredStopped": "Cloudflare tunnel stopped",
"cloudflaredRequestFailed": "Failed to update Cloudflare tunnel",
"cloudflaredTemporaryNote": "Quick Tunnel URLs are temporary and will change after every restart.",
"cloudflaredUnsupportedNote": "This platform is not supported for managed installs. Install cloudflared manually or point CLOUDFLARED_BIN to an existing binary.",
"cloudflaredIdleNote": "Create a temporary Cloudflare Quick Tunnel for this endpoint.",
"cloudflaredLastError": "Last error: {error}",
"providerModelsTitle": "{provider} — Models",
"noModelsForProvider": "No models available for this provider.",
"chat": "Chat",

View File

@@ -1031,6 +1031,25 @@
"cloudWorkerUnreachable": "无法连接到云 Worker。请确认云服务已运行在 `/cloud` 中执行 `npm run dev`)。",
"connectionFailed": "连接失败",
"syncFailed": "同步云端数据失败",
"cloudflaredTitle": "Cloudflare Quick Tunnel",
"cloudflaredDescription": "为当前端点创建 Cloudflare Quick Tunnel。",
"cloudflaredUrlNotice": "创建一个临时的 Cloudflare Quick Tunnel。每次重启后 URL 都会变化。",
"cloudflaredEnable": "启用 Tunnel",
"cloudflaredInstallAndEnable": "安装并启用",
"cloudflaredDisable": "停止 Tunnel",
"cloudflaredRunning": "运行中",
"cloudflaredStarting": "启动中",
"cloudflaredStoppedState": "已停止",
"cloudflaredNotInstalled": "未安装",
"cloudflaredUnsupported": "不支持",
"cloudflaredError": "错误",
"cloudflaredStarted": "Cloudflare Tunnel 已启动",
"cloudflaredStopped": "Cloudflare Tunnel 已停止",
"cloudflaredRequestFailed": "更新 Cloudflare Tunnel 失败",
"cloudflaredTemporaryNote": "Quick Tunnel URL 是临时地址,每次重启后都会变化。",
"cloudflaredUnsupportedNote": "当前平台不支持托管安装。请自行安装 cloudflared或通过 CLOUDFLARED_BIN 指向已有二进制文件。",
"cloudflaredIdleNote": "为当前端点创建一个临时的 Cloudflare Quick Tunnel。",
"cloudflaredLastError": "最近错误:{error}",
"providerModelsTitle": "{provider} — 模型",
"noModelsForProvider": "该提供商当前没有可用模型。",
"chat": "聊天",

View File

@@ -0,0 +1,559 @@
import { spawn, execFile } from "child_process";
import { promisify } from "util";
import fs from "fs/promises";
import fsSync from "fs";
import path from "path";
import { resolveDataDir } from "@/lib/dataPaths";
import { getRuntimePorts } from "@/lib/runtime/ports";
const execFileAsync = promisify(execFile);
const CLOUDFLARED_RELEASE_BASE =
"https://github.com/cloudflare/cloudflared/releases/latest/download";
const START_TIMEOUT_MS = 30000;
const STOP_TIMEOUT_MS = 5000;
type CloudflaredInstallSource = "managed" | "path" | "env";
type TunnelPhase = "unsupported" | "not_installed" | "stopped" | "starting" | "running" | "error";
type AssetSpec = {
assetName: string;
binaryName: string;
archive: "none" | "tgz";
downloadUrl: string;
};
type BinaryResolution = {
binaryPath: string | null;
source: CloudflaredInstallSource | null;
managed: boolean;
};
type PersistedTunnelState = {
binaryPath?: string | null;
installSource?: CloudflaredInstallSource | null;
pid?: number | null;
publicUrl?: string | null;
apiUrl?: string | null;
targetUrl?: string | null;
status?: TunnelPhase;
lastError?: string | null;
startedAt?: string | null;
installedAt?: string | null;
};
export type CloudflaredTunnelStatus = {
supported: boolean;
installed: boolean;
managedInstall: boolean;
installSource: CloudflaredInstallSource | null;
binaryPath: string | null;
running: boolean;
pid: number | null;
publicUrl: string | null;
apiUrl: string | null;
targetUrl: string;
phase: TunnelPhase;
lastError: string | null;
logPath: string;
};
let tunnelProcess: ReturnType<typeof spawn> | null = null;
let tunnelPid: number | null = null;
let installPromise: Promise<string> | null = null;
let startPromise: Promise<CloudflaredTunnelStatus> | null = null;
function getTunnelDir() {
return path.join(resolveDataDir(), "cloudflared");
}
function getManagedBinaryPath(platform = process.platform) {
return path.join(getTunnelDir(), "bin", platform === "win32" ? "cloudflared.exe" : "cloudflared");
}
function getStateFilePath() {
return path.join(getTunnelDir(), "quick-tunnel-state.json");
}
function getPidFilePath() {
return path.join(getTunnelDir(), ".quick-tunnel.pid");
}
function getLogFilePath() {
return path.join(getTunnelDir(), "quick-tunnel.log");
}
function getLocalTargetUrl() {
const { apiPort } = getRuntimePorts();
return `http://127.0.0.1:${apiPort}`;
}
function getTunnelApiUrl(publicUrl: string | null) {
return publicUrl ? `${publicUrl.replace(/\/$/, "")}/v1` : null;
}
async function ensureTunnelDir() {
await fs.mkdir(path.join(getTunnelDir(), "bin"), { recursive: true });
}
async function readStateFile(): Promise<PersistedTunnelState> {
try {
const content = await fs.readFile(getStateFilePath(), "utf8");
return JSON.parse(content) as PersistedTunnelState;
} catch {
return {};
}
}
async function writeStateFile(state: PersistedTunnelState) {
await ensureTunnelDir();
await fs.writeFile(getStateFilePath(), JSON.stringify(state, null, 2) + "\n", "utf8");
}
async function updateStateFile(patch: PersistedTunnelState) {
const current = await readStateFile();
await writeStateFile({ ...current, ...patch });
}
async function clearPidFile() {
try {
await fs.unlink(getPidFilePath());
} catch {
// Ignore missing/stale pid files.
}
}
async function writePidFile(pid: number) {
await ensureTunnelDir();
await fs.writeFile(getPidFilePath(), String(pid), "utf8");
}
async function readPidFile() {
try {
const content = await fs.readFile(getPidFilePath(), "utf8");
const pid = Number.parseInt(content.trim(), 10);
return Number.isFinite(pid) ? pid : null;
} catch {
return null;
}
}
function isProcessAlive(pid: number | null) {
if (!pid || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function appendTunnelLog(source: "stdout" | "stderr", message: string) {
await ensureTunnelDir();
const timestamp = new Date().toISOString();
await fs.appendFile(getLogFilePath(), `[${timestamp}] [${source}] ${message}\n`, "utf8");
}
export function extractTryCloudflareUrl(text: string) {
const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/i);
return match ? match[0] : null;
}
export function getCloudflaredAssetSpec(
platform = process.platform,
arch = process.arch
): AssetSpec | null {
const matrix: Record<string, Record<string, Omit<AssetSpec, "downloadUrl">>> = {
linux: {
x64: {
assetName: "cloudflared-linux-amd64",
binaryName: "cloudflared",
archive: "none",
},
arm64: {
assetName: "cloudflared-linux-arm64",
binaryName: "cloudflared",
archive: "none",
},
},
darwin: {
x64: {
assetName: "cloudflared-darwin-amd64.tgz",
binaryName: "cloudflared",
archive: "tgz",
},
arm64: {
assetName: "cloudflared-darwin-arm64.tgz",
binaryName: "cloudflared",
archive: "tgz",
},
},
win32: {
x64: {
assetName: "cloudflared-windows-amd64.exe",
binaryName: "cloudflared.exe",
archive: "none",
},
arm64: {
assetName: "cloudflared-windows-arm64.exe",
binaryName: "cloudflared.exe",
archive: "none",
},
},
};
const spec = matrix[platform]?.[arch];
if (!spec) return null;
return {
...spec,
downloadUrl: `${CLOUDFLARED_RELEASE_BASE}/${spec.assetName}`,
};
}
async function resolvePathCommand(command: string) {
const lookupCommand = process.platform === "win32" ? "where" : "which";
const args = [command];
try {
const { stdout } = await execFileAsync(lookupCommand, args, { timeout: 3000 });
const first = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
return first || null;
} catch {
return null;
}
}
async function resolveBinary(): Promise<BinaryResolution> {
const envPath = String(process.env.CLOUDFLARED_BIN || "").trim();
if (envPath && fsSync.existsSync(envPath)) {
return { binaryPath: envPath, source: "env", managed: false };
}
const managedPath = getManagedBinaryPath();
if (fsSync.existsSync(managedPath)) {
return { binaryPath: managedPath, source: "managed", managed: true };
}
const pathBinary = await resolvePathCommand("cloudflared");
if (pathBinary) {
return { binaryPath: pathBinary, source: "path", managed: false };
}
return { binaryPath: null, source: null, managed: false };
}
async function extractArchive(archivePath: string, destinationDir: string) {
await execFileAsync("tar", ["-xzf", archivePath, "-C", destinationDir], { timeout: 15000 });
}
async function downloadToFile(url: string, destinationPath: string) {
const response = await fetch(url, { redirect: "follow" });
if (!response.ok) {
throw new Error(`Download failed with status ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
await fs.writeFile(destinationPath, buffer);
}
async function ensureExecutable(binaryPath: string) {
if (process.platform !== "win32") {
await fs.chmod(binaryPath, 0o755);
}
}
async function installManagedBinary() {
if (installPromise) return installPromise;
installPromise = (async () => {
const spec = getCloudflaredAssetSpec();
if (!spec) {
throw new Error(
`Unsupported platform for managed cloudflared install: ${process.platform}/${process.arch}`
);
}
await ensureTunnelDir();
const managedBinaryPath = getManagedBinaryPath();
const tempDownloadPath = path.join(getTunnelDir(), `${spec.assetName}.download`);
await updateStateFile({
status: "starting",
lastError: null,
});
try {
await downloadToFile(spec.downloadUrl, tempDownloadPath);
if (spec.archive === "tgz") {
await extractArchive(tempDownloadPath, path.dirname(managedBinaryPath));
} else {
await fs.rename(tempDownloadPath, managedBinaryPath);
}
await ensureExecutable(managedBinaryPath);
await updateStateFile({
binaryPath: managedBinaryPath,
installSource: "managed",
installedAt: new Date().toISOString(),
lastError: null,
});
return managedBinaryPath;
} finally {
try {
await fs.unlink(tempDownloadPath);
} catch {
// Ignore temp cleanup issues.
}
installPromise = null;
}
})();
return installPromise;
}
async function ensureBinary() {
const resolved = await resolveBinary();
if (resolved.binaryPath) {
return resolved;
}
const binaryPath = await installManagedBinary();
return {
binaryPath,
source: "managed" as const,
managed: true,
};
}
async function finalizeProcessExit(code: number | null, signal: NodeJS.Signals | null) {
const currentState = await readStateFile();
const lastError =
code === 0 || signal === "SIGTERM" || signal === "SIGINT"
? null
: `cloudflared exited unexpectedly (${code ?? "signal"}${signal ? `/${signal}` : ""})`;
tunnelProcess = null;
tunnelPid = null;
await clearPidFile();
await writeStateFile({
...currentState,
pid: null,
publicUrl: null,
apiUrl: null,
status: lastError ? "error" : "stopped",
lastError,
});
}
async function killPid(pid: number) {
process.kill(pid, "SIGTERM");
const start = Date.now();
while (Date.now() - start < STOP_TIMEOUT_MS) {
if (!isProcessAlive(pid)) return;
await new Promise((resolve) => setTimeout(resolve, 200));
}
if (isProcessAlive(pid)) {
process.kill(pid, "SIGKILL");
}
}
async function stopExistingTunnel() {
if (tunnelProcess && tunnelPid && !tunnelProcess.killed) {
const pid = tunnelPid;
tunnelProcess.kill("SIGTERM");
await killPid(pid);
return;
}
const pid = await readPidFile();
if (pid && isProcessAlive(pid)) {
await killPid(pid);
}
}
export async function getCloudflaredTunnelStatus(): Promise<CloudflaredTunnelStatus> {
const state = await readStateFile();
const resolved = await resolveBinary();
const pidFromState = tunnelPid || state.pid || (await readPidFile());
const running = isProcessAlive(pidFromState);
const publicUrl = running ? state.publicUrl || null : null;
const phase =
!getCloudflaredAssetSpec() && !resolved.binaryPath
? "unsupported"
: running
? publicUrl
? "running"
: "starting"
: resolved.binaryPath
? state.lastError
? "error"
: "stopped"
: "not_installed";
if (!running && state.pid) {
await clearPidFile();
}
return {
supported: !!(getCloudflaredAssetSpec() || resolved.binaryPath),
installed: !!resolved.binaryPath,
managedInstall: resolved.managed,
installSource: resolved.source,
binaryPath: resolved.binaryPath,
running,
pid: running ? pidFromState : null,
publicUrl,
apiUrl: publicUrl ? getTunnelApiUrl(publicUrl) : null,
targetUrl: state.targetUrl || getLocalTargetUrl(),
phase,
lastError: running ? null : state.lastError || null,
logPath: getLogFilePath(),
};
}
export async function startCloudflaredTunnel(): Promise<CloudflaredTunnelStatus> {
const current = await getCloudflaredTunnelStatus();
if (current.running) return current;
if (startPromise) return startPromise;
startPromise = (async () => {
const spec = getCloudflaredAssetSpec();
if (!spec && !(await resolveBinary()).binaryPath) {
throw new Error(
`Unsupported platform for cloudflared tunnel: ${process.platform}/${process.arch}`
);
}
const binary = await ensureBinary();
const targetUrl = getLocalTargetUrl();
await stopExistingTunnel();
await ensureTunnelDir();
await fs.writeFile(getLogFilePath(), "", "utf8");
await writeStateFile({
binaryPath: binary.binaryPath,
installSource: binary.source,
pid: null,
publicUrl: null,
apiUrl: null,
targetUrl,
status: "starting",
lastError: null,
startedAt: new Date().toISOString(),
});
const child = spawn(
binary.binaryPath as string,
["tunnel", "--url", targetUrl, "--no-autoupdate", "--protocol", "http2"],
{
stdio: ["ignore", "pipe", "pipe"],
env: process.env,
}
);
tunnelProcess = child;
tunnelPid = child.pid ?? null;
if (!child.pid) {
throw new Error("cloudflared failed to start");
}
await writePidFile(child.pid);
await updateStateFile({ pid: child.pid, status: "starting" });
const ready = await new Promise<CloudflaredTunnelStatus>((resolve, reject) => {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const settle = (handler: () => void) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
handler();
};
const handleOutput = async (source: "stdout" | "stderr", chunk: Buffer) => {
const text = chunk.toString("utf8").trim();
if (!text) return;
await appendTunnelLog(source, text);
const url = extractTryCloudflareUrl(text);
if (!url) return;
const apiUrl = getTunnelApiUrl(url);
await updateStateFile({
pid: child.pid,
publicUrl: url,
apiUrl,
status: "running",
lastError: null,
});
const status = await getCloudflaredTunnelStatus();
settle(() => resolve(status));
};
child.stdout.on("data", (chunk: Buffer) => {
void handleOutput("stdout", chunk);
});
child.stderr.on("data", (chunk: Buffer) => {
void handleOutput("stderr", chunk);
});
child.once("exit", (code, signal) => {
void finalizeProcessExit(code, signal);
settle(() =>
reject(
new Error(
`cloudflared exited before tunnel URL was ready (${code ?? "signal"}${signal ? `/${signal}` : ""})`
)
)
);
});
timeout = setTimeout(async () => {
await stopExistingTunnel();
settle(() => reject(new Error("Timed out while waiting for Cloudflare tunnel URL")));
}, START_TIMEOUT_MS);
});
return ready;
})();
try {
return await startPromise;
} catch (error) {
await updateStateFile({
status: "error",
lastError: error instanceof Error ? error.message : "Failed to start cloudflared tunnel",
});
throw error;
} finally {
startPromise = null;
}
}
export async function stopCloudflaredTunnel() {
await stopExistingTunnel();
const current = await readStateFile();
await writeStateFile({
...current,
pid: null,
publicUrl: null,
apiUrl: null,
status: "stopped",
lastError: null,
});
tunnelProcess = null;
tunnelPid = null;
await clearPidFile();
return getCloudflaredTunnelStatus();
}

View File

@@ -0,0 +1,47 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
extractTryCloudflareUrl,
getCloudflaredAssetSpec,
} from "../../src/lib/cloudflaredTunnel.ts";
test("extractTryCloudflareUrl parses trycloudflare URL from log output", () => {
const url = extractTryCloudflareUrl(
"INF +------------------------------------------------------------+\nINF | https://violet-sky-1234.trycloudflare.com |\nINF +------------------------------------------------------------+"
);
assert.equal(url, "https://violet-sky-1234.trycloudflare.com");
});
test("extractTryCloudflareUrl returns null when no tunnel URL is present", () => {
assert.equal(extractTryCloudflareUrl("cloudflared starting without assigned URL"), null);
});
test("getCloudflaredAssetSpec resolves linux amd64 binary", () => {
const spec = getCloudflaredAssetSpec("linux", "x64");
assert.deepEqual(spec, {
assetName: "cloudflared-linux-amd64",
binaryName: "cloudflared",
archive: "none",
downloadUrl:
"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
});
});
test("getCloudflaredAssetSpec resolves darwin arm64 archive", () => {
const spec = getCloudflaredAssetSpec("darwin", "arm64");
assert.deepEqual(spec, {
assetName: "cloudflared-darwin-arm64.tgz",
binaryName: "cloudflared",
archive: "tgz",
downloadUrl:
"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz",
});
});
test("getCloudflaredAssetSpec returns null for unsupported platforms", () => {
assert.equal(getCloudflaredAssetSpec("freebsd", "x64"), null);
});