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
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`).
231 lines
12 KiB
Python
231 lines
12 KiB
Python
"""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"}
|