gendesign/tradein-mvp/backend/app/api/v1/glitchtip.py
bot-backend ed94a03f73
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 14s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m9s
fix(tradein): не логировать секрет вебхука GlitchTip в access-log (#3154)
uvicorn печатает в access-log полный путь вместе с query, поэтому секрет
вебхука (`?secret=`, он же TRADEIN_INTERNAL_AUTH_SECRET, второй рубеж rbac)
уезжал в Loki открытым текстом. Скруббер #3115 в Alloy ловит только форму
`user:pass@host` (DSN postgres_exporter) и такую строку не закрывает.

Два слоя:
- хендлер принимает секрет из заголовка X-GlitchTip-Secret; query-параметр
  остаётся fallback'ом, т.к. сам GlitchTip 6.1.6 (`send_webhook()`)
  заголовков не шлёт вовсе — убрать query можно только когда заголовок начнёт
  подставлять кто-то перед нами (Caddy header_up) или сменится отправитель;
- app/core/log_scrub.py: logging-фильтр маскирует значения чувствительных
  query-параметров (secret/token/api_key/…) на uvicorn.access и на
  обработчиках корневого логгера — секрета нет уже в `docker logs`.

Сравнение секрета и было constant-time (`secrets.compare_digest`).
2026-09-05 22:51:57 +05:00

231 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""GlitchTip → Telegram алерты (мониторинг сейчас нем: `alerts_projectalert`/
`alerts_alertrecipient` пусты, `EMAIL_URL=consolemail://` печатает письма в
stdout и никуда их не доставляет — аудит на проде 2026-08-15).
GlitchTip (self-hosted, `errors.gendsgn.ru`, образ `glitchtip/glitchtip:6.1.6`)
умеет слать получателю типа `webhook` (``RecipientType.GENERAL_WEBHOOK`` —
"General Slack-compatible webhook"). И issue-алерты (``apps/alerts/webhooks.py
send_issue_as_webhook``), и uptime-алерты (``apps/uptime/webhooks.py
_send_uptime_generic``) в итоге идут через ОДНУ И ТУ ЖЕ низкоуровневую
``send_webhook()`` — ``aiohttp.ClientSession.post(url, json=asdict(WebhookPayload
(text=..., attachments=[...])))``, БЕЗ каких-либо заголовков (ни Authorization,
ни подписи, ни X-*). Значит:
1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ —
``{"text": str, "attachments": [{"title","title_link","text","color",
"fields",...}]}`` — просто у uptime пустые/отсутствующие ``fields``/``color``;
2) единственный канал для аутентификации от САМОГО GlitchTip — сам URL (как и
у Slack-вебхуков): заголовок GlitchTip-сторона не добавляет. Поэтому
хендлер принимает секрет и из заголовка ``X-GlitchTip-Secret``
(предпочтительно — не течёт в access-log, #3154), и из query-параметра
``?secret=`` как fallback для текущего отправителя.
Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213
defense-in-depth, см. ``app.core.rbac``) вместо нового секрета — тот же
``secrets.compare_digest`` constant-time compare, тот же env. Отличие от
rbac-паттерна: ТАМ пустой секрет — fail-open (есть второй рубеж, roles.yaml).
ЗДЕСЬ секрет — единственный рубеж вообще, поэтому пустой секрет ИЛИ
несконфигурированный Telegram-бот → 503 "не настроено", а не тихий
fail-open настежь.
Путь ФИКСИРОВАННЫЙ (не несёт секрет в себе) — так его можно добавить в
``app.core.rbac._PUBLIC_PATHS`` одной строкой (точное совпадение, без
regex/prefix-веток в ``rbac_guard``). Сам путь — не секрет, секрет — только
значение query-параметра.
Сетевая связность (docker-compose.prod.yml, корневой стек): вебхуки шлёт
``glitchtip-worker`` (celery-таска), НЕ ``glitchtip-web`` — оба сейчас сидят
только в ``gendesign_default``. tradein-backend слушает на ``gendesign_shared``
(алиас неявный — Docker embedded DNS резолвит по ``container_name``, тот же
приём уже используется Caddy → ``tradein-backend:8000``, см. Caddyfile).
Значит ``glitchtip-worker`` тоже должен быть подписан на ``gendesign_shared``,
иначе имя ``tradein-backend`` не резолвится — общей сети нет.
"""
from __future__ import annotations
import json
import logging
import secrets
from datetime import UTC, datetime
from typing import Annotated, Any
from fastapi import APIRouter, Header, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, ValidationError
from app.core.config import settings
from app.services.tgbot.client import TelegramApiError, TelegramClient
logger = logging.getLogger(__name__)
router = APIRouter()
# Telegram sendMessage лимит — 4096 символов (см. support.py MAX_MESSAGE_LENGTH
# для исходящих сообщений пользователя; здесь лимит на ИСХОДЯЩЕЕ в Telegram, тот
# же потолок). Суффикс обрезки учтён в _truncate.
_TELEGRAM_MAX_LEN = 4096
_TRUNCATE_SUFFIX = "\n… (обрезано)"
# Узкий интерактивный бюджет (тот же принцип, что #tgsupport-web review H1 в
# support.py): GlitchTip-таска ждёт HTTP-ответ синхронно (её собственный aiohttp
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
_INTERACTIVE_SEND_MAX_RETRIES = 1
class GlitchTipAttachment(BaseModel):
"""Slack-совместимый attachment. Issue- и uptime-алерты заполняют РАЗНЫЕ
подмножества полей (uptime не шлёт ``fields``/``color``) — все опциональны,
``extra="allow"`` на случай будущих версий GlitchTip."""
model_config = ConfigDict(extra="allow")
title: str | None = None
title_link: str | None = None
text: str | None = None
color: str | None = None
fields: list[dict[str, Any]] | None = None
class GlitchTipWebhookPayload(BaseModel):
"""Тело POST от GlitchTip ``send_webhook()`` — одинаковое для issue- и
uptime-алертов (см. docstring модуля)."""
model_config = ConfigDict(extra="allow")
text: str | None = None
attachments: list[GlitchTipAttachment] | None = None
def _truncate(text: str, limit: int = _TELEGRAM_MAX_LEN) -> str:
if len(text) <= limit:
return text
return text[: limit - len(_TRUNCATE_SUFFIX)] + _TRUNCATE_SUFFIX
def _field_value(attachment: GlitchTipAttachment, label: str) -> str | None:
"""Ищет значение поля attachment.fields по title (issue-алерты кладут туда
"Project" литералом — см. apps/alerts/webhooks.py send_issue_as_webhook)."""
for field in attachment.fields or []:
if str(field.get("title", "")).strip().lower() == label.lower():
value = field.get("value")
return str(value) if value is not None else None
return None
def _format_known_payload(payload: GlitchTipWebhookPayload, received_at: datetime) -> str:
lines = [f"GlitchTip: {payload.text or 'Alert'}"]
for attachment in payload.attachments or []:
block: list[str] = []
project = _field_value(attachment, "Project")
if project:
block.append(f"Проект: {project}")
if attachment.title:
block.append(attachment.title)
if attachment.text:
block.append(attachment.text)
if attachment.title_link:
block.append(f"Ссылка: {attachment.title_link}")
if block:
lines.append("")
lines.extend(block)
lines.append("")
lines.append(f"Получено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC")
return "\n".join(lines)
def _format_unknown_payload(raw_body: bytes, received_at: datetime) -> str:
"""Payload не распознан ни как issue-, ни как uptime-алерт (нет ни `text`,
ни `attachments`, либо тело — не JSON-объект вовсе) — не роняем запрос,
пересылаем как есть с пометкой (см. требование задачи: неизвестная форма
payload не должна давать 500)."""
text_repr = raw_body.decode("utf-8", errors="replace")
header = "GlitchTip webhook: неизвестный формат payload, пересылаю как есть"
return _truncate(
f"{header}\n\n{text_repr}\n\nПолучено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC"
)
def _build_message(raw_body: bytes, received_at: datetime) -> str:
try:
data = json.loads(raw_body)
except (json.JSONDecodeError, UnicodeDecodeError):
return _format_unknown_payload(raw_body, received_at)
if not isinstance(data, dict):
return _format_unknown_payload(raw_body, received_at)
try:
payload = GlitchTipWebhookPayload.model_validate(data)
except ValidationError:
return _format_unknown_payload(raw_body, received_at)
if payload.text is None and not payload.attachments:
return _format_unknown_payload(raw_body, received_at)
return _truncate(_format_known_payload(payload, received_at))
def _alerts_configured() -> bool:
"""Все три части ОБЯЗАНЫ быть заданы: секрет (auth), токен бота, chat_id
темы алертов. Отсутствие любой — 503, а не тихий no-op и не fail-open."""
return bool(
settings.tradein_internal_auth_secret
and settings.telegram_bot_token
and settings.telegram_alerts_chat_id
)
def _verify_secret(provided: str) -> None:
expected = settings.tradein_internal_auth_secret
# constant-time: длина/префикс секрета не утекают через время ответа.
if not secrets.compare_digest(provided or "", expected):
logger.warning("glitchtip webhook: invalid or missing secret")
raise HTTPException(status_code=401, detail="invalid or missing secret")
@router.post("/ops/glitchtip-webhook")
async def glitchtip_webhook(
request: Request,
secret: Annotated[str, Query()] = "",
header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "",
) -> dict[str, str]:
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
— ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) — этот
хендлер сам делает единственную проверку секрета.
Секрет принимается ИЗ ЗАГОЛОВКА ``X-GlitchTip-Secret``, а query-параметр
``?secret=`` остаётся fallback'ом (#3154). Заголовок предпочтителен потому,
что query едет в access-log и оттуда в Loki открытым текстом; query оставлен,
т.к. САМ GlitchTip 6.1.6 заголовков не шлёт вовсе (``send_webhook()`` —
``session.post(url, json=...)`` без headers, см. docstring модуля), и убрать
query можно только когда заголовок начнёт подставлять кто-то перед нами
(Caddy ``header_up`` на маршруте вебхука) либо после смены отправителя.
"""
if not _alerts_configured():
raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured")
_verify_secret(header_secret or secret)
raw_body = await request.body()
received_at = datetime.now(UTC)
text = _build_message(raw_body, received_at)
client = TelegramClient(settings.telegram_bot_token)
try:
await client.send_message(
chat_id=settings.telegram_alerts_chat_id,
text=text,
message_thread_id=settings.telegram_alerts_topic_id or None,
# review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
# может легально висеть воркерные минуты ретраев.
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
)
except TelegramApiError:
logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram")
raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None
return {"status": "ok"}