Files
remnawave-bedolaga-telegram…/app/webserver/remnawave_webhook.py
T
c0mrade 565c08366b feat: add Remnawave panel 2.7.0 API support
- Add MONTH_ROLLING traffic reset strategy (enum, UI, mapping)
- Fix squad remove-users HTTP method POST → DELETE
- Add forceRestart param to restart_all_nodes (API, service, routes)
- Replace removed /bandwidth-stats/nodes/realtime with /system/nodes/metrics
- Fix parse_bytes suffix matching bug (B was catching GB/MB/KB/TB)
- Add torrent_blocker.report webhook event with admin notifications
- Add subpage_config_changed → auto-invalidate app config cache
- Pass webhook meta field to handlers (notConnectedAfterHours)
- Improve CRM/login webhook formatting (providerName, loginAttempt)
- Update bandwidth display: remove /s suffix, show inbound traffic totals
2026-03-28 12:34:15 +03:00

155 lines
5.9 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 = {}
# Inject meta into data so handlers can access it via data.get('_meta')
meta = payload.get('meta')
if isinstance(meta, dict):
data['_meta'] = meta
# 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