/** * proxy.ts — единая точка выхода для запросов, которые сервер бота (HF Spaces) * не может сделать напрямую, потому что датацентровые IP HF Spaces блокируются * некоторыми сервисами на уровне сети (см. README проекта Lumen): * - Telegram Bot API (полностью заблокирован на уровне TLS-handshake — без * этого прокси бот вообще не может ходить в Telegram). * - TikWM (стабильно отвечает HTTP 403 с пустым телом на исходящие запросы * с IP HF Spaces — см. историю отладки, /logs с тегом [tikwm][diag]). * * ЗАМЕНЯЕТ собой прежний tg-proxy (умел проксировать только Telegram Bot API * по формату /bot/) — переименован и обобщён. Лимиты Deno * Deploy (free-тариф: 1 млн запросов/мес, 20 ГБ исходящего трафика/мес, 15ч * CPU/мес) общие на ВЕСЬ АККАУНТ, а не на отдельный проект — значит держать * два раздельных Deno-приложения (одно под Telegram, другое под TikWM) не * даёт вообще никакой отдельной квоты, только лишняя сущность для поддержки. * Один универсальный прокси проще: один домен, один секрет, один деплой. * * ── Формат ── * GET/POST/... https://<домен>/fetch//<путь...>? * -> https:///<путь...>? * Метод, заголовки и тело запроса передаются как есть; статус, заголовки и * тело ответа — тоже как есть (без буферизации целиком в память — тело * стримится напрямую, это важно для больших file-загрузок в Telegram, см. * sendVideo/sendPhoto/sendMediaGroup у бота). * * ── Почему нужен allowlist хостов ── * Без ограничения на разрешённые хосты это был бы открытый анонимный релей на * ЛЮБОЙ адрес в интернете — кто угодно, узнав домен, мог бы использовать его * для проксирования куда захочет, тратя общую квоту трафика аккаунта (те же * 20 ГБ/мес) и потенциально привлекая к аккаунту внимание как к источнику * абьюза/скана. ALLOWED_HOSTS ниже — единственные хосты, которые реально * нужны боту; расширять список нужно только по факту новой подтверждённой * необходимости (см. тот же принцип "не добавляй заранее" в остальном проекте). * * ── Как настроить бота на использование этого прокси ── * В HF Spaces secrets/variables: * TELEGRAM_API_BASE_URL = https://<домен>/fetch/api.telegram.org * TIKWM_API_BASE_URL = https://<домен>/fetch/www.tikwm.com * bot.py дальше сам достраивает нужные пути (/bot/, * /file/bot/, /api/?url=...) поверх этой базы — никаких других * изменений в Python-коде для смены адреса прокси не требуется. */ export const PROXY_AUTH_HEADER = "X-Lumen-Proxy-Secret"; export const ALLOWED_HOSTS = new Set([ "api.telegram.org", "www.tikwm.com", "tikwm.com", ]); // Кап объявленного тела запроса (аудит 26.09.2026). Telegram сам режет загрузки // на 50 МБ, а прокси — общий трафик аккаунта, поэтому граница взята с запасом // под честно большие файлы, но не «без предела». export const MAX_REQUEST_BODY_BYTES = 100 * 1024 * 1024; // Короткий секрет перебирается быстро: нижняя граница длины с обеих сторон // (здесь и в lumen_telegram_transport.py). export const MIN_PROXY_SECRET_LENGTH = 32; export class BodyTooLargeError extends Error {} export function limitStreamBytes( stream: ReadableStream | null, maxBytes: number, ): ReadableStream | null { // Потоковый кап без буферизации: считаем байты на лету и рвём стрим при // превышении. Нужен и для тела запроса без Content-Length, и для тела ответа: // иначе объявленный кап обходился chunked-потоком любого размера. if (!stream) return stream; let total = 0; return stream.pipeThrough(new TransformStream({ transform(chunk, controller) { total += chunk.byteLength; if (total > maxBytes) { controller.error(new BodyTooLargeError(`Body exceeds ${maxBytes} byte cap`)); } else { controller.enqueue(chunk); } }, })); } // Апстрим без таймаута держал бы запрос вечно, жря квоту Deno: висящий апстрим — 504. export const UPSTREAM_FETCH_TIMEOUT_MS = 60_000; // Заголовки, которые нельзя слепо пробрасывать дальше как есть — Host/Connection // в запросе относятся к соединению с ЭТИМ (Deno) сервером, а не с реальным // апстримом, апстрим сам выставит правильные. Content-Encoding/Content-Length в // ОТВЕТЕ — fetch() в Deno уже сам распаковывает gzip/br к моменту, когда тело // становится нам доступно, поэтому исходный Content-Encoding больше не описывает // реальное тело — клиент, попытавшийся распаковать уже распакованное, получил бы // битые данные. Content-Length по той же причине может не совпадать с реальным // размером — рантайм сам выставит корректный Transfer-Encoding для стрима. const HOP_BY_HOP_REQUEST_HEADERS = [ "host", "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "proxy-connection", "te", "trailer", "transfer-encoding", "upgrade", ]; const HOP_BY_HOP_RESPONSE_HEADERS = [ "content-encoding", "content-length", "connection", "transfer-encoding", "keep-alive", "trailer", "upgrade", ]; // Разложено на чистые тестируемые функции вместо одного большого обработчика: // маршрутизацию и заголовки проверяют юниты без сети, сетевую часть — // handleRequest через подмену fetch. export type TargetResolution = | { ok: true; url: string } | { ok: false; status: 404 | 403; message: string }; export function resolveTarget(pathname: string, search: string): TargetResolution { // pathname всегда начинается с "/", поэтому после split("/") первый элемент — // всегда пустая строка, а реальные сегменты — начиная с индекса 1. // Пустые сегменты намеренно не фильтруем: filter(Boolean) съедал завершающий "/" // у путей вида "/fetch/host/api/", и запрос TikWM уходил без слеша (дорогая // отладка в истории проекта). const parts = pathname.split("/"); if (parts.length < 3 || parts[1] !== "fetch" || parts[2] === "") { return { ok: false, status: 404, message: "Not found — ожидаемый формат пути: /fetch//<путь>" }; } const host = parts[2]; if (!ALLOWED_HOSTS.has(host)) { return { ok: false, status: 403, message: "Host not allowed" }; } const path = "/" + parts.slice(3).join("/"); if (host === "api.telegram.org" && !isTelegramBotPath(path)) { return { ok: false, status: 404, message: "Not found — путь Telegram обязан иметь форму /bot/<метод> или /file/bot/<путь>" }; } return { ok: true, url: `https://${host}${path}${search}` }; } // Telegram Bot API без токена в пути не вызывается: без формы исчезает целый // класс чужеродного использования (релей произвольных путей при наличии секрета). function isTelegramBotPath(path: string): boolean { return /^\/bot[^/]+\/.+/.test(path) || /^\/file\/bot[^/]+\/.+/.test(path); } export function buildForwardHeaders(reqHeaders: Headers): Headers { const headers = new Headers(reqHeaders); for (const name of HOP_BY_HOP_REQUEST_HEADERS) headers.delete(name); // Тело заменено capped-стримом, исходный length врёт (рантайм выставит сам). headers.delete("content-length"); headers.delete(PROXY_AUTH_HEADER); return headers; } export function buildResponseHeaders(upstreamHeaders: Headers): Headers { const headers = new Headers(upstreamHeaders); for (const name of HOP_BY_HOP_RESPONSE_HEADERS) headers.delete(name); return headers; } // fetchImpl — точка подмены для тестов: реальная сеть юнитам не нужна. export async function handleRequest( req: Request, fetchImpl: typeof fetch = fetch, proxySecret: string | undefined = undefined, maxBodyBytes: number = MAX_REQUEST_BODY_BYTES, ): Promise { if ( !proxySecret || proxySecret.length < MIN_PROXY_SECRET_LENGTH || !/^[\x21-\x7e]+$/.test(proxySecret) ) { return new Response("Proxy authentication unavailable", { status: 503 }); } const suppliedSecret = req.headers.get(PROXY_AUTH_HEADER) ?? ""; const encoder = new TextEncoder(); const [expected, supplied] = await Promise.all([ crypto.subtle.digest("SHA-256", encoder.encode(proxySecret)), crypto.subtle.digest("SHA-256", encoder.encode(suppliedSecret)), ]); const expectedBytes = new Uint8Array(expected); const suppliedBytes = new Uint8Array(supplied); let difference = 0; for (let i = 0; i < expectedBytes.length; i++) { difference |= expectedBytes[i] ^ suppliedBytes[i]; } if (!suppliedSecret || difference !== 0) { return new Response("Unauthorized", { status: 401 }); } const url = new URL(req.url); const target = resolveTarget(url.pathname, url.search); if (!target.ok) { return new Response(target.message, { status: target.status }); } // Кап тела запроса по объявленному content-length: без него любой, у кого есть // секрет, лил в прокси поток любого размера и съедал общий лимит Deno-аккаунта // (ровно то, от чего прокси и защищает — аудит 26.09.2026). Потоковое тело без // заголовка тоже капаем на лету, а не пропускаем: иначе кап обходился chunked. const declaredLength = Number(req.headers.get("content-length") ?? "0"); if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) { return new Response("Request body too large", { status: 413 }); } const forwardHeaders = buildForwardHeaders(req.headers); const hasBody = req.method !== "GET" && req.method !== "HEAD"; const cappedRequestBody = hasBody ? limitStreamBytes(req.body, maxBodyBytes) : undefined; let upstreamResp: Response; try { upstreamResp = await fetchImpl(target.url, { method: req.method, headers: forwardHeaders, // redirect:"error" осознанно: проброс 3xx Location наружу вывел бы клиент // из allowlist, а переезд хоста у TikWM даст честный 502 ниже. redirect: "error", // GET/HEAD не могут иметь тело запроса (fetch бросит исключение, если // передать body для них) — для остальных методов пробрасываем тело // напрямую как поток, не буферизуя целиком в памяти (важно для // multipart file-загрузок в Telegram, см. докстринг выше). body: cappedRequestBody, signal: AbortSignal.timeout(UPSTREAM_FETCH_TIMEOUT_MS), // @ts-ignore — Deno требует duplex:"half" для потокового тела запроса // (часть стандарта WHATWG fetch для body типа ReadableStream). duplex: "half", }); } catch (err) { if (err instanceof BodyTooLargeError) { return new Response("Request body too large", { status: 413 }); } if (err instanceof DOMException && err.name === "TimeoutError") { return new Response("Upstream fetch timed out", { status: 504 }); } return new Response("Upstream fetch failed", { status: 502 }); } const respHeaders = buildResponseHeaders(upstreamResp.headers); return new Response(limitStreamBytes(upstreamResp.body, maxBodyBytes), { status: upstreamResp.status, headers: respHeaders, }); } // Реальный сервер стартует только при прямом запуске файла (deno run/deploy), // не при импорте из proxy_test.ts — иначе тесты пытались бы забиндить порт. if (import.meta.main) { const proxySecret = Deno.env.get("LUMEN_PROXY_SECRET"); Deno.serve((req) => handleRequest(req, fetch, proxySecret)); }