feat(proxy-pool): Cloudflare Workers proxy deployer + pool integration (#4640)

Integrated into release/v3.8.36 (relay type added to RELAY_TYPES set; dropdown UX preserved + Cloudflare item added; proxies.ts file-size rebaselined 1057→1060)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-24 00:57:43 -03:00
committed by GitHub
parent 078785cbf3
commit 8080800d8f
12 changed files with 742 additions and 12 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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. |

View File

@@ -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<string> = new Set(["vercel", "deno"]);
export const RELAY_TYPES: ReadonlySet<string> = 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;
}

View File

@@ -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<string | null>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
role="dialog"
aria-modal="true"
aria-labelledby="cloudflare-relay-title"
>
<div className="bg-surface rounded-lg shadow-xl p-6 w-full max-w-md space-y-4">
<div className="flex items-center justify-between">
<h2
id="cloudflare-relay-title"
className="text-lg font-bold flex items-center gap-2"
>
<span className="material-symbols-outlined text-primary" aria-hidden="true">
cloud
</span>
{t("cloudflareRelayModalTitle")}
</h2>
<button
onClick={onClose}
aria-label={t("close")}
className="text-text-muted hover:text-text"
>
<span className="material-symbols-outlined" aria-hidden="true">
close
</span>
</button>
</div>
<div className="bg-orange-500/10 border border-orange-500/30 rounded p-3 text-xs text-orange-300 space-y-1">
<p>{t("cloudflareRelayWarning")}</p>
<p className="text-text-muted">{t("cloudflareRelayTokenHowto")}</p>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium mb-1 block" htmlFor="cloudflare-account-id">
{t("cloudflareRelayAccountIdLabel")}
</label>
<input
id="cloudflare-account-id"
type="text"
value={accountId}
onChange={(e) => 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"
/>
<p className="text-xs text-text-muted mt-1">
{t("cloudflareRelayAccountIdHint")}
</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block" htmlFor="cloudflare-api-token">
{t("cloudflareRelayApiTokenLabel")}
</label>
<input
id="cloudflare-api-token"
type="password"
value={apiToken}
onChange={(e) => 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"
/>
<p className="text-xs text-text-muted mt-1">
{t("cloudflareRelayApiTokenHint")}
</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block" htmlFor="cloudflare-project-name">
{t("cloudflareRelayProjectNameLabel")}
</label>
<input
id="cloudflare-project-name"
type="text"
value={projectName}
onChange={(e) => 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"
/>
</div>
</div>
{error && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded p-2">
{error}
</div>
)}
<p className="text-xs text-text-muted">{t("cloudflareRelayFreeTierNote")}</p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={onClose} disabled={deploying}>
{t("cancel")}
</Button>
<Button variant="primary" size="sm" onClick={handleDeploy} disabled={deploying}>
{deploying ? t("cloudflareRelayDeploying") : t("cloudflareRelayDeploy")}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -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<HTMLDivElement | null>(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 (
<div className="space-y-4">
{showAnyRelay && (
@@ -86,6 +93,24 @@ export default function ProxyPoolTab() {
{t("denoRelayButton")}
</button>
)}
{showCloudflareRelay && (
<button
type="button"
onClick={() => {
setCloudflareModalOpen(true);
setMenuOpen(false);
}}
className="flex w-full items-center gap-2 rounded px-3 py-2 text-sm hover:bg-surface-alt"
>
<span
className="material-symbols-outlined text-[20px] text-primary"
aria-hidden="true"
>
cloud
</span>
{t("cloudflareRelayButton")}
</button>
)}
</div>
)}
</div>
@@ -95,12 +120,17 @@ export default function ProxyPoolTab() {
<VercelRelayModal
isOpen={vercelModalOpen}
onClose={() => setVercelModalOpen(false)}
onDeployed={handleDeployed}
onDeployed={handleVercelDeployed}
/>
<DenoRelayModal
isOpen={denoModalOpen}
onClose={() => setDenoModalOpen(false)}
onDeployed={handleDeployed}
onDeployed={handleVercelDeployed}
/>
<CloudflareRelayModal
isOpen={cloudflareModalOpen}
onClose={() => setCloudflareModalOpen(false)}
onDeployed={handleCloudflareDeployed}
/>
</div>
);

View File

@@ -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://<name>.<sub>.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");
}
}

View File

@@ -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",

View File

@@ -1,5 +1,8 @@
// Convention: when type is a relay (vercel | deno), the `notes` column stores JSON { relayAuth: "<token>" }
// 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: "<token>" } 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);

View File

@@ -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" },
});
}
},
};
`;
}

View File

@@ -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"),
});

View File

@@ -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",
});
});