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));
}