Files
remnawave-bedolaga-telegram…/app/webserver/remnawave_webhook.py
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

150 lines
5.7 KiB
Python

"""
FastAPI router for receiving incoming webhooks from RemnaWave backend.
Handles HMAC-SHA256 signature verification, payload parsing, and
event dispatch to RemnaWaveWebhookService.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import structlog
from aiogram import Bot
from fastapi import APIRouter, Request, status
from fastapi.responses import JSONResponse
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.services.remnawave_webhook_service import RemnaWaveWebhookService
logger = structlog.get_logger(__name__)
# Max accepted webhook payload size (64 KB) to prevent memory exhaustion DoS
_MAX_BODY_SIZE = 64 * 1024
def _verify_signature(raw_body: bytes, received_signature: str, secret: str) -> bool:
"""Verify HMAC-SHA256 signature from RemnaWave backend."""
expected = hmac.new(secret.encode('utf-8'), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received_signature)
def create_remnawave_webhook_router(bot: Bot) -> APIRouter:
router = APIRouter()
webhook_service = RemnaWaveWebhookService(bot)
webhook_path = settings.REMNAWAVE_WEBHOOK_PATH
@router.get(webhook_path)
async def remnawave_webhook_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'remnawave_webhook',
'enabled': settings.is_remnawave_webhook_enabled(),
}
)
@router.post(webhook_path)
async def remnawave_webhook(request: Request) -> JSONResponse:
raw_body = await request.body()
if not raw_body:
return JSONResponse(
{'status': 'error', 'reason': 'empty_body'},
status_code=status.HTTP_400_BAD_REQUEST,
)
if len(raw_body) > _MAX_BODY_SIZE:
logger.warning('RemnaWave webhook: payload too large (bytes)', raw_body_count=len(raw_body))
return JSONResponse(
{'status': 'error', 'reason': 'payload_too_large'},
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
)
# Verify HMAC-SHA256 signature (always required)
secret = settings.REMNAWAVE_WEBHOOK_SECRET
if not secret:
logger.error('RemnaWave webhook: secret not configured, rejecting request')
return JSONResponse(
{'status': 'error', 'reason': 'webhook_not_configured'},
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
signature = request.headers.get('X-Remnawave-Signature') or ''
if not signature:
logger.warning('RemnaWave webhook: missing signature header')
return JSONResponse(
{'status': 'error', 'reason': 'missing_signature'},
status_code=status.HTTP_401_UNAUTHORIZED,
)
if not _verify_signature(raw_body, signature, secret):
logger.warning('RemnaWave webhook: invalid signature')
return JSONResponse(
{'status': 'error', 'reason': 'invalid_signature'},
status_code=status.HTTP_401_UNAUTHORIZED,
)
# Parse JSON payload
try:
payload = json.loads(raw_body.decode('utf-8'))
except json.JSONDecodeError:
return JSONResponse(
{'status': 'error', 'reason': 'invalid_json'},
status_code=status.HTTP_400_BAD_REQUEST,
)
# Extract and validate event info
scope = payload.get('scope', '')
event = payload.get('event', '')
data = payload.get('data')
if not scope or not event:
logger.warning('RemnaWave webhook: missing scope or event')
return JSONResponse(
{'status': 'error', 'reason': 'missing_scope_or_event'},
status_code=status.HTTP_400_BAD_REQUEST,
)
if not isinstance(data, dict):
data = {}
# RemnaWave sends event as full qualified name (e.g. "user.modified"),
# so we use event directly instead of concatenating scope + event.
event_name = event
logger.info('RemnaWave webhook received: scope event', scope=scope, event_name=event_name)
# Process event — return 200 to prevent retries for application-level errors.
# Only return non-200 for infrastructure failures (DB unavailable).
# Admin events (node/service/crm) don't need a DB session.
if webhook_service.is_admin_event(event_name):
try:
processed = await webhook_service.process_event(None, event_name, data)
return JSONResponse({'status': 'ok', 'processed': processed})
except Exception:
logger.exception('RemnaWave webhook processing error for event', event_name=event_name)
return JSONResponse({'status': 'ok', 'processed': False})
# User events require a DB session
try:
async with AsyncSessionLocal() as db:
try:
processed = await webhook_service.process_event(db, event_name, data)
await db.commit()
return JSONResponse({'status': 'ok', 'processed': processed})
except Exception:
await db.rollback()
logger.exception('RemnaWave webhook processing error for event', event_name=event_name)
return JSONResponse({'status': 'ok', 'processed': False})
except Exception:
logger.error('RemnaWave webhook: failed to get database session')
return JSONResponse(
{'status': 'error', 'reason': 'database_unavailable'},
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
return router