Замер 12.09.2026, оба хоста в одни и те же минуты: getMe из tradein-tgbot на Selectel — 9 успешных из 12, три ConnectTimeout; TCP-443 до адреса, резолвящегося на Selectel (149.154.167.220) — 5 из 6; TCP-443 до адреса, резолвящегося на Beget (149.154.166.110) — 8 из 8. За сутки в логе бота 508 строк network error, за 30 дней 92 обрыва итерации poll loop. Значит: путь до Telegram с Selectel лоссовый, с Beget чистый — Alertmanager (живёт на Beget) шлёт в тот же чат без проблем, а бот поддержки на Selectel часть отправок теряет. Добавлен ops/metrics/tg-relay — stdlib-only HTTP-сервис (тот же принцип, что у alert-ack: без зависимостей, поднимается даже когда всё остальное сломано), проксирует Bot API целиком (метод, путь, тело — sendMessage, copyMessage, getUpdates) на api.telegram.org. Токен из пути не логируется: log_request переопределён полностью, путь редактируется до записи в лог. Аутентификация — общий секрет в X-Relay-Secret, по образцу X-Internal-Auth-Secret из этого же стека. Клиент (tgbot/client.py) при транспортном отказе похода на ретранслятор делает одну попытку напрямую к api.telegram.org — хуже прямого пути быть не должно ни при каких условиях. Пустой TELEGRAM_RELAY_BASE_URL — прежнее поведение без изменений, это и есть механизм отката. Refs #3471
162 lines
9.3 KiB
Python
162 lines
9.3 KiB
Python
#!/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<TOKEN>/<method>`, тело,
|
||
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<TOKEN>/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<REDACTED>"
|
||
tail = tail.split("?", 1)[0]
|
||
return f"/bot<REDACTED>/{tail}" if tail else "/bot<REDACTED>"
|
||
|
||
|
||
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()
|