diff --git a/README.md b/README.md index 19a095b373..98fec26643 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 77ff35d592..3c5af34dad 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -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 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 11a475bdb9..3f47a4a6e6 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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`) diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index 2befac4fbe..06f5c3a91f 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -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; +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(null); const [a2aStatus, setA2aStatus] = useState(null); const [searchProviders, setSearchProviders] = useState([]); + const [cloudflaredStatus, setCloudflaredStatus] = useState(null); + const [cloudflaredBusy, setCloudflaredBusy] = useState(false); + const [cloudflaredNotice, setCloudflaredNotice] = useState(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 = + { + 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 (
@@ -407,6 +583,99 @@ export default function APIPageClient({ machineId }) { {copied === "endpoint_url" ? tc("copied") : tc("copy")}
+ +
+
+
+
+
+

+ {translateOrFallback("cloudflaredTitle", "Cloudflare Quick Tunnel")} +

+ + {cloudflaredPhaseMeta[cloudflaredPhase].label} + +
+
+ + {cloudflaredStatus?.supported !== false && ( + + )} +
+ + {cloudflaredNotice && ( +
+ + {cloudflaredNotice.type === "success" + ? "check_circle" + : cloudflaredNotice.type === "info" + ? "info" + : "error"} + + {cloudflaredNotice.message} + +
+ )} + +

{cloudflaredUrlNotice}

+
+ + +
+ {cloudflaredStatus?.lastError && ( +

+ {translateOrFallback("cloudflaredLastError", "Last error: {error}", { + error: cloudflaredStatus.lastError, + })} +

+ )} +
+
diff --git a/src/app/api/tunnels/cloudflared/route.ts b/src/app/api/tunnels/cloudflared/route.ts new file mode 100644 index 0000000000..3b65450532 --- /dev/null +++ b/src/app/api/tunnels/cloudflared/route.ts @@ -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 } + ); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index dc9105da85..469dc527a9 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 400802ea98..492450040e 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -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": "聊天", diff --git a/src/lib/cloudflaredTunnel.ts b/src/lib/cloudflaredTunnel.ts new file mode 100644 index 0000000000..e5d1f41b86 --- /dev/null +++ b/src/lib/cloudflaredTunnel.ts @@ -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 | null = null; +let tunnelPid: number | null = null; +let installPromise: Promise | null = null; +let startPromise: Promise | 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 { + 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>> = { + 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 { + 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 { + 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 { + 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((resolve, reject) => { + let settled = false; + let timeout: ReturnType | 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(); +} diff --git a/tests/unit/cloudflaredTunnel.test.mjs b/tests/unit/cloudflaredTunnel.test.mjs new file mode 100644 index 0000000000..005ee7c7e0 --- /dev/null +++ b/tests/unit/cloudflaredTunnel.test.mjs @@ -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); +});