mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
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:
committed by
GitHub
parent
078785cbf3
commit
8080800d8f
14
.env.example
14
.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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
178
src/app/api/settings/proxy/cloudflare-deploy/route.ts
Normal file
178
src/app/api/settings/proxy/cloudflare-deploy/route.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
107
src/lib/proxyRelay/cloudflareWorkerScript.ts
Normal file
107
src/lib/proxyRelay/cloudflareWorkerScript.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
`;
|
||||
}
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
|
||||
183
tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts
Normal file
183
tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user