diff --git a/src/app/api/settings/database/route.js b/src/app/api/settings/database/route.js
index 403f3110..5d696054 100644
--- a/src/app/api/settings/database/route.js
+++ b/src/app/api/settings/database/route.js
@@ -1,5 +1,6 @@
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() {
try {
@@ -15,6 +16,15 @@ export async function POST(request) {
try {
const payload = await request.json();
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 });
} catch (error) {
console.log("Error importing database:", error);
diff --git a/src/app/api/settings/proxy-test/route.js b/src/app/api/settings/proxy-test/route.js
new file mode 100644
index 00000000..8d390d2c
--- /dev/null
+++ b/src/app/api/settings/proxy-test/route.js
@@ -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 });
+ }
+}
diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js
index fa2332f7..eaf744d0 100644
--- a/src/app/api/settings/route.js
+++ b/src/app/api/settings/route.js
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
+import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
import bcrypt from "bcryptjs";
export async function GET() {
@@ -53,6 +54,15 @@ export async function PATCH(request) {
}
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;
return NextResponse.json(safeSettings);
} catch (error) {
diff --git a/src/app/layout.js b/src/app/layout.js
index 6639fa80..6f5f3168 100644
--- a/src/app/layout.js
+++ b/src/app/layout.js
@@ -2,6 +2,7 @@ import { Inter } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/shared/components/ThemeProvider";
import "@/lib/initCloudSync"; // Auto-initialize cloud sync
+import "@/lib/network/initOutboundProxy"; // Auto-initialize outbound proxy env
const inter = Inter({
subsets: ["latin"],
diff --git a/src/proxy.js b/src/dashboardGuard.js
similarity index 100%
rename from src/proxy.js
rename to src/dashboardGuard.js
diff --git a/src/lib/localDb.js b/src/lib/localDb.js
index 1db52ccf..19ccf8e3 100644
--- a/src/lib/localDb.js
+++ b/src/lib/localDb.js
@@ -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;
}
}
diff --git a/src/lib/network/initOutboundProxy.js b/src/lib/network/initOutboundProxy.js
new file mode 100644
index 00000000..739fb570
--- /dev/null
+++ b/src/lib/network/initOutboundProxy.js
@@ -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;
diff --git a/src/lib/network/outboundProxy.js b/src/lib/network/outboundProxy.js
new file mode 100644
index 00000000..9c99dd3a
--- /dev/null
+++ b/src/lib/network/outboundProxy.js
@@ -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;
+ }
+}
diff --git a/src/lib/network/proxyTest.js b/src/lib/network/proxyTest.js
new file mode 100644
index 00000000..62ecde7e
--- /dev/null
+++ b/src/lib/network/proxyTest.js
@@ -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
+ }
+ }
+}
diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js
index 99f8b7cc..338fc10f 100644
--- a/src/lib/oauth/providers.js
+++ b/src/lib/oauth/providers.js
@@ -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,
diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js
index 230ad640..11067114 100644
--- a/src/sse/handlers/chat.js
+++ b/src/sse/handlers/chat.js
@@ -1,3 +1,5 @@
+import "open-sse/index.js";
+
import {
getProviderCredentials,
markAccountUnavailable,