Spaces:
Running
Running
Download proxy/proxy.ts from SilverElixir/Lumen: direct link, hf CLI and curl.
- Browser
- Download file 14.6 kB
-
https://huggingface.co/spaces/SilverElixir/Lumen/resolve/main/proxy/proxy.ts
- Command line
-
hf download hf://spaces/SilverElixir/Lumen/proxy/proxy.ts
-
curl -L -o proxy.ts https://huggingface.co/spaces/SilverElixir/Lumen/resolve/main/proxy/proxy.ts
14.6 kB
| /** | |
| * 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<token>/<method>) — переименован и обобщён. Лимиты Deno | |
| * Deploy (free-тариф: 1 млн запросов/мес, 20 ГБ исходящего трафика/мес, 15ч | |
| * CPU/мес) общие на ВЕСЬ АККАУНТ, а не на отдельный проект — значит держать | |
| * два раздельных Deno-приложения (одно под Telegram, другое под TikWM) не | |
| * даёт вообще никакой отдельной квоты, только лишняя сущность для поддержки. | |
| * Один универсальный прокси проще: один домен, один секрет, один деплой. | |
| * | |
| * ── Формат ── | |
| * GET/POST/... https://<домен>/fetch/<host>/<путь...>?<query> | |
| * -> https://<host>/<путь...>?<query> | |
| * Метод, заголовки и тело запроса передаются как есть; статус, заголовки и | |
| * тело ответа — тоже как есть (без буферизации целиком в память — тело | |
| * стримится напрямую, это важно для больших 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<token>/<method>, | |
| * /file/bot<token>/<path>, /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<Uint8Array> | null, | |
| maxBytes: number, | |
| ): ReadableStream<Uint8Array> | null { | |
| // Потоковый кап без буферизации: считаем байты на лету и рвём стрим при | |
| // превышении. Нужен и для тела запроса без Content-Length, и для тела ответа: | |
| // иначе объявленный кап обходился chunked-потоком любого размера. | |
| if (!stream) return stream; | |
| let total = 0; | |
| return stream.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({ | |
| 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/<host>/<путь>" }; | |
| } | |
| 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<token>/<метод> или /file/bot<token>/<путь>" }; | |
| } | |
| 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<Response> { | |
| 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)); | |
| } | |