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

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
}
}
}