/// /** * HTTP/HTTPS Proxy support for Deno fetch * * Reads proxy settings from: * 1. Runtime config set via setRuntimeProxy() / /api/proxy/set * 2. Environment variables: HTTPS_PROXY, HTTP_PROXY, PROXY_URL * * NO_PROXY — comma-separated hosts to bypass (e.g. localhost,127.0.0.1) * * Run with: * HTTPS_PROXY=http://user:pass@host:port deno run --allow-net --allow-env --allow-read mod.ts */ // ─── Native fetch reference (captured BEFORE any patching) ─────────────────── // Must be module-level so it's captured at import time. const _nativeFetch: typeof fetch = globalThis.fetch.bind(globalThis); // ─── Runtime proxy config (set via API / UI) ───────────────────────────────── let _runtimeProxyUrl: string | null = null; /** Set proxy URL at runtime (called from /api/proxy/set). Pass null to clear. */ export function setRuntimeProxy(url: string | null): void { _runtimeProxyUrl = url; } /** Returns the active proxy URL (runtime config takes priority over env vars). */ export function getActiveProxyUrl(): string | null { if (_runtimeProxyUrl) return _runtimeProxyUrl; return ( Deno.env.get("HTTPS_PROXY") || Deno.env.get("https_proxy") || Deno.env.get("HTTP_PROXY") || Deno.env.get("http_proxy") || Deno.env.get("PROXY_URL") || null ); } // ─── Parse proxy URL for a given target ────────────────────────────────────── function getProxyUrl(targetUrl: string): string | null { const proxyUrl = getActiveProxyUrl(); if (!proxyUrl) return null; // Check NO_PROXY const noProxy = (Deno.env.get("NO_PROXY") || Deno.env.get("no_proxy") || "") .split(",").map((s: string) => s.trim()).filter(Boolean); if (noProxy.length) { try { const { hostname } = new URL(targetUrl); for (const entry of noProxy) { if (entry === "*" || hostname === entry || hostname.endsWith("." + entry)) return null; } } catch { /* ignore */ } } return proxyUrl; } // ─── HTTP CONNECT tunnel ────────────────────────────────────────────────────── async function connectViaProxy( proxyUrl: string, targetHost: string, targetPort: number, ): Promise { const proxy = new URL(proxyUrl); const proxyHost = proxy.hostname; const proxyPort = parseInt(proxy.port || "8080"); const conn = await Deno.connect({ hostname: proxyHost, port: proxyPort }); let authHeader = ""; if (proxy.username) { const creds = btoa(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`); authHeader = `Proxy-Authorization: Basic ${creds}\r\n`; } const connectReq = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`; const encoder = new TextEncoder(); await conn.write(encoder.encode(connectReq)); const buf = new Uint8Array(4096); const n = await conn.read(buf); if (n === null) throw new Error("Proxy closed connection unexpectedly"); const response = new TextDecoder().decode(buf.subarray(0, n)); const statusLine = response.split("\r\n")[0]; const statusCode = parseInt(statusLine.split(" ")[1]); if (statusCode !== 200) { conn.close(); throw new Error(`Proxy CONNECT failed: ${statusLine}`); } return conn; } // ─── Proxy-aware fetch ──────────────────────────────────────────────────────── /** * Drop-in replacement for global `fetch` that routes through proxy when configured. * Falls back to native fetch when no proxy is configured. */ export async function proxyFetch( input: string | URL | Request, init?: RequestInit, ): Promise { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const proxyUrl = getProxyUrl(url); // No proxy — use the NATIVE fetch (not globalThis.fetch to avoid recursion!) if (!proxyUrl) { return _nativeFetch(input, init); } try { const targetUrl = new URL(url); const isHttps = targetUrl.protocol === "https:"; const targetHost = targetUrl.hostname; const targetPort = parseInt(targetUrl.port || (isHttps ? "443" : "80")); if (isHttps) { const tunnel = await connectViaProxy(proxyUrl, targetHost, targetPort); const tlsConn = await Deno.startTls(tunnel, { hostname: targetHost }); const method = (init?.method || "GET").toUpperCase(); const path = targetUrl.pathname + targetUrl.search; const headers = new Headers(init?.headers as HeadersInit | undefined); if (!headers.has("Host")) headers.set("Host", targetHost); if (!headers.has("User-Agent")) headers.set("User-Agent", "Mozilla/5.0"); if (!headers.has("Connection")) headers.set("Connection", "close"); let body = ""; if (init?.body) { const bodyText = typeof init.body === "string" ? init.body : new TextDecoder().decode(init.body as Uint8Array); if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json"); if (!headers.has("Content-Length")) headers.set("Content-Length", String(new TextEncoder().encode(bodyText).length)); body = bodyText; } let rawRequest = `${method} ${path} HTTP/1.1\r\n`; headers.forEach((value, key) => { rawRequest += `${key}: ${value}\r\n`; }); rawRequest += "\r\n"; if (body) rawRequest += body; const encoder = new TextEncoder(); await tlsConn.write(encoder.encode(rawRequest)); const chunks: Uint8Array[] = []; const tmpBuf = new Uint8Array(16384); while (true) { const n = await tlsConn.read(tmpBuf); if (n === null) break; chunks.push(tmpBuf.slice(0, n)); } tlsConn.close(); const fullResponse = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0)); let offset = 0; for (const chunk of chunks) { fullResponse.set(chunk, offset); offset += chunk.length; } const responseText = new TextDecoder().decode(fullResponse); const headerEnd = responseText.indexOf("\r\n\r\n"); if (headerEnd === -1) throw new Error("Invalid HTTP response from proxy tunnel"); const headerSection = responseText.slice(0, headerEnd); const bodyBytes = fullResponse.slice(new TextEncoder().encode(headerSection + "\r\n\r\n").length); const lines = headerSection.split("\r\n"); const statusLine = lines[0]; const statusCode = parseInt(statusLine.split(" ")[1]); const statusText = statusLine.split(" ").slice(2).join(" "); const responseHeaders = new Headers(); for (const line of lines.slice(1)) { const idx = line.indexOf(": "); if (idx !== -1) responseHeaders.append(line.slice(0, idx), line.slice(idx + 2)); } return new Response(bodyBytes, { status: statusCode, statusText, headers: responseHeaders }); } // HTTP plain — forward proxy const proxy = new URL(proxyUrl); const conn = await Deno.connect({ hostname: proxy.hostname, port: parseInt(proxy.port || "8080") }); const method = (init?.method || "GET").toUpperCase(); const headers = new Headers(init?.headers as HeadersInit | undefined); if (!headers.has("Host")) headers.set("Host", targetHost); if (!headers.has("User-Agent")) headers.set("User-Agent", "Mozilla/5.0"); if (!headers.has("Connection")) headers.set("Connection", "close"); let body = ""; if (init?.body) { const bodyText = typeof init.body === "string" ? init.body : new TextDecoder().decode(init.body as Uint8Array); if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json"); if (!headers.has("Content-Length")) headers.set("Content-Length", String(new TextEncoder().encode(bodyText).length)); body = bodyText; } if (proxy.username && !headers.has("Proxy-Authorization")) { const creds = btoa(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`); headers.set("Proxy-Authorization", `Basic ${creds}`); } let rawRequest = `${method} ${url} HTTP/1.1\r\n`; headers.forEach((value, key) => { rawRequest += `${key}: ${value}\r\n`; }); rawRequest += "\r\n"; if (body) rawRequest += body; const encoder = new TextEncoder(); await conn.write(encoder.encode(rawRequest)); const chunks: Uint8Array[] = []; const tmpBuf = new Uint8Array(16384); while (true) { const n = await conn.read(tmpBuf); if (n === null) break; chunks.push(tmpBuf.slice(0, n)); } conn.close(); const fullResponse = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0)); let off = 0; for (const chunk of chunks) { fullResponse.set(chunk, off); off += chunk.length; } const responseText = new TextDecoder().decode(fullResponse); const headerEnd = responseText.indexOf("\r\n\r\n"); if (headerEnd === -1) throw new Error("Invalid HTTP response"); const headerSection = responseText.slice(0, headerEnd); const bodyBytes = fullResponse.slice(new TextEncoder().encode(headerSection + "\r\n\r\n").length); const lines = headerSection.split("\r\n"); const statusLine = lines[0]; const statusCode = parseInt(statusLine.split(" ")[1]); const statusText = statusLine.split(" ").slice(2).join(" "); const responseHeaders = new Headers(); for (const line of lines.slice(1)) { const idx = line.indexOf(": "); if (idx !== -1) responseHeaders.append(line.slice(0, idx), line.slice(idx + 2)); } return new Response(bodyBytes, { status: statusCode, statusText, headers: responseHeaders }); } catch (err) { // Proxy error — fall back to native fetch console.warn(`[proxy] Error via proxy for ${url}: ${err}. Falling back to direct.`); return _nativeFetch(input, init); } } // ─── Install ────────────────────────────────────────────────────────────────── /** * Call once at startup to replace globalThis.fetch with proxyFetch. * The native fetch is already captured at module level, so no recursion issues. */ export function installProxyFetch(): void { // @ts-ignore — patching global fetch globalThis.fetch = proxyFetch; const active = getActiveProxyUrl(); if (active) { const masked = active.replace(/:\/\/[^@]*@/, "://@"); console.log(`[proxy] HTTP proxy enabled: ${masked}`); } else { console.log("[proxy] No proxy configured — using direct connections"); } } // ─── Config file persistence ────────────────────────────────────────────────── const CONFIG_FILE = "./proxy.config.json"; /** Load saved proxy URL from disk (called at startup). */ export async function loadProxyConfig(): Promise { try { const raw = await Deno.readTextFile(CONFIG_FILE); const cfg = JSON.parse(raw); if (cfg.url) { _runtimeProxyUrl = cfg.url; const masked = cfg.url.replace(/:\/\/[^@]*@/, "://@"); console.log(`[proxy] Loaded saved proxy config: ${masked}`); } } catch { // File doesn't exist or invalid — ignore } } /** Save current runtime proxy URL to disk. */ export async function saveProxyConfig(url: string | null): Promise { try { await Deno.writeTextFile(CONFIG_FILE, JSON.stringify({ url }, null, 2)); } catch (e) { console.warn("[proxy] Failed to save proxy config:", e); } }