mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
This commit is contained in:
committed by
GitHub
parent
86b8938e2c
commit
b8cc4feef1
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
136
tests/unit/relay-deploy-5128.test.ts
Normal file
136
tests/unit/relay-deploy-5128.test.ts
Normal file
@@ -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`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user