mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
feat(proxy): add outbound HTTP proxy support for OAuth + provider requests
- Patch Node fetch via undici ProxyAgent when HTTP_PROXY/HTTPS_PROXY/ALL_PROXY is set - Ensure proxy patch is loaded for both chat pipeline and OAuth token exchange - Add Dashboard Settings → Network to edit outbound proxy and apply immediately - Persist outbound proxy settings in local db and initialize on server startup - Move proxy helpers to src/lib/network/ for better structure - Rename src/proxy.js → src/dashboardGuard.js to avoid naming confusion - Re-apply proxy env after DB import - Fix: close old dispatcher on proxy URL change to prevent connection pool leak - Fix: idempotency guard to avoid patching globalThis.fetch multiple times Made-with: Cursor
This commit is contained in:
+21
-5
@@ -57,7 +57,10 @@ const defaultData = {
|
||||
observabilityMaxRecords: 1000,
|
||||
observabilityBatchSize: 20,
|
||||
observabilityFlushIntervalMs: 5000,
|
||||
observabilityMaxJsonSize: 1024
|
||||
observabilityMaxJsonSize: 1024,
|
||||
outboundProxyEnabled: false,
|
||||
outboundProxyUrl: "",
|
||||
outboundNoProxy: ""
|
||||
},
|
||||
pricing: {} // NEW: pricing configuration
|
||||
};
|
||||
@@ -72,15 +75,18 @@ function cloneDefaultData() {
|
||||
apiKeys: [],
|
||||
settings: {
|
||||
cloudEnabled: false,
|
||||
tunnelEnabled: false,
|
||||
tunnelUrl: "",
|
||||
tunnelEnabled: false,
|
||||
tunnelUrl: "",
|
||||
stickyRoundRobinLimit: 3,
|
||||
requireLogin: true,
|
||||
observabilityEnabled: true,
|
||||
observabilityMaxRecords: 1000,
|
||||
observabilityBatchSize: 20,
|
||||
observabilityFlushIntervalMs: 5000,
|
||||
observabilityMaxJsonSize: 1024
|
||||
observabilityMaxJsonSize: 1024,
|
||||
outboundProxyEnabled: false,
|
||||
outboundProxyUrl: "",
|
||||
outboundNoProxy: "",
|
||||
},
|
||||
pricing: {},
|
||||
};
|
||||
@@ -114,7 +120,17 @@ function ensureDbShape(data) {
|
||||
) {
|
||||
for (const [settingKey, settingDefault] of Object.entries(defaultValue)) {
|
||||
if (next.settings[settingKey] === undefined) {
|
||||
next.settings[settingKey] = settingDefault;
|
||||
// Backward-compat: if users previously saved a proxy URL,
|
||||
// default to enabled so behavior doesn't silently change.
|
||||
if (
|
||||
settingKey === "outboundProxyEnabled" &&
|
||||
typeof next.settings.outboundProxyUrl === "string" &&
|
||||
next.settings.outboundProxyUrl.trim()
|
||||
) {
|
||||
next.settings.outboundProxyEnabled = true;
|
||||
} else {
|
||||
next.settings[settingKey] = settingDefault;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export async function ensureOutboundProxyInitialized() {
|
||||
if (initialized) return true;
|
||||
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
applyOutboundProxyEnv(settings);
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error("[ServerInit] Error initializing outbound proxy:", error);
|
||||
}
|
||||
|
||||
return initialized;
|
||||
}
|
||||
|
||||
ensureOutboundProxyInitialized().catch(console.log);
|
||||
|
||||
export default ensureOutboundProxyInitialized;
|
||||
@@ -0,0 +1,68 @@
|
||||
function normalizeString(value) {
|
||||
if (value === undefined || value === null) return "";
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
export function applyOutboundProxyEnv(
|
||||
{ outboundProxyEnabled, outboundProxyUrl, outboundNoProxy } = {}
|
||||
) {
|
||||
if (typeof process === "undefined" || !process.env) return;
|
||||
const enabled = Boolean(outboundProxyEnabled);
|
||||
const proxyUrl = normalizeString(outboundProxyUrl);
|
||||
const noProxy = normalizeString(outboundNoProxy);
|
||||
|
||||
// If disabled, only clear env vars we previously managed.
|
||||
if (!enabled) {
|
||||
if (process.env.NINE_ROUTER_PROXY_MANAGED === "1") {
|
||||
delete process.env.HTTP_PROXY;
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.ALL_PROXY;
|
||||
delete process.env.NO_PROXY;
|
||||
delete process.env.NINE_ROUTER_PROXY_MANAGED;
|
||||
delete process.env.NINE_ROUTER_PROXY_URL;
|
||||
delete process.env.NINE_ROUTER_NO_PROXY;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// When enabled:
|
||||
// - If values are provided, write them and mark as managed
|
||||
// - If values are empty, do not touch externally-provided env,
|
||||
// but do clear values we previously managed.
|
||||
const wasManaged = process.env.NINE_ROUTER_PROXY_MANAGED === "1";
|
||||
let managed = false;
|
||||
|
||||
if (wasManaged) {
|
||||
if (!proxyUrl) {
|
||||
delete process.env.HTTP_PROXY;
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.ALL_PROXY;
|
||||
delete process.env.NINE_ROUTER_PROXY_URL;
|
||||
}
|
||||
if (!noProxy) {
|
||||
delete process.env.NO_PROXY;
|
||||
delete process.env.NINE_ROUTER_NO_PROXY;
|
||||
}
|
||||
}
|
||||
|
||||
if (proxyUrl) {
|
||||
process.env.HTTP_PROXY = proxyUrl;
|
||||
process.env.HTTPS_PROXY = proxyUrl;
|
||||
process.env.ALL_PROXY = proxyUrl;
|
||||
process.env.NINE_ROUTER_PROXY_URL = proxyUrl;
|
||||
managed = true;
|
||||
}
|
||||
|
||||
if (noProxy) {
|
||||
process.env.NO_PROXY = noProxy;
|
||||
process.env.NINE_ROUTER_NO_PROXY = noProxy;
|
||||
managed = true;
|
||||
}
|
||||
|
||||
if (managed) {
|
||||
process.env.NINE_ROUTER_PROXY_MANAGED = "1";
|
||||
} else if (wasManaged) {
|
||||
// If we previously managed env but now cleared everything, drop the marker.
|
||||
delete process.env.NINE_ROUTER_PROXY_MANAGED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ProxyAgent, fetch as undiciFetch } from "undici";
|
||||
|
||||
const DEFAULT_TEST_URL = "https://example.com/";
|
||||
const DEFAULT_TIMEOUT_MS = 8000;
|
||||
|
||||
function normalizeString(value) {
|
||||
if (value === undefined || value === null) return "";
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
export async function testProxyUrl({ proxyUrl, testUrl, timeoutMs } = {}) {
|
||||
const normalizedProxyUrl = normalizeString(proxyUrl);
|
||||
if (!normalizedProxyUrl) {
|
||||
return { ok: false, status: 400, error: "proxyUrl is required" };
|
||||
}
|
||||
|
||||
const normalizedTestUrl = normalizeString(testUrl) || DEFAULT_TEST_URL;
|
||||
const timeoutMsRaw = Number(timeoutMs);
|
||||
const normalizedTimeoutMs =
|
||||
Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0
|
||||
? Math.min(timeoutMsRaw, 30000)
|
||||
: DEFAULT_TIMEOUT_MS;
|
||||
|
||||
let dispatcher;
|
||||
|
||||
try {
|
||||
try {
|
||||
dispatcher = new ProxyAgent({ uri: normalizedProxyUrl });
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: `Invalid proxy URL: ${err?.message || String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const startedAt = Date.now();
|
||||
const timer = setTimeout(() => controller.abort(), normalizedTimeoutMs);
|
||||
|
||||
try {
|
||||
const res = await undiciFetch(normalizedTestUrl, {
|
||||
method: "HEAD",
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "9Router",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
url: normalizedTestUrl,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
} catch (err) {
|
||||
const message =
|
||||
err?.name === "AbortError"
|
||||
? "Proxy test timed out"
|
||||
: err?.message || String(err);
|
||||
return { ok: false, status: 500, error: message };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await dispatcher?.close?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
* Centralized DRY approach for all OAuth providers
|
||||
*/
|
||||
|
||||
// Ensure outbound fetch respects HTTP(S)_PROXY/ALL_PROXY in Node runtime
|
||||
import "open-sse/index.js";
|
||||
|
||||
import { generatePKCE, generateState } from "./utils/pkce";
|
||||
import {
|
||||
CLAUDE_CONFIG,
|
||||
|
||||
Reference in New Issue
Block a user