Lumen / proxy /proxy.ts
SilverElixir
Validate Telegram path form and require long proxy secret
b33ab22
Raw History Blame Contribute Delete
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));
}