diff --git a/.env.example b/.env.example index 393f267015..a487fe0200 100644 --- a/.env.example +++ b/.env.example @@ -1244,6 +1244,20 @@ APP_LOG_TO_FILE=true # Used by: src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx # NEXT_PUBLIC_DENO_RELAY_ENABLED=true +# ── Cloudflare Workers proxy relay (#4640 / 9router#1360) ── +# Override the Cloudflare REST API base used by the proxy-pool relay deployer. +# Default: https://api.cloudflare.com/client/v4 (omit unless mocking). +# Used by: src/app/api/settings/proxy/cloudflare-deploy/route.ts +# CLOUDFLARE_API_BASE=https://api.cloudflare.com/client/v4 + +# Default worker project name suggested in the "Deploy Relay" modal. +# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx +# NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT=omniroute-relay + +# Set to "false" to hide the Cloudflare Workers relay option from the Proxy Pool tab. +# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx +# NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED=true + # ── Cloudflare Tunnel (cloudflared) ── # Custom path to cloudflared binary for tunnel management. # Used by: src/lib/cloudflaredTunnel.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index ec7bed3a78..ba522cd7de 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -197,7 +197,7 @@ "src/lib/db/migrationRunner.ts": 1125, "src/lib/db/models.ts": 1259, "src/lib/db/providers.ts": 1063, - "src/lib/db/proxies.ts": 1057, + "src/lib/db/proxies.ts": 1060, "src/lib/db/settings.ts": 1149, "src/lib/db/usageAnalytics.ts": 925, "src/lib/evals/evalRunner.ts": 961, diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 734d33711d..e038437da0 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -750,6 +750,9 @@ Automatic model pricing data synchronization from external sources. | `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). | | `AWS_DEFAULT_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Fallback when `AWS_REGION` is not set. | | `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. | +| `CLOUDFLARE_API_BASE` | `https://api.cloudflare.com/client/v4` | `src/app/api/settings/proxy/cloudflare-deploy/route.ts` | Override the Cloudflare REST API base used by the proxy-pool Workers relay deployer (#4640 / 9router#1360). | +| `NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx` | Default worker project name suggested in the proxy-pool "Deploy Relay" modal. | +| `NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | Set to `false` to hide the Cloudflare Workers relay option from the Proxy Pool tab. | | `CLOUDFLARED_BIN` | auto-detect | `src/lib/cloudflaredTunnel.ts` | Custom path to `cloudflared` binary. | | `DENO_DEPLOY_API_BASE` | `https://api.deno.com/v2` | `src/app/api/settings/proxy/deno-deploy/route.ts` | Override the Deno Deploy REST API base used by the proxy-pool relay deployer (#4643 / 9router#1437). | | `NEXT_PUBLIC_DENO_RELAY_DEFAULT_PROJECT` | `omniroute-deno-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx` | Default Deno Deploy app name suggested in the proxy-pool "Deploy Relay" modal. | diff --git a/open-sse/utils/proxyDispatcher.ts b/open-sse/utils/proxyDispatcher.ts index e787172253..18c2f409b6 100644 --- a/open-sse/utils/proxyDispatcher.ts +++ b/open-sse/utils/proxyDispatcher.ts @@ -13,7 +13,7 @@ const SUPPORTED_PROTOCOLS = new Set(["http:", "https:", "socks5:"]); // the caller wraps the upstream URL with buildRelayHeaders() and fetches the // relay endpoint directly. Keep this set as the single source of truth so // every dispatch decision stays in sync when a new relay backend lands. -export const RELAY_TYPES: ReadonlySet = new Set(["vercel", "deno"]); +export const RELAY_TYPES: ReadonlySet = new Set(["vercel", "deno", "cloudflare"]); export function isRelayType(type: string | undefined | null): boolean { return typeof type === "string" && RELAY_TYPES.has(type); @@ -415,9 +415,11 @@ export function proxyConfigToUrl( if (!config.host) return null; const type = String(config.type || "http").toLowerCase(); - // Edge-relay entries (vercel / deno) carry the relay URL in `host` — no - // dispatcher needed; callers should use buildRelayHeaders() and fetch - // the relay endpoint directly. + // Edge-relay entries (vercel / deno / cloudflare) carry the relay URL in + // `host` — no dispatcher needed; callers should use buildRelayHeaders() and + // fetch the relay endpoint directly. All relay types share the exact same + // x-relay-target / x-relay-path / x-relay-auth header spec (only the + // deployment target differs). if (RELAY_TYPES.has(type)) { return config.host ? `https://${config.host}` : null; } diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx new file mode 100644 index 0000000000..e94ef2bdb8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx @@ -0,0 +1,165 @@ +"use client"; +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/shared/components"; + +interface CloudflareRelayModalProps { + isOpen: boolean; + onClose: () => void; + onDeployed: (poolProxyId: string, relayUrl: string) => void; +} + +// Mirrors VercelRelayModal — shares the same x-relay-target/x-relay-auth +// header scheme on the wire, only the deployment surface differs. +export default function CloudflareRelayModal({ + isOpen, + onClose, + onDeployed, +}: CloudflareRelayModalProps) { + const t = useTranslations("settings"); + const [accountId, setAccountId] = useState(""); + const [apiToken, setApiToken] = useState(""); + const [projectName, setProjectName] = useState( + process.env.NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT || "omniroute-relay" + ); + const [deploying, setDeploying] = useState(false); + const [error, setError] = useState(null); + + const handleDeploy = async () => { + if (!accountId.trim() || !apiToken.trim()) { + setError(t("cloudflareRelayCredsRequired")); + return; + } + setDeploying(true); + setError(null); + try { + const res = await fetch("/api/settings/proxy/cloudflare-deploy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accountId: accountId.trim(), + apiToken: apiToken.trim(), + projectName: projectName.trim(), + }), + }); + const data = await res.json(); + if (!res.ok || !data.success) { + setError(data.error?.message || t("cloudflareRelayDeployFailed")); + } else { + setApiToken(""); + onDeployed(data.poolProxyId as string, data.relayUrl as string); + onClose(); + } + } catch (err) { + setError(err instanceof Error ? err.message : t("unknownError")); + } finally { + setDeploying(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+

+ + {t("cloudflareRelayModalTitle")} +

+ +
+ +
+

{t("cloudflareRelayWarning")}

+

{t("cloudflareRelayTokenHowto")}

+
+ +
+
+ + setAccountId(e.target.value)} + className="w-full text-sm bg-surface-alt border border-border rounded px-3 py-2 focus:outline-none focus:border-primary" + placeholder="your-cloudflare-account-id" + autoComplete="off" + /> +

+ {t("cloudflareRelayAccountIdHint")} +

+
+
+ + setApiToken(e.target.value)} + className="w-full text-sm bg-surface-alt border border-border rounded px-3 py-2 focus:outline-none focus:border-primary" + placeholder="cloudflare-api-token" + autoComplete="off" + /> +

+ {t("cloudflareRelayApiTokenHint")} +

+
+
+ + setProjectName(e.target.value)} + className="w-full text-sm bg-surface-alt border border-border rounded px-3 py-2 focus:outline-none focus:border-primary" + placeholder="omniroute-relay" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +

{t("cloudflareRelayFreeTierNote")}

+ +
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx index 36ea5bc46f..e31b8b1dd3 100644 --- a/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx @@ -5,17 +5,20 @@ import { useTranslations } from "next-intl"; import ProxyRegistryManager from "../ProxyRegistryManager"; import VercelRelayModal from "./VercelRelayModal"; import DenoRelayModal from "./DenoRelayModal"; +import CloudflareRelayModal from "./CloudflareRelayModal"; export default function ProxyPoolTab() { const t = useTranslations("settings"); const [vercelModalOpen, setVercelModalOpen] = useState(false); const [denoModalOpen, setDenoModalOpen] = useState(false); + const [cloudflareModalOpen, setCloudflareModalOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const menuRef = useRef(null); const showVercelRelay = process.env.NEXT_PUBLIC_VERCEL_RELAY_ENABLED !== "false"; const showDenoRelay = process.env.NEXT_PUBLIC_DENO_RELAY_ENABLED !== "false"; - const showAnyRelay = showVercelRelay || showDenoRelay; + const showCloudflareRelay = process.env.NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED !== "false"; + const showAnyRelay = showVercelRelay || showDenoRelay || showCloudflareRelay; // Close the dropdown on outside click — mirrors the upstream PR-1437 // grouped-button UX so adding more relay backends does not blow up the @@ -31,10 +34,14 @@ export default function ProxyPoolTab() { return () => document.removeEventListener("mousedown", onMouseDown); }, [menuOpen]); - const handleDeployed = (_poolProxyId: string, relayUrl: string) => { + const handleVercelDeployed = (_poolProxyId: string, relayUrl: string) => { alert(`${t("vercelRelaySuccess")}: ${relayUrl}`); }; + const handleCloudflareDeployed = (_poolProxyId: string, relayUrl: string) => { + alert(`${t("cloudflareRelaySuccess")}: ${relayUrl}`); + }; + return (
{showAnyRelay && ( @@ -86,6 +93,24 @@ export default function ProxyPoolTab() { {t("denoRelayButton")} )} + {showCloudflareRelay && ( + + )}
)} @@ -95,12 +120,17 @@ export default function ProxyPoolTab() { setVercelModalOpen(false)} - onDeployed={handleDeployed} + onDeployed={handleVercelDeployed} /> setDenoModalOpen(false)} - onDeployed={handleDeployed} + onDeployed={handleVercelDeployed} + /> + setCloudflareModalOpen(false)} + onDeployed={handleCloudflareDeployed} /> ); diff --git a/src/app/api/settings/proxy/cloudflare-deploy/route.ts b/src/app/api/settings/proxy/cloudflare-deploy/route.ts new file mode 100644 index 0000000000..e1d35a3ac6 --- /dev/null +++ b/src/app/api/settings/proxy/cloudflare-deploy/route.ts @@ -0,0 +1,178 @@ +import { randomBytes } from "crypto"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { cloudflareDeploySchema } from "@/shared/validation/freeProxySchemas"; +import { createProxy } from "@/lib/localDb"; +import { encrypt } from "@/lib/db/encryption"; +import { buildCloudflareWorkerScript } from "@/lib/proxyRelay/cloudflareWorkerScript"; + +// Port of upstream decolua/9router PR #1360 — Cloudflare Workers proxy relay. +// Architecture mirrors src/app/api/settings/proxy/vercel-deploy/route.ts so the +// shared proxyFetch relay short-circuit, x-relay-auth scheme, and inline SSRF +// guard work unchanged. Only the deployment surface differs (Cloudflare Workers +// API instead of Vercel /v13/deployments). + +const CLOUDFLARE_API_BASE = process.env.CLOUDFLARE_API_BASE || "https://api.cloudflare.com/client/v4"; + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown = {}; + try { + rawBody = await request.json(); + } catch { + return createErrorResponse({ + status: 400, + message: "Invalid JSON body", + type: "invalid_request", + }); + } + + const validation = validateBody(cloudflareDeploySchema, rawBody); + if (isValidationFailure(validation)) { + return createErrorResponse({ + status: 400, + message: validation.error.message, + type: "invalid_request", + }); + } + + const { accountId, apiToken, projectName } = validation.data; + + // Generate random auth secret for the relay — stored in proxy notes, never + // returned to client. Same scheme as the Vercel relay so the deployed worker + // is not an open SSRF proxy reachable from any third party with the workers.dev URL. + const relayAuth = randomBytes(24).toString("hex"); + const workerScript = buildCloudflareWorkerScript(relayAuth); + + try { + // 1. PUT the Worker script — Cloudflare requires multipart/form-data with + // main_module + a metadata blob describing the upload. + const workerScriptUrl = `${CLOUDFLARE_API_BASE}/accounts/${accountId}/workers/scripts/${projectName}`; + const formData = new FormData(); + formData.append( + "index.js", + new Blob([workerScript], { type: "application/javascript+module" }), + "index.js" + ); + formData.append( + "metadata", + new Blob( + [ + JSON.stringify({ + main_module: "index.js", + compatibility_date: "2026-03-20", + observability: { enabled: true }, + }), + ], + { type: "application/json" } + ), + "metadata.json" + ); + + const uploadRes = await fetch(workerScriptUrl, { + method: "PUT", + headers: { Authorization: `Bearer ${apiToken}` }, + body: formData, + }); + + if (!uploadRes.ok) { + // Surface only the canonical Cloudflare error message; never forward raw + // response text (may carry internal IDs / token hints). + let upstreamMessage = "Cloudflare API rejected the Worker upload"; + try { + const parsed = (await uploadRes.json().catch(() => null)) as { + errors?: Array<{ message?: string }>; + } | null; + const candidate = parsed?.errors?.[0]?.message; + if (typeof candidate === "string" && candidate.trim()) { + upstreamMessage = candidate.trim().slice(0, 200); + } + } catch { + /* fall through to generic message */ + } + return createErrorResponse({ + status: uploadRes.status, + message: `Cloudflare Worker upload failed: ${upstreamMessage}`, + type: "upstream_error", + }); + } + + // 2. Enable the workers.dev subdomain for this script so it is reachable. + // A failure here is non-fatal (some accounts already enable subdomains + // by default); the next call surfaces the correct error if anything is + // actually missing. + await fetch(`${workerScriptUrl}/subdomain`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled: true }), + }).catch(() => {}); + + // 3. Look up the account's workers.dev subdomain to build the final URL. + const subdomainRes = await fetch( + `${CLOUDFLARE_API_BASE}/accounts/${accountId}/workers/subdomain`, + { + method: "GET", + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + }, + } + ); + + let deployUrl = ""; + if (subdomainRes.ok) { + const subdomainData = (await subdomainRes.json().catch(() => null)) as { + result?: { subdomain?: string }; + } | null; + const sub = subdomainData?.result?.subdomain; + if (typeof sub === "string" && sub) { + deployUrl = `https://${projectName}.${sub}.workers.dev`; + } + } + + if (!deployUrl) { + return createErrorResponse({ + status: 400, + message: + "Worker deployed but failed to retrieve workers.dev subdomain. Set up a workers.dev subdomain in the Cloudflare dashboard first.", + type: "upstream_error", + }); + } + + // Store as proxy pool entry — apiToken is NOT stored. relayAuth is + // encrypted at rest when STORAGE_ENCRYPTION_KEY is configured (encrypt() is + // a no-op in passthrough mode); the redactor strips both shapes from API responses. + const encryptedRelayAuth = encrypt(relayAuth); + const notesPayload = + encryptedRelayAuth && encryptedRelayAuth !== relayAuth + ? { relayAuthEnc: encryptedRelayAuth } + : { relayAuth }; + + // deployUrl is "https://..workers.dev" — strip the protocol so + // the `host` column matches the Vercel-relay shape (proxyFetch prepends + // "https://" when routing). + const hostOnly = deployUrl.replace(/^https?:\/\//, ""); + const poolProxy = await createProxy({ + name: `Cloudflare Relay (${projectName})`, + type: "cloudflare", + host: hostOnly, + port: 443, + notes: JSON.stringify(notesPayload), + source: "cloudflare-relay", + }); + + return Response.json({ + success: true, + relayUrl: deployUrl, + poolProxyId: poolProxy?.id, + }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Cloudflare deploy failed"); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 5712cec4be..200708bfcc 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -6037,6 +6037,21 @@ "denoRelayFreeTierNote": "Deno Deploy v2 runs on a global edge network. Free tier: 1M requests & 100GiB outbound traffic per month, no per-request CPU limits, up to 20 active apps & 50 custom domains.", "denoRelayDeploying": "Deploying...", "denoRelayDeploy": "Deploy", + "cloudflareRelaySuccess": "Cloudflare Relay deployed successfully", + "cloudflareRelayButton": "Deploy Cloudflare Relay", + "cloudflareRelayModalTitle": "Deploy Cloudflare Worker Relay", + "cloudflareRelayWarning": "Deploys a Cloudflare Worker that proxies LLM requests through Cloudflare's edge network — masking the host IP behind dynamic Cloudflare addresses. The Worker enforces a one-time auth secret so the public workers.dev URL cannot be reused as an open relay.", + "cloudflareRelayTokenHowto": "Create the API token under My Profile -> API Tokens -> Create Token -> Custom Token -> Account / Workers Scripts / Edit.", + "cloudflareRelayAccountIdLabel": "Cloudflare Account ID", + "cloudflareRelayAccountIdHint": "Found on the right side of the Cloudflare dashboard overview page.", + "cloudflareRelayApiTokenLabel": "Cloudflare API Token", + "cloudflareRelayApiTokenHint": "Requires 'Workers Scripts: Edit' permission. The token is used only at deploy time and is never stored.", + "cloudflareRelayProjectNameLabel": "Worker Name", + "cloudflareRelayFreeTierNote": "Free tier: 100,000 requests/day per Cloudflare account.", + "cloudflareRelayDeploying": "Deploying...", + "cloudflareRelayDeploy": "Deploy", + "cloudflareRelayCredsRequired": "Account ID and API Token are required", + "cloudflareRelayDeployFailed": "Deploy failed", "modelLockout": "Model Lockout", "modelLockoutPageDescription": "Configure which HTTP error codes trigger per-model lockout and control the cooldown behavior.", "modelLockoutEnabled": "Enable Model Lockout", diff --git a/src/lib/db/proxies.ts b/src/lib/db/proxies.ts index 231a26d411..43bd7d914e 100755 --- a/src/lib/db/proxies.ts +++ b/src/lib/db/proxies.ts @@ -1,5 +1,8 @@ -// Convention: when type is a relay (vercel | deno), the `notes` column stores JSON { relayAuth: "" } -// used by proxyFetch.ts to route requests through the Vercel edge relay instead of an undici ProxyAgent. +// Convention: when type is a relay (vercel | deno | cloudflare), the `notes` column stores JSON +// { relayAuth: "" } used by proxyFetch.ts to route requests through the relay edge function +// (Vercel Edge, Deno Deploy, or Cloudflare Workers) instead of an undici ProxyAgent. All relay +// types share the exact same x-relay-target / x-relay-path / x-relay-auth header spec; only the +// deployment surface differs. import { randomUUID } from "crypto"; import { getDbInstance } from "./core"; import { backupDbFile } from "./backup"; @@ -122,7 +125,7 @@ function mapAssignmentRow(row: unknown): ProxyAssignmentRecord { // Edge-relay proxy types. Mirrors RELAY_TYPES in open-sse/utils/proxyDispatcher. // Duplicated here (not imported) to keep src/lib/db/ free of open-sse runtime // imports; if a third relay backend lands, update BOTH sets. -const RELAY_PROXY_TYPES = new Set(["vercel", "deno"]); +const RELAY_PROXY_TYPES = new Set(["vercel", "deno", "cloudflare"]); function isRelayProxyType(type: unknown): boolean { return typeof type === "string" && RELAY_PROXY_TYPES.has(type); diff --git a/src/lib/proxyRelay/cloudflareWorkerScript.ts b/src/lib/proxyRelay/cloudflareWorkerScript.ts new file mode 100644 index 0000000000..d8a639b376 --- /dev/null +++ b/src/lib/proxyRelay/cloudflareWorkerScript.ts @@ -0,0 +1,107 @@ +/** + * Cloudflare Worker source emitter for the OmniRoute proxy relay. + * + * Port of upstream decolua/9router PR #1360. The Worker plays the same role + * the Vercel-relay edge function does (`src/app/api/settings/proxy/vercel-deploy/route.ts`): + * - Accepts inbound requests carrying x-relay-target / x-relay-path / + * x-relay-auth headers. + * - Authorises with the embedded relayAuth secret (so a leaked workers.dev URL + * is not an open SSRF proxy). + * - Inlines an SSRF guard rejecting RFC1918 / loopback / link-local / IPv6 ULA + * targets — the Edge runtime cannot import Node helpers, the guard lives + * here as a string. + * - Strips Host + relay control headers before forwarding upstream. + * + * The string template is fed to Cloudflare's PUT /accounts/{id}/workers/scripts/{name} + * API with main_module=index.js (ESM Workers Modules format). + * + * The OmniRoute variant intentionally diverges from the upstream PR: + * - The upstream worker had NO auth check, leaving the deployed workers.dev URL + * as an open SSRF proxy. We mirror Vercel's x-relay-auth scheme instead so the + * same buildVercelRelayHeaders helper (open-sse/utils/proxyDispatcher.ts) and + * the same proxyFetch relay short-circuit work unchanged. + * - SSRF guard is inlined so a leaked relay URL cannot scan internal IPs. + */ +export function buildCloudflareWorkerScript(relayAuth: string): string { + // relayAuth is generated server-side via randomBytes(24).toString("hex") — no + // user-controlled input ever reaches this template, so direct interpolation + // into the worker source string is safe. + return `// OmniRoute Cloudflare Worker proxy relay — generated at deploy time. +function isPrivateHostname(h) { + if (!h) return true; + const host = h.trim().toLowerCase().replace(/^\\[|\\]$/g, ""); + if ( + host === "localhost" || + host === "0.0.0.0" || + host === "127.0.0.1" || + host === "::1" || + host.endsWith(".localhost") || + host.endsWith(".local") || + host.endsWith(".internal") || + host.startsWith("::ffff:") + ) return true; + const v4 = host.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/); + if (v4) { + const a = +v4[1], b = +v4[2]; + if (a === 0 || a === 10 || a === 127) return true; + if (a === 169 && b === 254) return true; // link-local IPv4 + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + return false; + } + if (host.includes(":")) { + // IPv6 loopback/ULA/link-local (fe80::/10) + return host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:"); + } + return false; +} + +export default { + async fetch(request, env, ctx) { + const auth = request.headers.get("x-relay-auth"); + if (auth !== "${relayAuth}") { + return new Response("Unauthorized", { status: 401 }); + } + const target = request.headers.get("x-relay-target"); + if (!target) { + return new Response("missing x-relay-target", { status: 400 }); + } + let targetUrl; + try { targetUrl = new URL(target); } catch { return new Response("invalid x-relay-target", { status: 400 }); } + if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") { + return new Response("forbidden x-relay-target protocol", { status: 403 }); + } + if (targetUrl.username || targetUrl.password) { + return new Response("forbidden x-relay-target (embedded credentials)", { status: 403 }); + } + if (isPrivateHostname(targetUrl.hostname)) { + return new Response("forbidden x-relay-target (private/loopback host)", { status: 403 }); + } + const relayPath = request.headers.get("x-relay-path") || "/"; + const headers = new Headers(request.headers); + ["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach((h) => headers.delete(h)); + const init = { + method: request.method, + headers, + }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + init.duplex = "half"; + } + try { + const upstream = await fetch(target.replace(/\\/$/, "") + relayPath, init); + return new Response(upstream.body, { + status: upstream.status, + headers: upstream.headers, + }); + } catch (error) { + return new Response(JSON.stringify({ error: error && error.message ? error.message : "relay error" }), { + status: 502, + headers: { "content-type": "application/json" }, + }); + } + }, +}; +`; +} diff --git a/src/shared/validation/freeProxySchemas.ts b/src/shared/validation/freeProxySchemas.ts index 1b4fb64df8..ab91b72810 100644 --- a/src/shared/validation/freeProxySchemas.ts +++ b/src/shared/validation/freeProxySchemas.ts @@ -79,3 +79,33 @@ export const vercelDeploySchema = z.object({ .regex(/^[a-z0-9-]+$/, "Project name must be lowercase alphanumeric with hyphens") .default("omniroute-relay"), }); + +export const cloudflareDeploySchema = z.object({ + // Cloudflare Account ID is a 32-char lowercase hex string. Reject anything + // obviously malformed so users get clearer feedback than a Cloudflare 401/404. + accountId: z + .string() + .min(8, "Cloudflare Account ID looks too short") + .max(64) + .regex( + /^[a-f0-9]+$/, + "Cloudflare Account ID must be lowercase hex" + ), + // Cloudflare API tokens are opaque alphanumeric (40+ chars) — same alphabet + // we accept for Vercel tokens; constrain length to catch paste accidents. + apiToken: z + .string() + .min(20, "Cloudflare API token looks too short") + .max(200) + .regex( + /^[A-Za-z0-9_-]+$/, + "Cloudflare API token must contain only alphanumeric, underscore, or hyphen" + ), + projectName: z + .string() + .min(3) + .max(52) + .regex(/^[a-z0-9-]+$/, "Worker name must be lowercase alphanumeric with hyphens") + .default("omniroute-relay"), +}); + diff --git a/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts b/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts new file mode 100644 index 0000000000..6109a52452 --- /dev/null +++ b/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts @@ -0,0 +1,183 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Port of upstream decolua/9router PR #1360: Cloudflare Workers as proxy relay. +// +// Architecture mirrors the existing Vercel relay (same x-relay-target / +// x-relay-path / x-relay-auth header spec, same SSRF guard inlined into the +// worker, same fail-closed missing-relayAuth check). Only the deployment +// target changes: instead of POSTing to the Vercel /v13/deployments API, +// we PUT a Worker script to Cloudflare's accounts/{accountId}/workers/scripts +// API and enable the workers.dev subdomain. +// +// Coverage: +// 1. buildCloudflareWorkerScript(relayAuth) returns an Edge-runtime worker +// module whose source enforces the same x-relay-auth check as the +// Vercel relay (so a leaked workers.dev URL cannot be used as an +// open SSRF proxy by a third party). +// 2. proxyFetch's relay short-circuit treats type "cloudflare" the same +// way as "vercel" — uses buildVercelRelayHeaders + routes to the +// workers.dev origin, never the upstream target. +// 3. The proxyDispatcher / DB layer recognise "cloudflare" as a relay +// type (extractRelayAuth fires, dispatcher returns the worker URL). + +// --- Install the relay sink BEFORE importing the module under test. --- +type FetchCall = { input: unknown; init: RequestInit & { headers?: HeadersInit } }; +const relayCalls: FetchCall[] = []; +const realGlobalFetch = globalThis.fetch; + +const relaySink = (async (input: unknown, init: RequestInit = {}) => { + relayCalls.push({ input, init }); + return Response.json({ via: "cloudflare-relay" }); +}) as unknown as typeof globalThis.fetch; + +globalThis.fetch = relaySink; + +const proxyDispatcher = await import("../../open-sse/utils/proxyDispatcher.ts"); +const { buildVercelRelayHeaders, proxyConfigToUrl } = proxyDispatcher; +const proxyFetchMod = await import("../../open-sse/utils/proxyFetch.ts"); +const { proxyFetch, runWithProxyContext } = proxyFetchMod; + +const cfDeploy = await import("../../src/lib/proxyRelay/cloudflareWorkerScript.ts"); +const { buildCloudflareWorkerScript } = cfDeploy; + +test.after(() => { + globalThis.fetch = realGlobalFetch; +}); + +test.beforeEach(() => { + relayCalls.length = 0; +}); + +// -------------------------------------------------------------------------- +// 1. buildCloudflareWorkerScript — emitted worker source contract +// -------------------------------------------------------------------------- + +test("buildCloudflareWorkerScript embeds the supplied relayAuth literal", () => { + const src = buildCloudflareWorkerScript("a-very-specific-secret-token"); + assert.ok( + src.includes('"a-very-specific-secret-token"'), + "worker source must embed the relayAuth secret as a string literal" + ); +}); + +test("buildCloudflareWorkerScript rejects requests without a valid x-relay-auth header", () => { + // The worker source must enforce the same auth check as the Vercel relay: + // a 401 short-circuit when x-relay-auth does not match the embedded token. + // We don't run the worker here — we check the source contains the guard. + const src = buildCloudflareWorkerScript("the-secret"); + assert.ok( + /x-relay-auth/.test(src), + "worker source must reference the x-relay-auth header" + ); + assert.ok( + /401|Unauthorized/.test(src), + "worker source must short-circuit unauthorised requests with 401" + ); +}); + +test("buildCloudflareWorkerScript blocks loopback / RFC1918 / link-local hosts (SSRF guard)", () => { + // Mirrors the Vercel relay's inlined SSRF guard. A leaked workers.dev URL + // must not be usable to scan internal networks. + const src = buildCloudflareWorkerScript("tok"); + // The guard recognises private CIDRs / loopback by literal substrings in + // the inline function. These specific tokens are load-bearing. + assert.ok(/127\.0\.0\.1|localhost/.test(src), "blocks loopback hosts"); + assert.ok(/192\.168|10\.|172/.test(src), "blocks RFC1918 hosts"); + assert.ok(/169\.254|link-local|fe80/.test(src), "blocks link-local hosts"); +}); + +test("buildCloudflareWorkerScript uses ESM default-export fetch handler (Workers Modules format)", () => { + // Cloudflare's PUT /workers/scripts API expects a module-format worker + // (main_module = index.js, content-type application/javascript+module). + // The handler must be exposed as `export default { fetch }`. + const src = buildCloudflareWorkerScript("tok"); + assert.ok(/export\s+default/.test(src), "must be an ES module (export default)"); + assert.ok(/fetch\s*\(/.test(src), "must export a fetch handler"); +}); + +// -------------------------------------------------------------------------- +// 2. proxyFetch — cloudflare type takes the relay short-circuit +// -------------------------------------------------------------------------- + +const CLOUDFLARE_CTX = { + type: "cloudflare" as const, + host: "omniroute-relay.acme.workers.dev", + relayAuth: "live-cf-secret", +}; + +test("proxyFetch routes a cloudflare-type context through the relay endpoint with relay headers", async () => { + const response = await runWithProxyContext(CLOUDFLARE_CTX, () => + proxyFetch("https://api.anthropic.com/v1/messages?x=1", { + method: "POST", + headers: { "x-existing": "keep-me" }, + }) + ); + + assert.deepEqual(await response.json(), { via: "cloudflare-relay" }); + assert.equal(relayCalls.length, 1, "exactly one relay dispatch"); + const call = relayCalls[0]; + + // Rewritten to the workers.dev origin, NOT the upstream target. + assert.equal(call.input, "https://omniroute-relay.acme.workers.dev"); + + const sentHeaders = new Headers(call.init.headers); + assert.equal(sentHeaders.get("x-relay-target"), "https://api.anthropic.com"); + assert.equal(sentHeaders.get("x-relay-path"), "/v1/messages?x=1"); + assert.equal(sentHeaders.get("x-relay-auth"), "live-cf-secret"); + assert.equal(sentHeaders.get("x-existing"), "keep-me"); + assert.equal(call.init.method, "POST"); + assert.equal((call.init as { duplex?: string }).duplex, "half"); +}); + +test("proxyFetch throws (without dispatching) when a cloudflare context is missing relayAuth", async () => { + await assert.rejects( + runWithProxyContext({ type: "cloudflare", host: "x.workers.dev" }, () => + proxyFetch("https://api.anthropic.com/v1/messages", { method: "POST" }) + ), + /relay configuration error: missing relayAuth/ + ); + assert.equal(relayCalls.length, 0, "no relay dispatch when relayAuth is missing"); +}); + +test("the missing-relayAuth error message does not leak internal [ProxyFetch] diagnostics", async () => { + await runWithProxyContext({ type: "cloudflare", host: "x.workers.dev" }, async () => { + try { + await proxyFetch("https://api.anthropic.com/v1/messages", { method: "POST" }); + assert.fail("expected the relay branch to throw on missing relayAuth"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + assert.ok(!message.includes("[ProxyFetch]"), "no internal [ProxyFetch] label"); + assert.ok(!message.includes("at /"), "no stack-trace path leaked"); + } + }); +}); + +// -------------------------------------------------------------------------- +// 3. proxyConfigToUrl — cloudflare type yields the worker https origin +// -------------------------------------------------------------------------- + +test("proxyConfigToUrl returns the cloudflare worker URL (no HTTP-proxy dispatcher needed)", () => { + const url = proxyConfigToUrl({ + type: "cloudflare", + host: "omniroute-relay.acme.workers.dev", + }); + assert.equal(url, "https://omniroute-relay.acme.workers.dev"); +}); + +// -------------------------------------------------------------------------- +// 4. buildVercelRelayHeaders is shared (renamed-or-aliased? at minimum still +// works for cloudflare — same header spec). +// -------------------------------------------------------------------------- + +test("buildVercelRelayHeaders is the shared relay-header builder used for cloudflare too", () => { + const headers = buildVercelRelayHeaders( + "https://api.openai.com/v1/chat/completions", + "cf-tok" + ); + assert.deepEqual(headers, { + "x-relay-target": "https://api.openai.com", + "x-relay-path": "/v1/chat/completions", + "x-relay-auth": "cf-tok", + }); +});