Spaces:
Runtime error
Runtime error
| import { request as undiciRequest } from "undici"; | |
| import { | |
| createProxyDispatcher, | |
| isRelayType, | |
| isSocks5ProxyEnabled, | |
| proxyConfigToUrl, | |
| proxyUrlForLogs, | |
| } from "@omniroute/open-sse/utils/proxyDispatcher.ts"; | |
| import { testProxySchema } from "@/shared/validation/schemas"; | |
| import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; | |
| import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; | |
| import { getProxyById } from "@/lib/localDb"; | |
| import { extractRelayAuth } from "@/lib/db/proxies"; | |
| import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; | |
| import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; | |
| const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]); | |
| function getErrorMessage(error: unknown, fallbackMessage: string): string { | |
| return sanitizeErrorMessage(error) || fallbackMessage; | |
| } | |
| function getSupportedProxyTypes() { | |
| if (isSocks5ProxyEnabled()) { | |
| return new Set([...BASE_SUPPORTED_PROXY_TYPES, "socks5"]); | |
| } | |
| return BASE_SUPPORTED_PROXY_TYPES; | |
| } | |
| function supportedTypesMessage() { | |
| return isSocks5ProxyEnabled() ? "http, https, or socks5" : "http or https"; | |
| } | |
| /** | |
| * POST /api/settings/proxy/test — test proxy connectivity | |
| * Body: { proxy: { type, host, port, username?, password? } } | |
| * Returns: { success, publicIp?, latencyMs?, error? } | |
| */ | |
| export async function POST(request: Request) { | |
| const authError = await requireManagementAuth(request); | |
| if (authError) return authError; | |
| let rawBody: unknown; | |
| try { | |
| rawBody = await request.json(); | |
| } catch { | |
| return createErrorResponse({ | |
| status: 400, | |
| message: "Invalid JSON body", | |
| type: "invalid_request", | |
| }); | |
| } | |
| try { | |
| const validation = validateBody(testProxySchema, rawBody); | |
| if (isValidationFailure(validation)) { | |
| return createErrorResponse({ | |
| status: 400, | |
| message: validation.error.message, | |
| details: validation.error.details, | |
| type: "invalid_request", | |
| }); | |
| } | |
| let { proxy } = validation.data; | |
| // If a proxyId is provided, look up the real (non-redacted) credentials from DB. | |
| // The frontend sends redacted credentials (***) from listProxies(), so we need | |
| // the actual secrets for testing. | |
| const body = rawBody as Record<string, unknown>; | |
| const proxyId = typeof body.proxyId === "string" ? body.proxyId.trim() : null; | |
| let dbProxyNotes: string | null = null; | |
| if (proxyId) { | |
| const dbProxy = await getProxyById(proxyId, { includeSecrets: true }); | |
| if (dbProxy) { | |
| proxy = { | |
| ...proxy, | |
| host: proxy.host || dbProxy.host, | |
| port: proxy.port || String(dbProxy.port), | |
| type: proxy.type || dbProxy.type, | |
| username: dbProxy.username, | |
| password: dbProxy.password, | |
| }; | |
| dbProxyNotes = dbProxy.notes ?? null; | |
| } | |
| } | |
| const proxyType = String(proxy.type || "http").toLowerCase(); | |
| // Relay proxies (Vercel / Deno / Cloudflare): test by hitting ipify via the | |
| // relay headers. All three share the same x-relay-* header contract; the | |
| // only difference is the deployed edge target (#5128 — Deno/Cloudflare were | |
| // previously rejected here as unsupported proxy types). | |
| if (isRelayType(proxyType)) { | |
| const relayHost = proxy.host; | |
| // relayAuth lives in notes JSON, written by the deploy routes as either a | |
| // plaintext { relayAuth } or, on installs with STORAGE_ENCRYPTION_KEY, an | |
| // encrypted { relayAuthEnc }. extractRelayAuth handles both (#5128 — the | |
| // encrypted form was previously ignored, leaving relayAuth empty → 401). | |
| let relayAuth = extractRelayAuth(dbProxyNotes) ?? ""; | |
| // Fallback: ad-hoc callers may pass relayAuth in the password field | |
| if (!relayAuth) relayAuth = proxy.password ?? ""; | |
| const relayUrl = `https://${relayHost}`; | |
| const start = Date.now(); | |
| const controller2 = new AbortController(); | |
| const timeout2 = setTimeout(() => controller2.abort(), 10000); | |
| try { | |
| // Send request to the relay URL with relay headers; relay forwards to ipify | |
| const res = await undiciRequest(`${relayUrl}/`, { | |
| method: "GET", | |
| signal: controller2.signal, | |
| headersTimeout: 10000, | |
| bodyTimeout: 10000, | |
| headers: { | |
| "x-relay-target": "https://api64.ipify.org", | |
| "x-relay-path": "/?format=json", | |
| "x-relay-auth": relayAuth, | |
| }, | |
| }); | |
| const text = await res.body.text(); | |
| let parsedIp: { ip?: string } = {}; | |
| try { | |
| parsedIp = JSON.parse(text) as { ip?: string }; | |
| } catch {} | |
| return Response.json({ | |
| success: res.statusCode === 200, | |
| publicIp: parsedIp.ip || null, | |
| latencyMs: Date.now() - start, | |
| proxyUrl: relayUrl, | |
| }); | |
| } catch (relayErr) { | |
| return Response.json({ | |
| success: false, | |
| error: | |
| relayErr instanceof Error && relayErr.name === "AbortError" | |
| ? "Connection timeout (10s)" | |
| : getErrorMessage(relayErr, "Relay test failed"), | |
| latencyMs: Date.now() - start, | |
| proxyUrl: relayUrl, | |
| }); | |
| } finally { | |
| clearTimeout(timeout2); | |
| } | |
| } | |
| if (proxyType === "socks5" && !isSocks5ProxyEnabled()) { | |
| return createErrorResponse({ | |
| status: 400, | |
| message: "SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)", | |
| type: "invalid_request", | |
| }); | |
| } | |
| if (proxyType.startsWith("socks") && proxyType !== "socks5") { | |
| return createErrorResponse({ | |
| status: 400, | |
| message: `proxy.type must be ${supportedTypesMessage()}`, | |
| type: "invalid_request", | |
| }); | |
| } | |
| if (!getSupportedProxyTypes().has(proxyType)) { | |
| return createErrorResponse({ | |
| status: 400, | |
| message: `proxy.type must be ${supportedTypesMessage()}`, | |
| type: "invalid_request", | |
| }); | |
| } | |
| let proxyUrl: string; | |
| try { | |
| const normalizedProxyUrl = proxyConfigToUrl( | |
| { | |
| type: proxyType, | |
| host: proxy.host, | |
| port: proxy.port, | |
| username: proxy.username || "", | |
| password: proxy.password || "", | |
| }, | |
| { allowSocks5: isSocks5ProxyEnabled() } | |
| ); | |
| if (!normalizedProxyUrl) { | |
| return createErrorResponse({ | |
| status: 400, | |
| message: "Invalid proxy configuration", | |
| type: "invalid_request", | |
| }); | |
| } | |
| proxyUrl = normalizedProxyUrl; | |
| } catch (proxyError) { | |
| return createErrorResponse({ | |
| status: 400, | |
| message: getErrorMessage(proxyError, "Invalid proxy configuration"), | |
| type: "invalid_request", | |
| }); | |
| } | |
| const publicProxyUrl = proxyUrlForLogs(proxyUrl); | |
| const startTime = Date.now(); | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), 10000); | |
| const dispatcher = createProxyDispatcher(proxyUrl); | |
| try { | |
| const result = await undiciRequest("https://api64.ipify.org?format=json", { | |
| method: "GET", | |
| dispatcher, | |
| signal: controller.signal, | |
| headersTimeout: 10000, | |
| bodyTimeout: 10000, | |
| }); | |
| const responseText = await result.body.text(); | |
| let parsed: { ip?: string }; | |
| try { | |
| const parsedJson = JSON.parse(responseText); | |
| if (parsedJson && typeof parsedJson === "object") { | |
| parsed = parsedJson as { ip?: string }; | |
| } else { | |
| parsed = { ip: String(parsedJson) }; | |
| } | |
| } catch { | |
| parsed = { ip: responseText.trim() }; | |
| } | |
| return Response.json({ | |
| success: true, | |
| publicIp: parsed.ip || null, | |
| latencyMs: Date.now() - startTime, | |
| proxyUrl: publicProxyUrl, | |
| }); | |
| } catch (fetchError) { | |
| return Response.json({ | |
| success: false, | |
| error: | |
| fetchError instanceof Error && fetchError.name === "AbortError" | |
| ? "Connection timeout (10s)" | |
| : getErrorMessage(fetchError, "Connection failed"), | |
| latencyMs: Date.now() - startTime, | |
| proxyUrl: publicProxyUrl, | |
| }); | |
| } finally { | |
| clearTimeout(timeout); | |
| } | |
| } catch (error) { | |
| return createErrorResponseFromUnknown(error, "Unexpected server error"); | |
| } | |
| } | |