diff --git a/CHANGELOG.md b/CHANGELOG.md index 2afda224ee..8664523ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ _In development — bullets added per PR; finalized at release._ ### 🔧 Bug Fixes +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) - **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) - **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) - **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) diff --git a/src/app/api/settings/proxy/cloudflare-deploy/route.ts b/src/app/api/settings/proxy/cloudflare-deploy/route.ts index e1d35a3ac6..55aac1d561 100644 --- a/src/app/api/settings/proxy/cloudflare-deploy/route.ts +++ b/src/app/api/settings/proxy/cloudflare-deploy/route.ts @@ -54,7 +54,11 @@ export async function POST(request: Request) { const formData = new FormData(); formData.append( "index.js", - new Blob([workerScript], { type: "application/javascript+module" }), + // Cloudflare's script-upload API only accepts application/javascript, + // text/javascript, or multipart/form-data for the script part and rejects + // "application/javascript+module" outright (#5128). ES-module semantics + // come from `main_module` in the metadata blob below, not this MIME type. + new Blob([workerScript], { type: "application/javascript" }), "index.js" ); formData.append( diff --git a/src/app/api/settings/proxy/test/route.ts b/src/app/api/settings/proxy/test/route.ts index 778afa5b35..d0dd086aaf 100644 --- a/src/app/api/settings/proxy/test/route.ts +++ b/src/app/api/settings/proxy/test/route.ts @@ -1,6 +1,7 @@ import { request as undiciRequest } from "undici"; import { createProxyDispatcher, + isRelayType, isSocks5ProxyEnabled, proxyConfigToUrl, proxyUrlForLogs, @@ -9,6 +10,7 @@ import { testProxySchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; import { getProxyById } from "@/lib/localDb"; +import { extractRelayAuth } from "@/lib/db/proxies"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; @@ -84,18 +86,17 @@ export async function POST(request: Request) { const proxyType = String(proxy.type || "http").toLowerCase(); - // Vercel Relay: test by hitting ipify via the relay headers - if (proxyType === "vercel") { + // Relay proxies (Vercel / Deno / Cloudflare): test by hitting ipify via the + // relay headers. All three share the same x-relay-* header contract; the + // only difference is the deployed edge target (#5128 — Deno/Cloudflare were + // previously rejected here as unsupported proxy types). + if (isRelayType(proxyType)) { const relayHost = proxy.host; - // relayAuth lives in notes JSON: { relayAuth } stored by the vercel-deploy route. - // proxy.password is empty for relay entries; parse notes instead. - let relayAuth = ""; - if (dbProxyNotes) { - try { - const parsed = JSON.parse(dbProxyNotes) as { relayAuth?: string }; - relayAuth = parsed.relayAuth ?? ""; - } catch {} - } + // relayAuth lives in notes JSON, written by the deploy routes as either a + // plaintext { relayAuth } or, on installs with STORAGE_ENCRYPTION_KEY, an + // encrypted { relayAuthEnc }. extractRelayAuth handles both (#5128 — the + // encrypted form was previously ignored, leaving relayAuth empty → 401). + let relayAuth = extractRelayAuth(dbProxyNotes) ?? ""; // Fallback: ad-hoc callers may pass relayAuth in the password field if (!relayAuth) relayAuth = proxy.password ?? ""; const relayUrl = `https://${relayHost}`; diff --git a/src/lib/db/proxies.ts b/src/lib/db/proxies.ts index 43bd7d914e..9e3ce2d349 100755 --- a/src/lib/db/proxies.ts +++ b/src/lib/db/proxies.ts @@ -131,7 +131,7 @@ function isRelayProxyType(type: unknown): boolean { return typeof type === "string" && RELAY_PROXY_TYPES.has(type); } -function extractRelayAuth(notes: unknown): string | undefined { +export function extractRelayAuth(notes: unknown): string | undefined { if (typeof notes !== "string") return undefined; try { const parsed = JSON.parse(notes) as { diff --git a/src/shared/validation/schemas/proxy.ts b/src/shared/validation/schemas/proxy.ts index 4875ef7309..6f2de60f31 100644 --- a/src/shared/validation/schemas/proxy.ts +++ b/src/shared/validation/schemas/proxy.ts @@ -107,7 +107,7 @@ export const proxyRegistryFieldsSchema = z type: z .preprocess( (value) => (typeof value === "string" ? value.trim().toLowerCase() : value), - z.enum(["http", "https", "socks5", "vercel"]) + z.enum(["http", "https", "socks5", "vercel", "deno", "cloudflare"]) ) .optional() .default("http"), @@ -118,7 +118,16 @@ export const proxyRegistryFieldsSchema = z region: z.string().trim().max(64).nullable().optional(), notes: z.string().trim().max(1000).nullable().optional(), status: z.enum(["active", "inactive"]).optional().default("active"), - source: z.enum(["manual", "oneproxy", "dashboard-custom", "vercel-relay"]).optional(), + source: z + .enum([ + "manual", + "oneproxy", + "dashboard-custom", + "vercel-relay", + "deno-relay", + "cloudflare-relay", + ]) + .optional(), // Address-family egress policy (#3777): "auto" keeps the prior dual-stack behavior; // "ipv4"/"ipv6" pin the connection to that family (no v4 leak under an IPv6-only proxy). family: z.enum(["auto", "ipv4", "ipv6"]).optional().default("auto"), diff --git a/tests/unit/relay-deploy-5128.test.ts b/tests/unit/relay-deploy-5128.test.ts new file mode 100644 index 0000000000..7e9f149aee --- /dev/null +++ b/tests/unit/relay-deploy-5128.test.ts @@ -0,0 +1,136 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Regression tests for #5128 — one-click relay deployments (Deno + Cloudflare + +// Vercel) broken in v3.8.37. Four distinct, independently-reproducible bugs: +// A) /api/settings/proxy/test only handled type "vercel", so a "deno" or +// "cloudflare" relay fell through to the generic +// `proxy.type must be http, https, or socks5` 400 and was never tested. +// B) the test route parsed only { relayAuth } from notes, ignoring the +// encrypted { relayAuthEnc } form, so on installs with STORAGE_ENCRYPTION_KEY +// the relay auth was empty → relay returns 401 → publicIp null. +// C) the Cloudflare Worker upload sent the script part with MIME +// "application/javascript+module", which the CF API rejects (only +// application/javascript | text/javascript | multipart/form-data allowed). +// D) the proxy-registry schema enum lacked "deno"/"cloudflare", so editing a +// deployed relay in the UI failed Zod validation with a silent 400. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-5128-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const proxyTestRoute = await import("../../src/app/api/settings/proxy/test/route.ts"); +const proxySchemas = await import("../../src/shared/validation/schemas/proxy.ts"); + +test.after(() => { + core.resetDbInstance(); + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch { + /* best effort */ + } +}); + +// -------------------------------------------------------------------------- +// A) Deno relay no longer rejected as an unsupported proxy type +// -------------------------------------------------------------------------- +test("#5128A: a 'deno' relay proxy is routed through the relay branch, not rejected as unsupported", async () => { + const stored = await proxiesDb.createProxy({ + name: "Deno Relay", + type: "deno", + host: "127.0.0.1", + port: 443, + notes: JSON.stringify({ relayAuth: "deno-secret" }), + }); + + const res = await proxyTestRoute.POST( + new Request("http://localhost/api/settings/proxy/test", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ proxyId: stored.id, proxy: { host: "127.0.0.1", port: 443 } }), + }) + ); + const body = (await res.json()) as { error?: { message?: string } }; + + // Before the fix this returned 400 "proxy.type must be http, https, or socks5". + assert.notEqual(res.status, 400, body.error?.message ?? "expected non-400"); +}); + +// -------------------------------------------------------------------------- +// B) extractRelayAuth prefers the encrypted relayAuthEnc form +// -------------------------------------------------------------------------- +test("#5128B: extractRelayAuth decrypts the encrypted relayAuthEnc form (encryption disabled = identity)", () => { + // With STORAGE_ENCRYPTION_KEY unset, decrypt() is a no-op passthrough, so the + // stored relayAuthEnc value is returned verbatim. The bug was that the test + // route never looked at relayAuthEnc at all (only plain relayAuth). + const enc = proxiesDb.extractRelayAuth(JSON.stringify({ relayAuthEnc: "enc-token" })); + assert.equal(enc, "enc-token"); + + const plain = proxiesDb.extractRelayAuth(JSON.stringify({ relayAuth: "plain-token" })); + assert.equal(plain, "plain-token"); + + assert.equal(proxiesDb.extractRelayAuth("not-json"), undefined); +}); + +// -------------------------------------------------------------------------- +// C) Cloudflare Worker upload uses an accepted script MIME type +// -------------------------------------------------------------------------- +test("#5128C: Cloudflare worker upload sends an accepted script Content-Type", async () => { + const realFetch = globalThis.fetch; + let putBodyType: string | undefined; + globalThis.fetch = (async (input: unknown, init: RequestInit = {}) => { + const url = String(input); + if (init.method === "PUT" && url.includes("/workers/scripts/")) { + const fd = init.body as FormData; + const scriptPart = fd.get("index.js") as Blob; + putBodyType = scriptPart.type; + // Simulate the CF API rejecting the upload so the route short-circuits + // without making the follow-up subdomain calls. + return Response.json({ errors: [{ message: "stubbed" }] }, { status: 400 }); + } + return Response.json({ result: {} }); + }) as unknown as typeof globalThis.fetch; + + try { + const route = await import("../../src/app/api/settings/proxy/cloudflare-deploy/route.ts"); + await route.POST( + new Request("http://localhost/api/settings/proxy/cloudflare-deploy", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + accountId: "abcdef0123456789", + apiToken: "cf-token-aaaaaaaaaaaaaaaaaaaaaa", + projectName: "omniroute-relay", + }), + }) + ); + } finally { + globalThis.fetch = realFetch; + } + + // CF rejects "application/javascript+module"; only these are accepted. + assert.ok( + putBodyType === "application/javascript" || putBodyType === "text/javascript", + `expected an accepted script MIME, got "${putBodyType}"` + ); +}); + +// -------------------------------------------------------------------------- +// D) proxy-registry schema accepts deno/cloudflare relay types + sources +// -------------------------------------------------------------------------- +test("#5128D: proxyRegistryFieldsSchema accepts deno/cloudflare types and relay sources", () => { + for (const type of ["deno", "cloudflare", "vercel"]) { + const parsed = proxySchemas.proxyRegistryFieldsSchema.safeParse({ + name: `${type} relay`, + type, + host: "example.workers.dev", + port: 443, + source: `${type}-relay`, + }); + assert.ok(parsed.success, `type "${type}" / source "${type}-relay" should validate`); + } +});