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:
@@ -1,9 +1,8 @@
|
|||||||
|
|
||||||
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
|
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
let proxyAgent = null;
|
let proxyDispatcher = null;
|
||||||
let socksAgent = null;
|
let proxyDispatcherUrl = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get proxy URL from environment
|
* Get proxy URL from environment
|
||||||
@@ -36,24 +35,36 @@ function getProxyUrl(targetUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create proxy agent lazily
|
* Normalize proxy URL (allow host:port)
|
||||||
*/
|
*/
|
||||||
async function getAgent(proxyUrl) {
|
function normalizeProxyUrl(proxyUrl) {
|
||||||
const proxyProtocol = new URL(proxyUrl).protocol;
|
if (!proxyUrl) return null;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-new
|
||||||
|
new URL(proxyUrl);
|
||||||
|
return proxyUrl;
|
||||||
|
} catch {
|
||||||
|
// Allow "127.0.0.1:7890" style values
|
||||||
|
return `http://${proxyUrl}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (proxyProtocol === "socks:" || proxyProtocol === "socks5:" || proxyProtocol === "socks4:") {
|
/**
|
||||||
if (!socksAgent) {
|
* Create proxy dispatcher lazily (undici-compatible)
|
||||||
const { SocksProxyAgent } = await import("socks-proxy-agent");
|
* Closes old dispatcher when proxy URL changes to prevent connection pool leak
|
||||||
socksAgent = new SocksProxyAgent(proxyUrl);
|
*/
|
||||||
}
|
async function getDispatcher(proxyUrl) {
|
||||||
return socksAgent;
|
const normalized = normalizeProxyUrl(proxyUrl);
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
if (!proxyDispatcher || proxyDispatcherUrl !== normalized) {
|
||||||
|
try { proxyDispatcher?.close?.(); } catch { /* ignore */ }
|
||||||
|
const { ProxyAgent } = await import("undici");
|
||||||
|
proxyDispatcher = new ProxyAgent({ uri: normalized });
|
||||||
|
proxyDispatcherUrl = normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!proxyAgent) {
|
return proxyDispatcher;
|
||||||
const { HttpsProxyAgent } = await import("https-proxy-agent");
|
|
||||||
proxyAgent = new HttpsProxyAgent(proxyUrl);
|
|
||||||
}
|
|
||||||
return proxyAgent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,12 +72,12 @@ async function getAgent(proxyUrl) {
|
|||||||
*/
|
*/
|
||||||
async function patchedFetch(url, options = {}) {
|
async function patchedFetch(url, options = {}) {
|
||||||
const targetUrl = typeof url === "string" ? url : url.toString();
|
const targetUrl = typeof url === "string" ? url : url.toString();
|
||||||
const proxyUrl = getProxyUrl(targetUrl);
|
const proxyUrl = normalizeProxyUrl(getProxyUrl(targetUrl));
|
||||||
|
|
||||||
if (proxyUrl) {
|
if (proxyUrl) {
|
||||||
try {
|
try {
|
||||||
const agent = await getAgent(proxyUrl);
|
const dispatcher = await getDispatcher(proxyUrl);
|
||||||
return await originalFetch(url, { ...options, dispatcher: agent });
|
return await originalFetch(url, { ...options, dispatcher });
|
||||||
} catch (proxyError) {
|
} catch (proxyError) {
|
||||||
// Fallback to direct connection if proxy fails
|
// Fallback to direct connection if proxy fails
|
||||||
console.warn(`[ProxyFetch] Proxy failed, falling back to direct: ${proxyError.message}`);
|
console.warn(`[ProxyFetch] Proxy failed, falling back to direct: ${proxyError.message}`);
|
||||||
@@ -77,7 +88,8 @@ async function patchedFetch(url, options = {}) {
|
|||||||
return originalFetch(url, options);
|
return originalFetch(url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isCloud) {
|
// Idempotency guard — only patch once to avoid wrapping multiple times
|
||||||
|
if (!isCloud && globalThis.fetch !== patchedFetch) {
|
||||||
globalThis.fetch = patchedFetch;
|
globalThis.fetch = patchedFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"https-proxy-agent": "^7.0.6",
|
|
||||||
"jose": "^6.1.3",
|
"jose": "^6.1.3",
|
||||||
"lowdb": "^7.0.1",
|
"lowdb": "^7.0.1",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
|
|||||||
@@ -16,12 +16,25 @@ export default function ProfilePage() {
|
|||||||
const [dbLoading, setDbLoading] = useState(false);
|
const [dbLoading, setDbLoading] = useState(false);
|
||||||
const [dbStatus, setDbStatus] = useState({ type: "", message: "" });
|
const [dbStatus, setDbStatus] = useState({ type: "", message: "" });
|
||||||
const importFileRef = useRef(null);
|
const importFileRef = useRef(null);
|
||||||
|
const [proxyForm, setProxyForm] = useState({
|
||||||
|
outboundProxyEnabled: false,
|
||||||
|
outboundProxyUrl: "",
|
||||||
|
outboundNoProxy: "",
|
||||||
|
});
|
||||||
|
const [proxyStatus, setProxyStatus] = useState({ type: "", message: "" });
|
||||||
|
const [proxyLoading, setProxyLoading] = useState(false);
|
||||||
|
const [proxyTestLoading, setProxyTestLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/settings")
|
fetch("/api/settings")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setSettings(data);
|
setSettings(data);
|
||||||
|
setProxyForm({
|
||||||
|
outboundProxyEnabled: data?.outboundProxyEnabled === true,
|
||||||
|
outboundProxyUrl: data?.outboundProxyUrl || "",
|
||||||
|
outboundNoProxy: data?.outboundNoProxy || "",
|
||||||
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -30,6 +43,103 @@ export default function ProfilePage() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const updateOutboundProxy = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (settings.outboundProxyEnabled !== true) return;
|
||||||
|
setProxyLoading(true);
|
||||||
|
setProxyStatus({ type: "", message: "" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
outboundProxyUrl: proxyForm.outboundProxyUrl,
|
||||||
|
outboundNoProxy: proxyForm.outboundNoProxy,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setSettings((prev) => ({ ...prev, ...data }));
|
||||||
|
setProxyStatus({ type: "success", message: "Proxy settings applied" });
|
||||||
|
} else {
|
||||||
|
setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setProxyStatus({ type: "error", message: "An error occurred" });
|
||||||
|
} finally {
|
||||||
|
setProxyLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testOutboundProxy = async () => {
|
||||||
|
if (settings.outboundProxyEnabled !== true) return;
|
||||||
|
|
||||||
|
const proxyUrl = (proxyForm.outboundProxyUrl || "").trim();
|
||||||
|
if (!proxyUrl) {
|
||||||
|
setProxyStatus({ type: "error", message: "Please enter a Proxy URL to test" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProxyTestLoading(true);
|
||||||
|
setProxyStatus({ type: "", message: "" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/proxy-test", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ proxyUrl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data?.ok) {
|
||||||
|
setProxyStatus({
|
||||||
|
type: "success",
|
||||||
|
message: `Proxy test OK (${data.status}) in ${data.elapsedMs}ms`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setProxyStatus({
|
||||||
|
type: "error",
|
||||||
|
message: data?.error || "Proxy test failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setProxyStatus({ type: "error", message: "An error occurred" });
|
||||||
|
} finally {
|
||||||
|
setProxyTestLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOutboundProxyEnabled = async (outboundProxyEnabled) => {
|
||||||
|
setProxyLoading(true);
|
||||||
|
setProxyStatus({ type: "", message: "" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ outboundProxyEnabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setSettings((prev) => ({ ...prev, ...data }));
|
||||||
|
setProxyForm((prev) => ({ ...prev, outboundProxyEnabled: data?.outboundProxyEnabled === true }));
|
||||||
|
setProxyStatus({
|
||||||
|
type: "success",
|
||||||
|
message: outboundProxyEnabled ? "Proxy enabled" : "Proxy disabled",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setProxyStatus({ type: "error", message: "An error occurred" });
|
||||||
|
} finally {
|
||||||
|
setProxyLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePasswordChange = async (e) => {
|
const handlePasswordChange = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (passwords.new !== passwords.confirm) {
|
if (passwords.new !== passwords.confirm) {
|
||||||
@@ -379,6 +489,77 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Network */}
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">wifi</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">Network</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Outbound Proxy</p>
|
||||||
|
<p className="text-sm text-text-muted">Enable proxy for OAuth + provider outbound requests.</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={settings.outboundProxyEnabled === true}
|
||||||
|
onChange={() => updateOutboundProxyEnabled(!(settings.outboundProxyEnabled === true))}
|
||||||
|
disabled={loading || proxyLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.outboundProxyEnabled === true && (
|
||||||
|
<form onSubmit={updateOutboundProxy} className="flex flex-col gap-4 pt-2 border-t border-border/50">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="font-medium">Proxy URL</label>
|
||||||
|
<Input
|
||||||
|
placeholder="http://127.0.0.1:7897"
|
||||||
|
value={proxyForm.outboundProxyUrl}
|
||||||
|
onChange={(e) => setProxyForm((prev) => ({ ...prev, outboundProxyUrl: e.target.value }))}
|
||||||
|
disabled={loading || proxyLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-text-muted">Leave empty to inherit existing env proxy (if any).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 pt-2 border-t border-border/50">
|
||||||
|
<label className="font-medium">No Proxy</label>
|
||||||
|
<Input
|
||||||
|
placeholder="localhost,127.0.0.1"
|
||||||
|
value={proxyForm.outboundNoProxy}
|
||||||
|
onChange={(e) => setProxyForm((prev) => ({ ...prev, outboundNoProxy: e.target.value }))}
|
||||||
|
disabled={loading || proxyLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-text-muted">Comma-separated hostnames/domains to bypass the proxy.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-border/50 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
loading={proxyTestLoading}
|
||||||
|
disabled={loading || proxyLoading}
|
||||||
|
onClick={testOutboundProxy}
|
||||||
|
>
|
||||||
|
Test proxy URL
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" loading={proxyLoading}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{proxyStatus.message && (
|
||||||
|
<p className={`text-sm ${proxyStatus.type === "error" ? "text-red-500" : "text-green-500"} pt-2 border-t border-border/50`}>
|
||||||
|
{proxyStatus.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Theme Preferences */}
|
{/* Theme Preferences */}
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { exportDb, importDb } from "@/lib/localDb";
|
import { exportDb, getSettings, importDb } from "@/lib/localDb";
|
||||||
|
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -15,6 +16,15 @@ export async function POST(request) {
|
|||||||
try {
|
try {
|
||||||
const payload = await request.json();
|
const payload = await request.json();
|
||||||
await importDb(payload);
|
await importDb(payload);
|
||||||
|
|
||||||
|
// Ensure proxy settings take effect immediately after a DB import.
|
||||||
|
try {
|
||||||
|
const settings = await getSettings();
|
||||||
|
applyOutboundProxyEnv(settings);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[Settings][DatabaseImport] Failed to re-apply outbound proxy env:", err);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error importing database:", error);
|
console.log("Error importing database:", error);
|
||||||
|
|||||||
23
src/app/api/settings/proxy-test/route.js
Normal file
23
src/app/api/settings/proxy-test/route.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { testProxyUrl } from "@/lib/network/proxyTest";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const result = await testProxyUrl({
|
||||||
|
proxyUrl: body?.proxyUrl,
|
||||||
|
testUrl: body?.testUrl,
|
||||||
|
timeoutMs: body?.timeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.ok) {
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = typeof result?.status === "number" ? result.status : 500;
|
||||||
|
return NextResponse.json({ ok: false, error: result?.error || "Proxy test failed" }, { status });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err?.name === "AbortError" ? "Proxy test timed out" : (err?.message || String(err));
|
||||||
|
return NextResponse.json({ ok: false, error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||||
|
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -53,6 +54,15 @@ export async function PATCH(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settings = await updateSettings(body);
|
const settings = await updateSettings(body);
|
||||||
|
|
||||||
|
// Apply outbound proxy settings immediately (no restart required)
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(body, "outboundProxyEnabled") ||
|
||||||
|
Object.prototype.hasOwnProperty.call(body, "outboundProxyUrl") ||
|
||||||
|
Object.prototype.hasOwnProperty.call(body, "outboundNoProxy")
|
||||||
|
) {
|
||||||
|
applyOutboundProxyEnv(settings);
|
||||||
|
}
|
||||||
const { password, ...safeSettings } = settings;
|
const { password, ...safeSettings } = settings;
|
||||||
return NextResponse.json(safeSettings);
|
return NextResponse.json(safeSettings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Inter } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/shared/components/ThemeProvider";
|
import { ThemeProvider } from "@/shared/components/ThemeProvider";
|
||||||
import "@/lib/initCloudSync"; // Auto-initialize cloud sync
|
import "@/lib/initCloudSync"; // Auto-initialize cloud sync
|
||||||
|
import "@/lib/network/initOutboundProxy"; // Auto-initialize outbound proxy env
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ const defaultData = {
|
|||||||
observabilityMaxRecords: 1000,
|
observabilityMaxRecords: 1000,
|
||||||
observabilityBatchSize: 20,
|
observabilityBatchSize: 20,
|
||||||
observabilityFlushIntervalMs: 5000,
|
observabilityFlushIntervalMs: 5000,
|
||||||
observabilityMaxJsonSize: 1024
|
observabilityMaxJsonSize: 1024,
|
||||||
|
outboundProxyEnabled: false,
|
||||||
|
outboundProxyUrl: "",
|
||||||
|
outboundNoProxy: ""
|
||||||
},
|
},
|
||||||
pricing: {} // NEW: pricing configuration
|
pricing: {} // NEW: pricing configuration
|
||||||
};
|
};
|
||||||
@@ -72,15 +75,18 @@ function cloneDefaultData() {
|
|||||||
apiKeys: [],
|
apiKeys: [],
|
||||||
settings: {
|
settings: {
|
||||||
cloudEnabled: false,
|
cloudEnabled: false,
|
||||||
tunnelEnabled: false,
|
tunnelEnabled: false,
|
||||||
tunnelUrl: "",
|
tunnelUrl: "",
|
||||||
stickyRoundRobinLimit: 3,
|
stickyRoundRobinLimit: 3,
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
observabilityEnabled: true,
|
observabilityEnabled: true,
|
||||||
observabilityMaxRecords: 1000,
|
observabilityMaxRecords: 1000,
|
||||||
observabilityBatchSize: 20,
|
observabilityBatchSize: 20,
|
||||||
observabilityFlushIntervalMs: 5000,
|
observabilityFlushIntervalMs: 5000,
|
||||||
observabilityMaxJsonSize: 1024
|
observabilityMaxJsonSize: 1024,
|
||||||
|
outboundProxyEnabled: false,
|
||||||
|
outboundProxyUrl: "",
|
||||||
|
outboundNoProxy: "",
|
||||||
},
|
},
|
||||||
pricing: {},
|
pricing: {},
|
||||||
};
|
};
|
||||||
@@ -114,7 +120,17 @@ function ensureDbShape(data) {
|
|||||||
) {
|
) {
|
||||||
for (const [settingKey, settingDefault] of Object.entries(defaultValue)) {
|
for (const [settingKey, settingDefault] of Object.entries(defaultValue)) {
|
||||||
if (next.settings[settingKey] === undefined) {
|
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;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/lib/network/initOutboundProxy.js
Normal file
22
src/lib/network/initOutboundProxy.js
Normal 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
src/lib/network/outboundProxy.js
Normal file
68
src/lib/network/outboundProxy.js
Normal 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
src/lib/network/proxyTest.js
Normal file
74
src/lib/network/proxyTest.js
Normal 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,6 +3,9 @@
|
|||||||
* Centralized DRY approach for all OAuth providers
|
* 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 { generatePKCE, generateState } from "./utils/pkce";
|
||||||
import {
|
import {
|
||||||
CLAUDE_CONFIG,
|
CLAUDE_CONFIG,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import "open-sse/index.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getProviderCredentials,
|
getProviderCredentials,
|
||||||
markAccountUnavailable,
|
markAccountUnavailable,
|
||||||
|
|||||||
Reference in New Issue
Block a user