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:
gen
2026-02-28 10:11:53 +07:00
parent 833069caac
commit 5a015e5b4d
14 changed files with 450 additions and 29 deletions
+21 -5
View File
@@ -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;
}
}
+22
View File
@@ -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;
+68
View File
@@ -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;
}
}
+74
View File
@@ -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
View File
@@ -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,