diff --git a/open-sse/utils/proxyFetch.js b/open-sse/utils/proxyFetch.js index 0e4acc41..66cf1575 100644 --- a/open-sse/utils/proxyFetch.js +++ b/open-sse/utils/proxyFetch.js @@ -1,9 +1,8 @@ - const isCloud = typeof caches !== "undefined" && typeof caches === "object"; const originalFetch = globalThis.fetch; -let proxyAgent = null; -let socksAgent = null; +let proxyDispatcher = null; +let proxyDispatcherUrl = null; /** * Get proxy URL from environment @@ -36,24 +35,34 @@ function getProxyUrl(targetUrl) { } /** - * Create proxy agent lazily + * Normalize proxy URL (allow host:port) */ -async function getAgent(proxyUrl) { - const proxyProtocol = new URL(proxyUrl).protocol; - - if (proxyProtocol === "socks:" || proxyProtocol === "socks5:" || proxyProtocol === "socks4:") { - if (!socksAgent) { - const { SocksProxyAgent } = await import("socks-proxy-agent"); - socksAgent = new SocksProxyAgent(proxyUrl); - } - return socksAgent; +function normalizeProxyUrl(proxyUrl) { + 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 (!proxyAgent) { - const { HttpsProxyAgent } = await import("https-proxy-agent"); - proxyAgent = new HttpsProxyAgent(proxyUrl); +} + +/** + * Create proxy dispatcher lazily (undici-compatible) + */ +async function getDispatcher(proxyUrl) { + const normalized = normalizeProxyUrl(proxyUrl); + if (!normalized) return null; + + if (!proxyDispatcher || proxyDispatcherUrl !== normalized) { + const { ProxyAgent } = await import("undici"); + proxyDispatcher = new ProxyAgent({ uri: normalized }); + proxyDispatcherUrl = normalized; } - return proxyAgent; + + return proxyDispatcher; } /** @@ -61,12 +70,12 @@ async function getAgent(proxyUrl) { */ async function patchedFetch(url, options = {}) { const targetUrl = typeof url === "string" ? url : url.toString(); - const proxyUrl = getProxyUrl(targetUrl); + const proxyUrl = normalizeProxyUrl(getProxyUrl(targetUrl)); if (proxyUrl) { try { - const agent = await getAgent(proxyUrl); - return await originalFetch(url, { ...options, dispatcher: agent }); + const dispatcher = await getDispatcher(proxyUrl); + return await originalFetch(url, { ...options, dispatcher }); } catch (proxyError) { // Fallback to direct connection if proxy fails console.warn(`[ProxyFetch] Proxy failed, falling back to direct: ${proxyError.message}`); diff --git a/package.json b/package.json index b8ecf590..6b34490f 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "express": "^5.2.1", "fs": "^0.0.1-security", "http-proxy-middleware": "^3.0.5", - "https-proxy-agent": "^7.0.6", "jose": "^6.1.3", "lowdb": "^7.0.1", "monaco-editor": "^0.55.1", diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index b1d1ea69..0ee1e3cc 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -16,12 +16,25 @@ export default function ProfilePage() { const [dbLoading, setDbLoading] = useState(false); const [dbStatus, setDbStatus] = useState({ type: "", message: "" }); 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(() => { fetch("/api/settings") .then((res) => res.json()) .then((data) => { setSettings(data); + setProxyForm({ + outboundProxyEnabled: data?.outboundProxyEnabled === true, + outboundProxyUrl: data?.outboundProxyUrl || "", + outboundNoProxy: data?.outboundNoProxy || "", + }); setLoading(false); }) .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) => { e.preventDefault(); if (passwords.new !== passwords.confirm) { @@ -379,6 +489,77 @@ export default function ProfilePage() { + {/* Network */} + +
+
+ wifi +
+

Network

+
+ +
+
+
+

Outbound Proxy

+

Enable proxy for OAuth + provider outbound requests.

+
+ updateOutboundProxyEnabled(!(settings.outboundProxyEnabled === true))} + disabled={loading || proxyLoading} + /> +
+ + {settings.outboundProxyEnabled === true && ( +
+
+ + setProxyForm((prev) => ({ ...prev, outboundProxyUrl: e.target.value }))} + disabled={loading || proxyLoading} + /> +

Leave empty to inherit existing env proxy (if any).

+
+ +
+ + setProxyForm((prev) => ({ ...prev, outboundNoProxy: e.target.value }))} + disabled={loading || proxyLoading} + /> +

Comma-separated hostnames/domains to bypass the proxy.

+
+ +
+ + +
+
+ )} + + {proxyStatus.message && ( +

+ {proxyStatus.message} +

+ )} +
+
+ {/* Theme Preferences */}
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,