Spaces:
Running
Running
File size: 14,559 Bytes
b059157 bd3cb33 b059157 1c833d2 5f80ef1 1c833d2 b33ab22 6d075c6 5bd5d41 6d075c6 5bd5d41 6d075c6 b059157 6d075c6 b059157 5f80ef1 b059157 6d075c6 b059157 6d075c6 5f80ef1 b059157 b33ab22 6d075c6 b059157 b33ab22 b059157 6d075c6 bd3cb33 b059157 5f80ef1 bd3cb33 5bd5d41 bd3cb33 b33ab22 bd3cb33 b059157 1c833d2 5bd5d41 1c833d2 5bd5d41 1c833d2 b059157 5bd5d41 b059157 6d075c6 bd3cb33 b059157 5bd5d41 6d075c6 b059157 6d075c6 bd3cb33 b059157 5bd5d41 b059157 bd3cb33 b059157 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 | /**
* 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));
}
|