From 4b86406041439b2ddfe28485d3b5b7a405446a41 Mon Sep 17 00:00:00 2001 From: savsis Date: Sun, 13 Sep 2026 21:15:13 +0500 Subject: [PATCH] feat: outbound webhooks for payment/subscription events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the docs.rw comparison researched earlier tonight, Remnawave fires webhooks for users+nodes and Marzban for users — this panel had neither, only received inbound webhooks from payment providers. New webhooks.py, fired on payment.paid (both webhook-driven and reconciler-driven grant paths, so it fires regardless of which one actually processes a given payment) and subscription.granted_by_admin (kept as a distinct event name rather than reusing payment.paid, since no money necessarily changed hands there). Settings tab gets a URL field; a secret is generated once on first save via secrets.token_hex and never regenerated on later URL edits, so a receiver's signature verification doesn't silently break when the admin just updates the endpoint. Every delivery is HMAC-SHA256 signed over the raw JSON body via X-Signature, same verification shape Platega already uses for its inbound webhooks. Delivery is fire-and-forget (10s timeout, swallows all exceptions) — a receiver being down must never block or fail a payment grant. Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's existing reader instead of adding a third copy of that logic. Verified with a real local HTTP server: actual delivery, payload shape, and that the received X-Signature verifies against the configured secret using the receiver's own side of the HMAC — not just asserting the sender computed *something*. Also verified the no-URL-configured no-op path and that changing the URL later does not rotate the secret. Co-Authored-By: Claude Sonnet 5 --- admin.html | 34 +++++++++++++++++++++++++++++++++- api.py | 34 ++++++++++++++++++++++++++++++++++ bot.py | 10 ++++++++++ webhooks.py | 23 +++++++++++++++++++++++ 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 webhooks.py diff --git a/admin.html b/admin.html index 1bf799a..6d635f9 100644 --- a/admin.html +++ b/admin.html @@ -656,6 +656,20 @@
+ +
+

Webhook на события

+

Панель сама постучится на твой URL при оплате или ручной выдаче подписки — для своих интеграций (CRM, аналитика, что угодно), без опроса API.

+
+
+
+
+

+ События: payment.paid, subscription.granted_by_admin. Тело — JSON {"event": "...", "data": {...}}, подписано заголовком X-Signature (HMAC-SHA256 от тела запроса на секрете ниже) — так получатель проверяет, что запрос реально от панели. + Секрет для проверки: — +

+
+
@@ -740,7 +754,7 @@ function showView(name) { if (name === "nodes") loadNodes(); if (name === "traffic") loadTraffic(); if (name === "payments") { loadPayments(); loadPaymentsSettings(); } - if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); } + if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); loadWebhookSettings(); } } const COUNTRIES = [ @@ -1199,6 +1213,24 @@ async function confirmDisableTotp() { } } +async function loadWebhookSettings() { + const res = await api("/admin/api/webhook-settings"); + document.getElementById("webhook-url").value = res.url || ""; + document.getElementById("webhook-secret-display").textContent = res.secret || "будет создан при сохранении URL"; +} + +async function saveWebhookSettings() { + const url = document.getElementById("webhook-url").value.trim(); + const result = document.getElementById("webhook-result"); + try { + await api("/admin/api/webhook-settings", { method: "POST", body: JSON.stringify({ url }) }); + result.innerHTML = '

Сохранено

'; + loadWebhookSettings(); + } catch (e) { + result.innerHTML = '

Не получилось: ' + esc(e.message) + '

'; + } +} + function downloadBackup() { window.location.href = "/admin/api/backup"; } diff --git a/api.py b/api.py index 7615f2e..a538c89 100644 --- a/api.py +++ b/api.py @@ -3,6 +3,7 @@ import datetime import json import os import re +import secrets import subprocess import urllib.request from fastapi import FastAPI, HTTPException, Request, Response, File, UploadFile @@ -17,6 +18,7 @@ import links import nodeprov import payments import totp +import webhooks import xray_manager from config import ( PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, @@ -328,6 +330,15 @@ def _grant_paid_subscription(payment_id: str): f"Срок: {plan['label']} — до {sub['expires_at'][:10]}\n\n" f"Ссылка-подписка:\nhttps://{SUB_DOMAIN}/sub/{user['token']}", ) + webhooks.send("payment.paid", { + "tg_id": payment["tg_id"], + "amount": payment["amount"], + "provider": payment["provider"], + "node": payment["node"], + "plan": payment["plan"], + "subscription_uuid": sub["uuid"], + "expires_at": sub["expires_at"], + }) def _check_and_reconcile_payment(payment: dict) -> str: @@ -456,6 +467,25 @@ def admin_set_platega_settings(request: Request, body: dict = Body(...)): return {"ok": True, "restarted_bot": restarted} +@app.get("/admin/api/webhook-settings") +def admin_get_webhook_settings(request: Request): + require_admin(request) + return { + "url": legal.read_env_var("WEBHOOK_URL", ""), + "secret": legal.read_env_var("WEBHOOK_SECRET", ""), + } + + +@app.post("/admin/api/webhook-settings") +def admin_set_webhook_settings(request: Request, body: dict = Body(...)): + require_admin(request) + url = (body.get("url") or "").strip() + _update_env_var("WEBHOOK_URL", url) + if url and not legal.read_env_var("WEBHOOK_SECRET", ""): + _update_env_var("WEBHOOK_SECRET", secrets.token_hex(24)) + return {"url": legal.read_env_var("WEBHOOK_URL", ""), "secret": legal.read_env_var("WEBHOOK_SECRET", "")} + + @app.post("/payments/webhook/yookassa") async def yookassa_webhook(request: Request): body = await request.json() @@ -864,6 +894,10 @@ def admin_grant_subscription(tg_id: int, request: Request, body: dict = Body(... db.get_or_create_user(tg_id, None) sub = db.create_subscription(tg_id, node_code, plan["days"], plan_code, source="admin") xray_manager.add_client_to_node(node, sub["uuid"], email=sub["uuid"]) + webhooks.send("subscription.granted_by_admin", { + "tg_id": tg_id, "node": node_code, "plan": plan_code, + "subscription_uuid": sub["uuid"], "expires_at": sub["expires_at"], + }) return sub diff --git a/bot.py b/bot.py index ac49087..84084f2 100644 --- a/bot.py +++ b/bot.py @@ -10,6 +10,7 @@ from aiogram.enums import ParseMode import db import links import payments +import webhooks import xray_manager from config import BOT_TOKEN, ADMIN_IDS, PLANS, PLANS_BY_CODE, SUB_DOMAIN, SITE_DOMAIN, PAYMENTS_ENABLED @@ -353,6 +354,15 @@ async def reconcile_pending_payments(): ) except Exception: log.exception("failed to notify user about payment") + await asyncio.to_thread(webhooks.send, "payment.paid", { + "tg_id": payment["tg_id"], + "amount": payment["amount"], + "provider": payment["provider"], + "node": payment["node"], + "plan": payment["plan"], + "subscription_uuid": sub["uuid"], + "expires_at": sub["expires_at"], + }) elif status in payments.FAILED_STATUSES: db.mark_payment_failed(payment["id"]) diff --git a/webhooks.py b/webhooks.py new file mode 100644 index 0000000..2eea961 --- /dev/null +++ b/webhooks.py @@ -0,0 +1,23 @@ +import hashlib +import hmac +import json +import urllib.request + +from legal import read_env_var + + +def send(event: str, data: dict): + url = read_env_var("WEBHOOK_URL") + secret = read_env_var("WEBHOOK_SECRET") + if not url or not secret: + return + body = json.dumps({"event": event, "data": data}).encode() + signature = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + req = urllib.request.Request( + url, data=body, method="POST", + headers={"Content-Type": "application/json", "X-Signature": signature}, + ) + try: + urllib.request.urlopen(req, timeout=10) + except Exception: + pass