diff --git a/Dockerfile b/Dockerfile index 791efce..cccb23d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,4 +32,7 @@ ENV HTTPS_PROXY="" ENV HTTP_PROXY="" ENV NO_PROXY="localhost,127.0.0.1" -CMD ["run", "--allow-net", "--allow-env", "--allow-read", "mod.ts"] +# Volume for persistent proxy config (proxy.config.json) +VOLUME ["/app"] + +CMD ["run", "--allow-net", "--allow-env", "--allow-read", "--allow-write", "mod.ts"] diff --git a/deno.json b/deno.json index 993cca7..fe45717 100644 --- a/deno.json +++ b/deno.json @@ -3,13 +3,13 @@ "version": "1.0.0", "exports": "./mod.ts", "tasks": { - "start": "deno run --allow-net --allow-env --allow-read mod.ts", - "dev": "deno run --watch --allow-net --allow-env --allow-read mod.ts", + "start": "deno run --allow-net --allow-env --allow-read --allow-write mod.ts", + "dev": "deno run --watch --allow-net --allow-env --allow-read --allow-write mod.ts", - "start:proxy": "HTTPS_PROXY=http://127.0.0.1:8080 HTTP_PROXY=http://127.0.0.1:8080 deno run --allow-net --allow-env --allow-read mod.ts", - "dev:proxy": "HTTPS_PROXY=http://127.0.0.1:8080 HTTP_PROXY=http://127.0.0.1:8080 deno run --watch --allow-net --allow-env --allow-read mod.ts", + "start:proxy": "HTTPS_PROXY=http://127.0.0.1:8080 HTTP_PROXY=http://127.0.0.1:8080 deno run --allow-net --allow-env --allow-read --allow-write mod.ts", + "dev:proxy": "HTTPS_PROXY=http://127.0.0.1:8080 HTTP_PROXY=http://127.0.0.1:8080 deno run --watch --allow-net --allow-env --allow-read --allow-write mod.ts", - "start:proxy:auth": "HTTPS_PROXY=http://user:password@127.0.0.1:8080 HTTP_PROXY=http://user:password@127.0.0.1:8080 deno run --allow-net --allow-env --allow-read mod.ts" + "start:proxy:auth": "HTTPS_PROXY=http://user:password@127.0.0.1:8080 HTTP_PROXY=http://user:password@127.0.0.1:8080 deno run --allow-net --allow-env --allow-read --allow-write mod.ts" }, "imports": { "std/": "https://deno.land/std@0.208.0/" diff --git a/mod.ts b/mod.ts index a6e7864..7249a38 100644 --- a/mod.ts +++ b/mod.ts @@ -9,9 +9,10 @@ import { serve } from "https://deno.land/std@0.208.0/http/server.ts"; import { YTMusic, YouTubeSearch, LastFM, fetchFromPiped, fetchFromInvidious, getLyrics, getTrendingMusic, getRadio, getTopArtists, getTopTracks, getArtistInfo, getTrackInfo, getSongComplete, getAlbumComplete, getArtistComplete, getFullChain } from "./lib.ts"; import { html as uiHtml } from "./ui.ts"; -import { installProxyFetch } from "./proxy.ts"; +import { installProxyFetch, loadProxyConfig, saveProxyConfig, setRuntimeProxy, getActiveProxyUrl } from "./proxy.ts"; -// ── Proxy must be installed BEFORE any fetch calls ────────────────────────── +// ── Proxy: load saved config → install (patch globalThis.fetch) ───────────── +await loadProxyConfig(); installProxyFetch(); const PORT = parseInt(Deno.env.get("PORT") || "8000"); @@ -98,92 +99,83 @@ async function handler(req: Request): Promise { if (pathname === "/favicon.ico") return new Response(null, { status: 204 }); if (pathname === "/health") return json({ status: "ok" }); + // ============ PROXY SET / CLEAR ============ + + if (pathname === "/api/proxy/set" && req.method === "POST") { + try { + const body = await req.json(); + const newUrl: string | null = body.url || null; + if (newUrl) { + // Validate URL format + try { new URL(newUrl); } catch { return error("Invalid proxy URL format"); } + } + setRuntimeProxy(newUrl); + await saveProxyConfig(newUrl); + return json({ success: true, proxy_url: newUrl, message: newUrl ? "Proxy set and saved" : "Proxy cleared" }); + } catch { return error("Invalid JSON body"); } + } + + if (pathname === "/api/proxy/clear" && (req.method === "POST" || req.method === "DELETE")) { + setRuntimeProxy(null); + await saveProxyConfig(null); + return json({ success: true, message: "Proxy cleared" }); + } + // ============ PROXY STATUS ============ if (pathname === "/api/proxy/status") { - const httpsProxy = Deno.env.get("HTTPS_PROXY") || Deno.env.get("https_proxy"); - const httpProxy = Deno.env.get("HTTP_PROXY") || Deno.env.get("http_proxy"); - const proxyUrl = Deno.env.get("PROXY_URL"); - const proxyActive = !!(httpsProxy || httpProxy || proxyUrl); - - const mask = (u: string | undefined) => + const mask = (u: string | null | undefined) => u ? u.replace(/:\/\/[^@]*@/, "://@") : null; - // 1. Определяем текущий внешний IP (через прокси, если настроен) + const activeProxy = getActiveProxyUrl(); + const proxyActive = !!activeProxy; + + // 1. IP через прокси (или прямой, если прокси не настроен) let ip = "unknown"; let ipInfo: Record = {}; let latencyMs = -1; - try { const t0 = Date.now(); const ipRes = await fetch("https://ipinfo.io/json", { headers: { "User-Agent": "curl/7.88.0", "Accept": "application/json" }, - signal: AbortSignal.timeout(8000), }); latencyMs = Date.now() - t0; - if (ipRes.ok) { - ipInfo = await ipRes.json(); - ip = ipInfo.ip || "unknown"; - } - } catch { /* timeout or connection error */ } + if (ipRes.ok) { ipInfo = await ipRes.json(); ip = ipInfo.ip || "unknown"; } + } catch { /* failed */ } - // 2. Определяем IP без прокси (прямое соединение), чтобы сравнить - let directIp = "unknown"; - try { - // Временно вызываем нативный fetch напрямую, минуя патч - const nativeFetch: typeof fetch = (globalThis as any).__nativeFetch || fetch; - const res = await nativeFetch("https://api.ipify.org?format=json", { - signal: AbortSignal.timeout(5000), - }); - if (res.ok) { - const data = await res.json(); - directIp = data.ip || "unknown"; - } - } catch { /* ignore */ } + // 2. Прямой IP (нативный fetch, минуя прокси-патч) + let directIp = ip; // если прокси не настроен — они совпадут + if (proxyActive) { + try { + // Импортируем нативный fetch через тот же модуль + const { proxyFetch: _pf, ...rest } = await import("./proxy.ts"); + void rest; void _pf; + // Используем глобальный нативный fetch напрямую через eval-трюк + const nf = (globalThis as any)._nativeFetchRef; + if (nf) { + const r = await nf("https://api.ipify.org?format=json"); + if (r.ok) { const d = await r.json(); directIp = d.ip || ip; } + } + } catch { /* ignore */ } + } const proxyWorking = proxyActive && ip !== "unknown" && ip !== directIp; return json({ proxy_enabled: proxyActive, proxy_working: proxyWorking, - proxy_url: mask((httpsProxy || httpProxy || proxyUrl) || undefined), + proxy_url: mask(activeProxy), current_ip: ip, direct_ip: directIp, - ip_masked: directIp !== "unknown" && ip !== directIp, + ip_masked: ip !== directIp, latency_ms: latencyMs, - location: ipInfo.city - ? `${ipInfo.city}, ${ipInfo.region}, ${ipInfo.country}` - : (ipInfo.country || null), + location: ipInfo.city ? `${ipInfo.city}, ${ipInfo.region}, ${ipInfo.country}` : (ipInfo.country || null), org: ipInfo.org || null, timezone: ipInfo.timezone || null, - status: proxyActive - ? (proxyWorking ? "proxy_ok" : (ip === "unknown" ? "proxy_error" : "proxy_transparent")) - : "no_proxy", - }); - } - - // ============ PROXY CONFIG ============ - - if (pathname === "/api/proxy/config") { - const httpsProxy = Deno.env.get("HTTPS_PROXY") || Deno.env.get("https_proxy"); - const httpProxy = Deno.env.get("HTTP_PROXY") || Deno.env.get("http_proxy"); - const proxyUrl = Deno.env.get("PROXY_URL"); - const noProxy = Deno.env.get("NO_PROXY") || Deno.env.get("no_proxy"); - const active = !!(httpsProxy || httpProxy || proxyUrl); - - // Mask credentials in proxy URL for display - const mask = (u: string | undefined) => - u ? u.replace(/:\/\/[^@]*@/, "://@") : null; - - return json({ - proxy_enabled: active, - https_proxy: mask(httpsProxy || undefined), - http_proxy: mask(httpProxy || undefined), - proxy_url: mask(proxyUrl || undefined), - no_proxy: noProxy || null, - note: active - ? "All outbound requests are routed through the proxy" - : "No proxy configured — direct connections are used", + status: !proxyActive ? "no_proxy" + : ip === "unknown" ? "proxy_error" + : proxyWorking ? "proxy_ok" + : "proxy_transparent", }); } diff --git a/proxy.ts b/proxy.ts index 15cab24..d0221ea 100644 --- a/proxy.ts +++ b/proxy.ts @@ -3,37 +3,47 @@ /** * HTTP/HTTPS Proxy support for Deno fetch * - * Reads proxy settings from environment variables: - * HTTPS_PROXY — proxy for HTTPS requests (e.g. http://127.0.0.1:8080) - * HTTP_PROXY — proxy for HTTP requests (e.g. http://127.0.0.1:8080) - * PROXY_URL — fallback universal proxy (e.g. http://user:pass@host:port) - * NO_PROXY — comma-separated hosts to skip (e.g. localhost,127.0.0.1) + * Reads proxy settings from: + * 1. Runtime config set via setRuntimeProxy() / /api/proxy/set + * 2. Environment variables: HTTPS_PROXY, HTTP_PROXY, PROXY_URL * - * Usage: - * import { proxyFetch } from "./proxy.ts"; - * const res = await proxyFetch("https://music.youtube.com/...", { ... }); + * NO_PROXY — comma-separated hosts to bypass (e.g. localhost,127.0.0.1) * * Run with: - * HTTPS_PROXY=http://127.0.0.1:8080 deno run --allow-net --allow-env --allow-read mod.ts + * HTTPS_PROXY=http://user:pass@host:port deno run --allow-net --allow-env --allow-read mod.ts */ -// ─── Parse proxy URL ────────────────────────────────────────────────────────── +// ─── 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 isHttps = targetUrl.startsWith("https://"); - - const httpsProxy = Deno.env.get("HTTPS_PROXY") || Deno.env.get("https_proxy"); - const httpProxy = Deno.env.get("HTTP_PROXY") || Deno.env.get("http_proxy"); - const universalProxy = Deno.env.get("PROXY_URL"); - - const proxyUrl = isHttps - ? (httpsProxy || universalProxy || httpProxy || null) - : (httpProxy || universalProxy || 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 => s.trim()).filter(Boolean); + 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); @@ -59,19 +69,16 @@ async function connectViaProxy( const conn = await Deno.connect({ hostname: proxyHost, port: proxyPort }); - // Basic auth header (if credentials present in proxy URL) let authHeader = ""; if (proxy.username) { const creds = btoa(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`); authHeader = `Proxy-Authorization: Basic ${creds}\r\n`; } - // Send CONNECT request 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)); - // Read response const buf = new Uint8Array(4096); const n = await conn.read(buf); if (n === null) throw new Error("Proxy closed connection unexpectedly"); @@ -91,8 +98,8 @@ async function connectViaProxy( // ─── Proxy-aware fetch ──────────────────────────────────────────────────────── /** - * Drop-in replacement for global `fetch` that respects HTTP/HTTPS proxy - * environment variables. Falls back to native fetch when no proxy is configured. + * 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, @@ -101,16 +108,11 @@ export async function proxyFetch( const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const proxyUrl = getProxyUrl(url); - // No proxy configured — use native fetch directly + // No proxy — use the NATIVE fetch (not globalThis.fetch to avoid recursion!) if (!proxyUrl) { - return fetch(input, init); + return _nativeFetch(input, init); } - // For HTTPS targets we build a tunnel with HTTP CONNECT, then do the request - // over the tunnelled raw TCP connection using the Fetch API with the tunnel. - // Deno's built-in fetch also supports HTTPS_PROXY natively — but only when - // the flag --allow-env is present AND Deno is compiled with that support. - // To be safe, we also forward the env vars so Deno's own fetch can pick them up. try { const targetUrl = new URL(url); const isHttps = targetUrl.protocol === "https:"; @@ -118,18 +120,13 @@ export async function proxyFetch( const targetPort = parseInt(targetUrl.port || (isHttps ? "443" : "80")); if (isHttps) { - // Open a TCP tunnel through the proxy const tunnel = await connectViaProxy(proxyUrl, targetHost, targetPort); - - // Upgrade to TLS over the tunnel const tlsConn = await Deno.startTls(tunnel, { hostname: targetHost }); - // Build raw HTTP/1.1 request const method = (init?.method || "GET").toUpperCase(); const path = targetUrl.pathname + targetUrl.search; const headers = new Headers(init?.headers as HeadersInit | undefined); - // Ensure required headers 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"); @@ -152,7 +149,6 @@ export async function proxyFetch( const encoder = new TextEncoder(); await tlsConn.write(encoder.encode(rawRequest)); - // Read response const chunks: Uint8Array[] = []; const tmpBuf = new Uint8Array(16384); while (true) { @@ -187,7 +183,7 @@ export async function proxyFetch( return new Response(bodyBytes, { status: statusCode, statusText, headers: responseHeaders }); } - // HTTP (plain) — use proxy as a regular forward proxy + // HTTP plain — forward proxy const proxy = new URL(proxyUrl); const conn = await Deno.connect({ hostname: proxy.hostname, port: parseInt(proxy.port || "8080") }); @@ -207,7 +203,6 @@ export async function proxyFetch( body = bodyText; } - // Proxy-Authorization header for HTTP forward proxy if (proxy.username && !headers.has("Proxy-Authorization")) { const creds = btoa(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`); headers.set("Proxy-Authorization", `Basic ${creds}`); @@ -255,28 +250,55 @@ export async function proxyFetch( return new Response(bodyBytes, { status: statusCode, statusText, headers: responseHeaders }); } catch (err) { - // On any proxy error — fall back to direct fetch and log the issue - console.warn(`[proxy] Error using proxy for ${url}: ${err}. Falling back to direct fetch.`); - return fetch(input, init); + // Proxy error — fall back to native fetch + console.warn(`[proxy] Error via proxy for ${url}: ${err}. Falling back to direct.`); + return _nativeFetch(input, init); } } -// ─── Patch global fetch ─────────────────────────────────────────────────────── +// ─── Install ────────────────────────────────────────────────────────────────── /** * Call once at startup to replace globalThis.fetch with proxyFetch. - * After this, ALL fetch() calls in the process go through the proxy. + * The native fetch is already captured at module level, so no recursion issues. */ export function installProxyFetch(): void { - const proxyUrl = 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"); + // @ts-ignore — patching global fetch + globalThis.fetch = proxyFetch; - if (proxyUrl) { - console.log(`[proxy] HTTP proxy enabled: ${proxyUrl.replace(/:\/\/[^@]*@/, "://@")}`); - // @ts-ignore — replacing 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); + } +} diff --git a/ui.ts b/ui.ts index 72be0a0..455c9de 100644 --- a/ui.ts +++ b/ui.ts @@ -97,6 +97,20 @@ export const html = ` .proxy-tag.blue{color:#60a5fa;background:rgba(96,165,250,.1)} .proxy-refresh{background:none;border:none;color:var(--dim);cursor:pointer;padding:4px;font-size:.9rem;transition:color .2s;flex-shrink:0} .proxy-refresh:hover{color:var(--text)} + .proxy-settings{display:none;margin-top:10px;padding-top:10px;border-top:1px solid var(--border);width:100%} + .proxy-settings.open{display:block} + .proxy-form{display:flex;gap:8px;flex-wrap:wrap} + .proxy-input{flex:1;min-width:200px;background:var(--surface2);border:1px solid var(--border);padding:8px 12px;border-radius:8px;color:var(--text);font-size:.8rem;font-family:monospace} + .proxy-input:focus{outline:none;border-color:var(--accent)} + .proxy-input::placeholder{color:var(--dim)} + .proxy-save{background:var(--accent);color:#000;border:none;padding:8px 16px;border-radius:8px;font-size:.8rem;font-weight:600;cursor:pointer;font-family:inherit;transition:opacity .15s} + .proxy-save:hover{opacity:.85} + .proxy-save:disabled{opacity:.4} + .proxy-clear-btn{background:rgba(239,68,68,.15);color:#ef4444;border:1px solid rgba(239,68,68,.3);padding:8px 14px;border-radius:8px;font-size:.8rem;cursor:pointer;font-family:inherit;transition:all .15s} + .proxy-clear-btn:hover{background:rgba(239,68,68,.25)} + .proxy-msg{font-size:.75rem;margin-top:6px;padding:4px 8px;border-radius:4px;display:none} + .proxy-msg.ok{display:block;color:#10b981;background:rgba(16,185,129,.1)} + .proxy-msg.err{display:block;color:#ef4444;background:rgba(239,68,68,.1)} @media(max-width:600px){.container{padding:40px 16px 180px}.logo{width:80px;height:80px}.title{font-size:2rem}.desc{display:none}.search-row{flex-direction:column}.proxy-widget{flex-wrap:wrap}} @@ -109,14 +123,26 @@ export const html = `

Music API for YouTube Music, Lyrics & Streaming

-
+
Checking proxy...
+ + + +
+
Proxy URL (http://user:pass@host:port)
+
+ + + +
+
+
@@ -494,6 +520,66 @@ async function testApi(){ } updateInputs(); +// ── Proxy Settings Form ──────────────────────────────────────────────────── +function toggleProxySettings(){ + var s=document.getElementById('proxySettings'); + s.classList.toggle('open'); +} + +function showProxyMsg(text,isOk){ + var m=document.getElementById('proxyMsg'); + m.textContent=text; + m.className='proxy-msg '+(isOk?'ok':'err'); + clearTimeout(m._t); + m._t=setTimeout(()=>m.className='proxy-msg',4000); +} + +async function saveProxy(){ + var input=document.getElementById('proxyUrlInput'); + var btn=document.getElementById('proxySaveBtn'); + var url=input.value.trim(); + if(!url){showProxyMsg('Enter proxy URL first','');return;} + + btn.disabled=true; + btn.textContent='Testing...'; + + try{ + var res=await fetch('/api/proxy/set',{ + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({url:url}) + }); + var d=await res.json(); + if(d.success){ + showProxyMsg('✓ Saved. Checking status...',true); + // Collapse form and refresh status + setTimeout(()=>{ + document.getElementById('proxySettings').classList.remove('open'); + loadProxyStatus(); + },600); + }else{ + showProxyMsg('Error: '+(d.error||'Unknown'),false); + } + }catch(e){ + showProxyMsg('Request failed: '+String(e),false); + }finally{ + btn.disabled=false; + btn.textContent='Test & Save'; + } +} + +async function clearProxy(){ + try{ + await fetch('/api/proxy/clear',{method:'POST'}); + document.getElementById('proxyUrlInput').value=''; + document.getElementById('proxySettings').classList.remove('open'); + showProxyMsg('Proxy cleared',true); + setTimeout(loadProxyStatus,400); + }catch(e){ + showProxyMsg('Failed: '+String(e),false); + } +} + // ── Proxy Status Widget ──────────────────────────────────────────────────── async function loadProxyStatus(){ var w=document.getElementById('proxyWidget');