forked from github-mirror/Verome-API
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
/// <reference lib="deno.ns" />
|
|
|
|
/**
|
|
* 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<Deno.TcpConn> {
|
|
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<Response> {
|
|
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(/:\/\/[^@]*@/, "://<credentials>@");
|
|
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<void> {
|
|
try {
|
|
const raw = await Deno.readTextFile(CONFIG_FILE);
|
|
const cfg = JSON.parse(raw);
|
|
if (cfg.url) {
|
|
_runtimeProxyUrl = cfg.url;
|
|
const masked = cfg.url.replace(/:\/\/[^@]*@/, "://<credentials>@");
|
|
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<void> {
|
|
try {
|
|
await Deno.writeTextFile(CONFIG_FILE, JSON.stringify({ url }, null, 2));
|
|
} catch (e) {
|
|
console.warn("[proxy] Failed to save proxy config:", e);
|
|
}
|
|
}
|