#!/usr/bin/env python3 """Ретранслятор Bot API продукта через инфраструктурный хост Beget (#3471). ЗАЧЕМ. Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` из контейнера `tradein-tgbot` на Selectel — 9 успешных из 12, три `ConnectTimeout`. TCP на 443 до адреса, резолвящегося на Selectel (149.154.167.220) — 5 из 6. Тот же TCP до адреса, резолвящегося на Beget (149.154.166.110) — 8 из 8. За сутки в логе бота 508 строк `network error`, за 30 дней 92 обрыва итерации poll loop. Путь до Telegram с Selectel лоссовый, с Beget чистый: Alertmanager (живёт на Beget) пишет в тот же чат без проблем, а бот поддержки на Selectel часть отправок теряет. Решение — не чинить сеть Selectel (вне контроля), а дать продуктовым сервисам обходной путь через хост, с которого Telegram отвечает надёжно. ЧТО ПРОКСИРУЕТСЯ. Метод Bot API целиком — путь `/bot/`, тело, query. Не только отправка: `getUpdates` (long-poll) страдает от потерь ровно так же, как `sendMessage`, и это тот же HTTP-путь через тот же испорченный транзит. ТОКЕН НЕ ЛОГИРУЕТСЯ. Он приходит в пути запроса. `log_request` переопределён ПОЛНОСТЬЮ (не вызывает `super()`): дефолт stdlib кладёт в лог `requestline` целиком, включая токен. Здесь путь редактируется до записи в лог. АУТЕНТИФИКАЦИЯ. Общий секрет в заголовке `X-Relay-Secret`, по образцу общего секрета `X-Internal-Auth-Secret` в этом же стеке (`app/core/config.py`, `tradein_internal_auth_secret`) — сравнение строкой (не сравнение таймингов: секрет не является паролем пользователя, ценность атаки по времени здесь исчезающе мала при секрете длиной от 32 байт, а stdlib `hmac` — лишняя зависимость ради stdlib-only сервиса). Домен публичный, без секрета — отказ, а не тихий приём. БЕЗ ЗАВИСИМОСТЕЙ. Только стандартная библиотека — тот же принцип, что у `ops/metrics/alert-ack/app.py`: сервис обязан подниматься, даже когда всё остальное сломано, и не тащить установку пакетов. ОТКАЗ РЕТРАНСЛЯТОРА НЕ ДОЛЖЕН РОНЯТЬ БОТА. Это реализовано НЕ здесь, а на стороне клиента (`tradein-mvp/backend/app/services/tgbot/client.py`): при транспортном отказе похода на ретранслятор клиент делает одну попытку напрямую к `api.telegram.org`. Здесь достаточно не быть точкой отказа хуже прямого пути: таймаут до апстрима подобран так, чтобы не обрубать long-poll `getUpdates` раньше, чем это сделал бы сам Telegram. Переменные окружения: TG_RELAY_SECRET обязательна — общий секрет, сверяется с заголовком X-Relay-Secret TG_RELAY_PORT порт (по умолчанию 8080) TG_RELAY_UPSTREAM_TIMEOUT_S таймаут запроса к api.telegram.org в секундах (по умолчанию 75 — с запасом над самым долгим long-poll getUpdates, который шлёт клиент: timeout=30 + 10с запаса на стороне httpx = 40с) """ from __future__ import annotations import logging import os import urllib.error import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("tg-relay") RELAY_SECRET = os.environ.get("TG_RELAY_SECRET", "") UPSTREAM = "https://api.telegram.org" UPSTREAM_TIMEOUT_S = float(os.environ.get("TG_RELAY_UPSTREAM_TIMEOUT_S", "75")) SECRET_HEADER = "X-Relay-Secret" def redact_path(path: str) -> str: """Прячет токен из `/bot/method[?query]` для логов и ошибок. Вынесена в модульную функцию (не метод), чтобы быть проверяемой напрямую без поднятия HTTP-сервера. """ if not path.startswith("/bot"): return path rest = path[len("/bot") :] _token_part, sep, tail = rest.partition("/") if not sep: # Ни `/method`, ни query — токен без хвоста (или токен+query без slash). return "/bot" tail = tail.split("?", 1)[0] return f"/bot/{tail}" if tail else "/bot" class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, fmt: str, *args) -> None: # noqa: A003 — сигнатура из stdlib log.info("%s", fmt % args) def log_request(self, code="-", size="-") -> None: # noqa: A003 — сигнатура из stdlib # ПОЛНОСТЬЮ заменяет реализацию BaseHTTPRequestHandler (не вызывает # super()): та кладёт в лог self.requestline целиком, а он для Bot API # содержит токен в пути. log.info('%s "%s %s" %s', self.address_string(), self.command, redact_path(self.path), code) def _reply(self, code: int, body: bytes, ctype: str = "application/json") -> None: self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.end_headers() if body: self.wfile.write(body) def _authorized(self) -> bool: return bool(RELAY_SECRET) and self.headers.get(SECRET_HEADER) == RELAY_SECRET def _proxy(self) -> None: if self.path == "/healthz": self._reply(200, b"ok", "text/plain; charset=utf-8") return if not self._authorized(): self._reply(401, b'{"ok":false,"description":"unauthorized"}') return if not self.path.startswith("/bot"): self._reply(404, b'{"ok":false,"description":"not found"}') return length = int(self.headers.get("Content-Length") or 0) body = self.rfile.read(length) if length else None req = urllib.request.Request( UPSTREAM + self.path, data=body, method=self.command, headers={"Content-Type": self.headers.get("Content-Type") or "application/json"}, ) try: # Таймаут ЯВНО шире любого long-poll getUpdates клиента — иначе # ретранслятор обрубит соединение раньше площадки и превратит # штатный long-poll в вечный network error, то есть станет хуже # прямого пути, а не лучше. with urllib.request.urlopen(req, timeout=UPSTREAM_TIMEOUT_S) as resp: out = resp.read() self._reply(resp.status, out, resp.headers.get("Content-Type") or "application/json") except urllib.error.HTTPError as exc: # Telegram ответил ошибкой (4xx/5xx) — это НЕ отказ ретранслятора, # передаём как есть, клиент сам решает, ретраить или нет. out = exc.read() ctype = exc.headers.get("Content-Type") if exc.headers else None self._reply(exc.code, out, ctype or "application/json") except Exception as exc: # noqa: BLE001 — любой отказ апстрима не должен уронить сервис log.warning("upstream недоступен (%s): %s", redact_path(self.path), type(exc).__name__) self._reply(502, b'{"ok":false,"description":"relay upstream unreachable"}') def do_GET(self) -> None: # noqa: N802 — имя из stdlib self._proxy() def do_POST(self) -> None: # noqa: N802 — имя из stdlib self._proxy() def main() -> None: if not RELAY_SECRET: raise SystemExit("не задан TG_RELAY_SECRET — домен публичный, отказ на старте") port = int(os.environ.get("TG_RELAY_PORT", "8080")) log.info("tg-relay слушает :%d, upstream_timeout=%.0fs", port, UPSTREAM_TIMEOUT_S) ThreadingHTTPServer(("", port), Handler).serve_forever() if __name__ == "__main__": main()