Files
remnawave-bedolaga-telegram…/app/external/yookassa_webhook.py
T
Fringg 1f0fef114b refactor: complete structlog migration with contextvars, kwargs, and logging hardening
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding
  via structlog contextvars (aiogram) and http_method/http_path (FastAPI)
- Use bound_contextvars() context manager instead of clear_contextvars()
  to safely restore previous state instead of wiping all context
- Register ContextVarsMiddleware as outermost middleware (before GlobalError)
  so all error logs include user context
- Replace structlog.get_logger() with structlog.get_logger(__name__) across
  270 calls in 265 files for meaningful logger names
- Switch wrapper_class from BoundLogger to make_filtering_bound_logger()
  for pre-processor level filtering (performance optimization)
- Migrate 1411 %-style positional arg logger calls to structlog kwargs
  style across 161 files via AST script
- Migrate log_rotation_service.py from stdlib logging to structlog
- Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES
  and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking
  to Telegram notifications and general log files
- Fix LoggingMiddleware: add from_user null-safety for channel posts,
  switch time.time() to time.monotonic() for duration measurement
- Remove duplicate logger assignments in purchase.py, config.py,
  inline.py, and admin/payments.py
2026-02-16 09:18:12 +03:00

413 lines
15 KiB
Python

from __future__ import annotations
import asyncio
import json
from collections.abc import Iterable
from ipaddress import (
IPv4Address,
IPv4Network,
IPv6Address,
IPv6Network,
ip_address,
ip_network,
)
from typing import TYPE_CHECKING, Union
import structlog
from aiohttp import web
from app.config import settings
from app.database.database import AsyncSessionLocal
if TYPE_CHECKING:
from app.services.payment_service import PaymentService
logger = structlog.get_logger(__name__)
IPAddress = Union[IPv4Address, IPv6Address]
IPNetwork = Union[IPv4Network, IPv6Network]
YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = (
ip_network('185.71.76.0/27'),
ip_network('185.71.77.0/27'),
ip_network('77.75.153.0/25'),
ip_network('77.75.154.128/25'),
ip_network('77.75.156.11/32'),
ip_network('77.75.156.35/32'),
ip_network('2a02:5180::/32'),
)
CLOUDFLARE_TRUSTED_NETWORKS: tuple[IPNetwork, ...] = (
ip_network('173.245.48.0/20'),
ip_network('103.21.244.0/22'),
ip_network('103.22.200.0/22'),
ip_network('103.31.4.0/22'),
ip_network('141.101.64.0/18'),
ip_network('108.162.192.0/18'),
ip_network('190.93.240.0/20'),
ip_network('188.114.96.0/20'),
ip_network('197.234.240.0/22'),
ip_network('198.41.128.0/17'),
ip_network('162.158.0.0/15'),
ip_network('104.16.0.0/13'),
ip_network('104.24.0.0/14'),
ip_network('172.64.0.0/13'),
ip_network('131.0.72.0/22'),
ip_network('2400:cb00::/32'),
ip_network('2606:4700::/32'),
ip_network('2803:f800::/32'),
ip_network('2405:b500::/32'),
ip_network('2405:8100::/32'),
ip_network('2a06:98c0::/29'),
ip_network('2c0f:f248::/32'),
)
YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = (
'payment.succeeded',
'payment.waiting_for_capture',
'payment.canceled',
)
def collect_yookassa_ip_candidates(*values: str | None) -> list[str]:
candidates: list[str] = []
for value in values:
if not value:
continue
for part in value.split(','):
normalized = part.strip()
if normalized:
candidates.append(normalized)
return candidates
def _parse_candidate_ip(candidate: str) -> IPAddress | None:
value = candidate.strip()
if not value:
return None
if value.startswith('[') and ']' in value:
value = value[1 : value.index(']')]
if '%' in value:
value = value.split('%', 1)[0]
if value.count(':') == 1 and '.' in value:
host, _, port = value.rpartition(':')
if port.isdigit():
value = host
try:
return ip_address(value)
except ValueError:
return None
def _should_trust_forwarded_headers(remote_ip: IPAddress | None) -> bool:
if remote_ip is None:
return True
if _is_trusted_proxy_ip(remote_ip):
return True
return any(
getattr(remote_ip, attribute) for attribute in ('is_private', 'is_loopback', 'is_link_local', 'is_reserved')
)
_TRUSTED_PROXY_NETWORKS_CACHE: tuple[str, tuple[IPNetwork, ...]] = ('', ())
def _get_trusted_proxy_networks() -> tuple[IPNetwork, ...]:
global _TRUSTED_PROXY_NETWORKS_CACHE
raw_value = getattr(settings, 'YOOKASSA_TRUSTED_PROXY_NETWORKS', '') or ''
cached_raw, cached_networks = _TRUSTED_PROXY_NETWORKS_CACHE
if raw_value == cached_raw:
return cached_networks
networks: list[IPNetwork] = []
for part in raw_value.split(','):
candidate = part.strip()
if not candidate:
continue
try:
networks.append(ip_network(candidate, strict=False))
except ValueError:
logger.warning('Неверная сеть доверенного прокси YooKassa', candidate=candidate)
cached_networks = tuple(networks)
_TRUSTED_PROXY_NETWORKS_CACHE = (raw_value, cached_networks)
return cached_networks
def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool:
if any(
getattr(ip_object, attribute) for attribute in ('is_private', 'is_loopback', 'is_link_local', 'is_reserved')
):
return True
if any(ip_object in network for network in CLOUDFLARE_TRUSTED_NETWORKS):
return True
return any(ip_object in network for network in _get_trusted_proxy_networks())
def resolve_yookassa_ip(
candidates: Iterable[str],
*,
remote: str | None = None,
) -> IPAddress | None:
remote_ip = _parse_candidate_ip(remote) if remote else None
if remote_ip is not None and remote_ip.is_global and not _is_trusted_proxy_ip(remote_ip):
return remote_ip
candidate_list = list(candidates)
if _should_trust_forwarded_headers(remote_ip):
last_hop = remote_ip
for candidate in reversed(candidate_list):
ip_object = _parse_candidate_ip(candidate)
if ip_object is not None:
if last_hop is None or _is_trusted_proxy_ip(last_hop):
if _is_trusted_proxy_ip(ip_object):
last_hop = ip_object
continue
return ip_object
break
if last_hop is not None and not _is_trusted_proxy_ip(last_hop):
return last_hop
return (
remote_ip
if remote_ip is not None
else next(
(ip for ip in (_parse_candidate_ip(value) for value in candidate_list) if ip is not None),
None,
)
)
def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool:
return any(ip_object in network for network in YOOKASSA_ALLOWED_IP_NETWORKS)
class YooKassaWebhookHandler:
def __init__(self, payment_service: PaymentService):
self.payment_service = payment_service
async def handle_webhook(self, request: web.Request) -> web.Response:
try:
logger.info('📥 Получен YooKassa webhook', method=request.method, path=request.path)
logger.info('📋 Headers', value=dict(request.headers))
header_ip_candidates = collect_yookassa_ip_candidates(
request.headers.get('X-Forwarded-For'),
request.headers.get('X-Real-IP'),
request.headers.get('Cf-Connecting-Ip'),
)
client_ip = resolve_yookassa_ip(
header_ip_candidates,
remote=request.remote,
)
if client_ip is None:
logger.warning(
'🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты',
header_ip_candidates=header_ip_candidates + ([request.remote] if request.remote else []),
)
return web.Response(status=403, text='Forbidden')
if not is_yookassa_ip_allowed(client_ip):
logger.warning(
'🚫 YooKassa webhook отклонён: IP %s не входит в доверенные диапазоны (%s)',
client_ip,
', '.join(str(network) for network in YOOKASSA_ALLOWED_IP_NETWORKS),
)
return web.Response(status=403, text='Forbidden')
logger.info('🌐 IP-адрес YooKassa подтверждён', client_ip=client_ip)
body = await request.text()
if not body:
logger.warning('⚠️ Получен пустой webhook от YooKassa')
return web.Response(status=400, text='Empty body')
logger.info('📄 Body', body=body)
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
if signature:
logger.info('ℹ️ Получена подпись YooKassa', signature=signature)
try:
webhook_data = json.loads(body)
except json.JSONDecodeError as e:
logger.error('❌ Ошибка парсинга JSON webhook YooKassa', error=e)
return web.Response(status=400, text='Invalid JSON')
logger.info('📊 Обработка webhook YooKassa', get=webhook_data.get('event', 'unknown_event'))
logger.debug('🔍 Полные данные webhook', webhook_data=webhook_data)
event_type = webhook_data.get('event')
if not event_type:
logger.warning('⚠️ Webhook YooKassa без типа события')
return web.Response(status=400, text='No event type')
# Извлекаем ID платежа из вебхука для предотвращения дублирования
yookassa_payment_id = webhook_data.get('object', {}).get('id')
if not yookassa_payment_id:
logger.warning('⚠️ Webhook YooKassa без ID платежа')
return web.Response(status=400, text='No payment id')
if event_type not in YOOKASSA_ALLOWED_EVENTS:
logger.info('ℹ️ Игнорируем событие YooKassa', event_type=event_type)
return web.Response(status=200, text='OK')
async with AsyncSessionLocal() as db:
try:
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
from app.database.crud.transaction import get_transaction_by_external_id
from app.database.models import PaymentMethod
existing_transaction = None
if yookassa_payment_id and hasattr(db, 'execute'):
existing_transaction = await get_transaction_by_external_id(
db, yookassa_payment_id, PaymentMethod.YOOKASSA
)
if existing_transaction and event_type == 'payment.succeeded':
logger.info(
'ℹ️ Платеж YooKassa уже был обработан. Пропускаем дублирующий вебхук.',
yookassa_payment_id=yookassa_payment_id,
)
return web.Response(status=200, text='OK')
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
if success:
await db.commit()
logger.info(
'✅ Успешно обработан webhook YooKassa: для платежа',
event_type=event_type,
yookassa_payment_id=yookassa_payment_id,
)
return web.Response(status=200, text='OK')
await db.rollback()
logger.error(
'❌ Ошибка обработки webhook YooKassa: для платежа',
event_type=event_type,
yookassa_payment_id=yookassa_payment_id,
)
return web.Response(status=500, text='Processing error')
except Exception as e:
await db.rollback()
logger.error('❌ Ошибка обработки webhook YooKassa', error=e, exc_info=True)
return web.Response(status=500, text='Processing error')
except Exception as e:
logger.error('❌ Критическая ошибка обработки webhook YooKassa', error=e, exc_info=True)
return web.Response(status=500, text='Internal server error')
def setup_routes(self, app: web.Application) -> None:
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
app.router.add_post(webhook_path, self.handle_webhook)
app.router.add_get(webhook_path, self._get_handler)
app.router.add_options(webhook_path, self._options_handler)
logger.info('✅ Настроен YooKassa webhook на пути: POST', webhook_path=webhook_path)
async def _get_handler(self, request: web.Request) -> web.Response:
return web.json_response(
{
'status': 'ok',
'message': 'YooKassa webhook endpoint is working',
'method': 'GET',
'path': request.path,
'note': 'Use POST method for actual webhooks',
}
)
async def _options_handler(self, request: web.Request) -> web.Response:
return web.Response(
status=200,
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-YooKassa-Signature',
},
)
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
app = web.Application()
webhook_handler = YooKassaWebhookHandler(payment_service)
webhook_handler.setup_routes(app)
async def health_check(request):
return web.json_response(
{
'status': 'ok',
'service': 'yookassa_webhook',
'port': settings.YOOKASSA_WEBHOOK_PORT,
'path': settings.YOOKASSA_WEBHOOK_PATH,
'enabled': settings.is_yookassa_enabled(),
}
)
app.router.add_get('/health', health_check)
return app
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_yookassa_enabled():
logger.info('ℹ️ YooKassa отключена, webhook сервер не запускается')
return
try:
app = create_yookassa_webhook_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, host=settings.YOOKASSA_WEBHOOK_HOST, port=settings.YOOKASSA_WEBHOOK_PORT)
await site.start()
logger.info(
'✅ YooKassa webhook сервер запущен на',
YOOKASSA_WEBHOOK_HOST=settings.YOOKASSA_WEBHOOK_HOST,
YOOKASSA_WEBHOOK_PORT=settings.YOOKASSA_WEBHOOK_PORT,
)
logger.info(
'🎯 YooKassa webhook URL: http://',
YOOKASSA_WEBHOOK_HOST=settings.YOOKASSA_WEBHOOK_HOST,
YOOKASSA_WEBHOOK_PORT=settings.YOOKASSA_WEBHOOK_PORT,
YOOKASSA_WEBHOOK_PATH=settings.YOOKASSA_WEBHOOK_PATH,
)
try:
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
logger.info('🛑 YooKassa webhook сервер получил сигнал остановки')
finally:
await site.stop()
await runner.cleanup()
logger.info('✅ YooKassa webhook сервер остановлен')
except Exception as e:
logger.error('❌ Ошибка запуска YooKassa webhook сервера', error=e, exc_info=True)
raise