mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-23 21:01:17 +00:00
- Add pyproject.toml with uv and ruff configuration - Pin Python version to 3.13 via .python-version - Add Makefile commands: lint, format, fix - Apply ruff formatting to entire codebase - Remove unused imports (base64 in yookassa/simple_subscription) - Update .gitignore for new config files
408 lines
14 KiB
Python
408 lines
14 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
from collections.abc import Iterable
|
||
from ipaddress import (
|
||
IPv4Address,
|
||
IPv4Network,
|
||
IPv6Address,
|
||
IPv6Network,
|
||
ip_address,
|
||
ip_network,
|
||
)
|
||
from typing import TYPE_CHECKING, Union
|
||
|
||
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 = logging.getLogger(__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: %s', 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(f'📥 Получен YooKassa webhook: {request.method} {request.path}')
|
||
logger.info(f'📋 Headers: {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. Кандидаты: %s',
|
||
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 подтверждён: %s', client_ip)
|
||
|
||
body = await request.text()
|
||
|
||
if not body:
|
||
logger.warning('⚠️ Получен пустой webhook от YooKassa')
|
||
return web.Response(status=400, text='Empty body')
|
||
|
||
logger.info(f'📄 Body: {body}')
|
||
|
||
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
|
||
if signature:
|
||
logger.info('ℹ️ Получена подпись YooKassa: %s', signature)
|
||
|
||
try:
|
||
webhook_data = json.loads(body)
|
||
except json.JSONDecodeError as e:
|
||
logger.error(f'❌ Ошибка парсинга JSON webhook YooKassa: {e}')
|
||
return web.Response(status=400, text='Invalid JSON')
|
||
|
||
logger.info(f'📊 Обработка webhook YooKassa: {webhook_data.get("event", "unknown_event")}')
|
||
logger.debug(f'🔍 Полные данные webhook: {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(f'ℹ️ Игнорируем событие YooKassa: {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(
|
||
f'ℹ️ Платеж YooKassa {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(
|
||
f'✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}'
|
||
)
|
||
return web.Response(status=200, text='OK')
|
||
await db.rollback()
|
||
logger.error(
|
||
f'❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}'
|
||
)
|
||
return web.Response(status=500, text='Processing error')
|
||
|
||
except Exception as e:
|
||
await db.rollback()
|
||
logger.error(f'❌ Ошибка обработки webhook YooKassa: {e}', exc_info=True)
|
||
return web.Response(status=500, text='Processing error')
|
||
|
||
except Exception as e:
|
||
logger.error(f'❌ Критическая ошибка обработки webhook YooKassa: {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(f'✅ Настроен YooKassa webhook на пути: POST {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 сервер запущен на %s:%s',
|
||
settings.YOOKASSA_WEBHOOK_HOST,
|
||
settings.YOOKASSA_WEBHOOK_PORT,
|
||
)
|
||
logger.info(
|
||
'🎯 YooKassa webhook URL: http://%s:%s%s',
|
||
settings.YOOKASSA_WEBHOOK_HOST,
|
||
settings.YOOKASSA_WEBHOOK_PORT,
|
||
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(f'❌ Ошибка запуска YooKassa webhook сервера: {e}', exc_info=True)
|
||
raise
|