fix(proxy): drive SOCKS5 UI option from runtime ENABLE_SOCKS5_PROXY, not build-time NEXT_PUBLIC (#3508) (#3579)

Integrated into release/v3.8.20
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-10 11:11:40 -03:00
committed by GitHub
parent 84ae9e4797
commit 9ca3c29633
4 changed files with 62 additions and 15 deletions

View File

@@ -23,6 +23,7 @@
- **fix(cli-tools):** saving the OpenCode/CLI tool config no longer 400s in cloud mode — every CLI tool card posts `apiKey: null` (the real key is resolved server-side from `keyId`), but `guideSettingsSaveSchema` used `z.string().optional()`, which rejects `null`. The schema now normalizes `null``undefined`, so the save succeeds and the `keyId`/default path is used. ([#3552](https://github.com/diegosouzapw/OmniRoute/issues/3552))
- **fix(catalog):** PublicAI is no longer miscatalogued as keyless/free — it requires an API key (registry `authType:"apikey"`; signup grants a one-time credit, then it bills). The three PublicAI models moved from `freeType:"keyless"` (which could pick them into the no-auth pool and dispatch with no `Authorization` header) to `"one-time-initial"`, and the provider's `hasFree` flag is now `false` — matching `freeTierCatalog.ts`, which already excluded publicai. ([#3558](https://github.com/diegosouzapw/OmniRoute/issues/3558))
- **fix(gemini-web):** a missing Playwright Chromium browser no longer loops and trips the provider breaker — when the browser binary is not installed, `chromium.launch()` threw an error surfaced as a retryable **500**, so accountFallback marked the account unavailable and retry-looped. It is now classified as a host/config problem and returns **503** with an actionable message (`npx playwright install chromium`) and the `X-Omni-Fallback-Hint: connection_cooldown` header, which skips the provider circuit breaker and applies a short non-exponential cooldown. ([#3516](https://github.com/diegosouzapw/OmniRoute/issues/3516))
- **fix(proxy):** the SOCKS5 proxy option now follows the runtime `ENABLE_SOCKS5_PROXY` env instead of the build-time `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` — Next.js inlines `NEXT_PUBLIC_*` at build time, so a prebuilt Docker image ignored a runtime setting and the SOCKS5 type stayed hidden. The proxy modal now reads `socks5Enabled` from `GET /api/settings/proxies` (server-side `ENABLE_SOCKS5_PROXY`), with the build-time value kept only as a static-deploy fallback. ([#3508](https://github.com/diegosouzapw/OmniRoute/issues/3508))
- **fix(security):** route raw `err.message` through `sanitizeErrorMessage()` in five web executors (`adapta-web`, `deepseek-web`, `perplexity-web`, `qoder`, `veoaifree-web`) and the embeddings + search handlers (Hard Rule #12) — these built error response bodies from the raw upstream/exception message, which could leak internal detail. ([#3494](https://github.com/diegosouzapw/OmniRoute/issues/3494), [#3495](https://github.com/diegosouzapw/OmniRoute/issues/3495))
- **fix(dashboard):** correct two dashboard fetches that hit non-existent routes (404) — `CustomHostsManager` called `/api/tools/traffic-inspector/custom-hosts` (the real route is `/hosts`), and `FeatureFlagsGrid`'s post-restart liveness probe called `/api/health` (the real lightweight endpoint is `/api/health/ping`). ([#3486](https://github.com/diegosouzapw/OmniRoute/issues/3486), [#3487](https://github.com/diegosouzapw/OmniRoute/issues/3487))
- **chore(providers):** remove the dead `krutrim` registry entry — it was half-registered (present in `providerRegistry.ts` with a baseUrl + one model, but absent from `providers.ts`, with no executor/translator/OAuth), so it was never selectable. Dropped its `ProviderIcon` entry and the `KNOWN_REGISTRY_ONLY` exception. ([#3483](https://github.com/diegosouzapw/OmniRoute/issues/3483))

View File

@@ -36,7 +36,14 @@ export async function GET(request: Request) {
}
const proxies = await listProxies({ includeSecrets: false });
return Response.json({ items: proxies, total: proxies.length });
// #3508: expose the SOCKS5 feature flag at runtime so the dashboard reflects the live
// ENABLE_SOCKS5_PROXY env (the UI previously gated on NEXT_PUBLIC_*, which is baked at
// build time and ignored a runtime Docker env).
return Response.json({
items: proxies,
total: proxies.length,
socks5Enabled: process.env.ENABLE_SOCKS5_PROXY === "true",
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load proxies");
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import Modal from "./Modal";
import Button from "./Button";
@@ -10,10 +10,12 @@ const ALL_PROXY_TYPES = [
{ value: "https", label: "HTTPS" },
{ value: "socks5", label: "SOCKS5" },
];
const SOCKS5_UI_ENABLED = process.env.NEXT_PUBLIC_ENABLE_SOCKS5_PROXY === "true";
const PROXY_TYPES = SOCKS5_UI_ENABLED
? ALL_PROXY_TYPES
: ALL_PROXY_TYPES.filter((type) => type.value !== "socks5");
// Build-time fallback (static deploys). The live value comes from GET /api/settings/proxies
// (server ENABLE_SOCKS5_PROXY) so a runtime Docker env is honoured — #3508.
const BUILD_TIME_SOCKS5 = process.env.NEXT_PUBLIC_ENABLE_SOCKS5_PROXY === "true";
export function buildProxyTypes(socks5Enabled: boolean) {
return socks5Enabled ? ALL_PROXY_TYPES : ALL_PROXY_TYPES.filter((type) => type.value !== "socks5");
}
type ProxyConfigLevel = "global" | "provider" | "combo" | "key";
@@ -135,7 +137,9 @@ export default function ProxyConfigModal({
const [mode, setMode] = useState("saved");
const [savedProxies, setSavedProxies] = useState<ProxyRegistryItem[]>([]);
const [selectedProxyId, setSelectedProxyId] = useState("");
const [proxyType, setProxyType] = useState(PROXY_TYPES[0]?.value || "http");
const [socks5Enabled, setSocks5Enabled] = useState(BUILD_TIME_SOCKS5);
const proxyTypes = useMemo(() => buildProxyTypes(socks5Enabled), [socks5Enabled]);
const [proxyType, setProxyType] = useState("http");
const [host, setHost] = useState("");
const [port, setPort] = useState("");
const [username, setUsername] = useState("");
@@ -166,14 +170,20 @@ export default function ProxyConfigModal({
try {
let hasSavedAssignment = false;
let registryItems: ProxyRegistryItem[] = [];
let runtimeSocks5 = BUILD_TIME_SOCKS5;
const registryRes = await fetch("/api/settings/proxies");
if (registryRes.ok) {
const registryPayload = await registryRes.json();
registryItems = Array.isArray(registryPayload?.items) ? registryPayload.items : [];
setSavedProxies(registryItems);
if (typeof registryPayload?.socks5Enabled === "boolean") {
runtimeSocks5 = registryPayload.socks5Enabled;
}
} else {
setSavedProxies([]);
}
setSocks5Enabled(runtimeSocks5);
const runtimeProxyTypes = buildProxyTypes(runtimeSocks5);
const scope = getAssignmentScope(level);
const assignmentParams = new URLSearchParams({ scope });
@@ -194,9 +204,9 @@ export default function ProxyConfigModal({
const assignedProxy = registryItems.find((item) => item.id === target.proxyId);
if (assignedProxy?.source === DASHBOARD_CUSTOM_PROXY_SOURCE) {
const normalizedType = String(assignedProxy.type || "http").toLowerCase();
const hasTypeOption = PROXY_TYPES.some((entry) => entry.value === normalizedType);
const hasTypeOption = runtimeProxyTypes.some((entry) => entry.value === normalizedType);
setMode("custom");
setProxyType(hasTypeOption ? normalizedType : PROXY_TYPES[0]?.value || "http");
setProxyType(hasTypeOption ? normalizedType : runtimeProxyTypes[0]?.value || "http");
setHost(assignedProxy.host || "");
setPort(String(assignedProxy.port || ""));
setUsername(
@@ -206,7 +216,7 @@ export default function ProxyConfigModal({
isRedactedSecret(assignedProxy.password) ? "" : assignedProxy.password || ""
);
setShowAuth(!!(assignedProxy.username || assignedProxy.password));
if (normalizedType === "socks5" && !SOCKS5_UI_ENABLED) {
if (normalizedType === "socks5" && !runtimeSocks5) {
setFormError(t("errorSocks5Hidden"));
}
} else {
@@ -227,15 +237,15 @@ export default function ProxyConfigModal({
const proxy = data.proxy;
if (proxy && proxy.host) {
const normalizedType = String(proxy.type || "http").toLowerCase();
const hasTypeOption = PROXY_TYPES.some((entry) => entry.value === normalizedType);
setProxyType(hasTypeOption ? normalizedType : PROXY_TYPES[0]?.value || "http");
const hasTypeOption = runtimeProxyTypes.some((entry) => entry.value === normalizedType);
setProxyType(hasTypeOption ? normalizedType : runtimeProxyTypes[0]?.value || "http");
setHost(proxy.host || "");
setPort(proxy.port || "");
setUsername(proxy.username || "");
setPassword(proxy.password || "");
setShowAuth(!!(proxy.username || proxy.password));
setHasOwnProxy(true);
if (normalizedType === "socks5" && !SOCKS5_UI_ENABLED) {
if (normalizedType === "socks5" && !runtimeSocks5) {
setFormError(t("errorSocks5Hidden"));
}
if (!hasSavedAssignment) setMode("custom");
@@ -279,7 +289,7 @@ export default function ProxyConfigModal({
}, [isOpen, level, levelId]);
const resetFields = () => {
setProxyType(PROXY_TYPES[0]?.value || "http");
setProxyType(proxyTypes[0]?.value || "http");
setHost("");
setPort("");
setUsername("");
@@ -602,7 +612,7 @@ export default function ProxyConfigModal({
{t("proxyType")}
</label>
<div className="flex gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
{PROXY_TYPES.map((t) => (
{proxyTypes.map((t) => (
<button
key={t.value}
onClick={() => setProxyType(t.value)}

View File

@@ -0,0 +1,29 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { buildProxyTypes } from "../../src/shared/components/ProxyConfigModal.tsx";
describe("buildProxyTypes — #3508 runtime socks5 flag", () => {
it("includes socks5 when socks5Enabled is true", () => {
const types = buildProxyTypes(true);
const values = types.map((t) => t.value);
assert.ok(values.includes("socks5"), "socks5 should be present when enabled");
assert.ok(values.includes("http"), "http should always be present");
assert.ok(values.includes("https"), "https should always be present");
});
it("excludes socks5 when socks5Enabled is false", () => {
const types = buildProxyTypes(false);
const values = types.map((t) => t.value);
assert.ok(!values.includes("socks5"), "socks5 should be absent when disabled");
assert.ok(values.includes("http"), "http should always be present");
assert.ok(values.includes("https"), "https should always be present");
});
it("returns a new array each call (no shared mutation)", () => {
const a = buildProxyTypes(true);
const b = buildProxyTypes(false);
assert.notStrictEqual(a, b);
assert.equal(a.length, 3);
assert.equal(b.length, 2);
});
});