feat: outbound webhooks for payment/subscription events
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 <noreply@anthropic.com>
This commit is contained in:
parent
24a1b26df2
commit
4b86406041
4 changed files with 100 additions and 1 deletions
34
admin.html
34
admin.html
|
|
@ -656,6 +656,20 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="backup-result"></div>
|
<div id="backup-result"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top:20px">
|
||||||
|
<div class="section-head"><h2>Webhook на события</h2></div>
|
||||||
|
<p class="page-sub" style="margin-bottom:16px">Панель сама постучится на твой URL при оплате или ручной выдаче подписки — для своих интеграций (CRM, аналитика, что угодно), без опроса API.</p>
|
||||||
|
<div class="form-row">
|
||||||
|
<div><label class="f">URL</label><input type="text" id="webhook-url" placeholder="https://example.com/hook"></div>
|
||||||
|
<div style="flex:0"><label class="f"> </label><button class="btn" onclick="saveWebhookSettings()">Сохранить</button></div>
|
||||||
|
</div>
|
||||||
|
<p class="check-hint">
|
||||||
|
События: <code>payment.paid</code>, <code>subscription.granted_by_admin</code>. Тело — JSON <code>{"event": "...", "data": {...}}</code>, подписано заголовком <code>X-Signature</code> (HMAC-SHA256 от тела запроса на секрете ниже) — так получатель проверяет, что запрос реально от панели.
|
||||||
|
Секрет для проверки: <code id="webhook-secret-display">—</code>
|
||||||
|
</p>
|
||||||
|
<div id="webhook-result"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -740,7 +754,7 @@ function showView(name) {
|
||||||
if (name === "nodes") loadNodes();
|
if (name === "nodes") loadNodes();
|
||||||
if (name === "traffic") loadTraffic();
|
if (name === "traffic") loadTraffic();
|
||||||
if (name === "payments") { loadPayments(); loadPaymentsSettings(); }
|
if (name === "payments") { loadPayments(); loadPaymentsSettings(); }
|
||||||
if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); }
|
if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); loadWebhookSettings(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const COUNTRIES = [
|
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 = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Сохранено</p>';
|
||||||
|
loadWebhookSettings();
|
||||||
|
} catch (e) {
|
||||||
|
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function downloadBackup() {
|
function downloadBackup() {
|
||||||
window.location.href = "/admin/api/backup";
|
window.location.href = "/admin/api/backup";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
api.py
34
api.py
|
|
@ -3,6 +3,7 @@ import datetime
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response, File, UploadFile
|
from fastapi import FastAPI, HTTPException, Request, Response, File, UploadFile
|
||||||
|
|
@ -17,6 +18,7 @@ import links
|
||||||
import nodeprov
|
import nodeprov
|
||||||
import payments
|
import payments
|
||||||
import totp
|
import totp
|
||||||
|
import webhooks
|
||||||
import xray_manager
|
import xray_manager
|
||||||
from config import (
|
from config import (
|
||||||
PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN,
|
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"Срок: {plan['label']} — до {sub['expires_at'][:10]}\n\n"
|
||||||
f"Ссылка-подписка:\nhttps://{SUB_DOMAIN}/sub/{user['token']}",
|
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:
|
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}
|
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")
|
@app.post("/payments/webhook/yookassa")
|
||||||
async def yookassa_webhook(request: Request):
|
async def yookassa_webhook(request: Request):
|
||||||
body = await request.json()
|
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)
|
db.get_or_create_user(tg_id, None)
|
||||||
sub = db.create_subscription(tg_id, node_code, plan["days"], plan_code, source="admin")
|
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"])
|
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
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
10
bot.py
10
bot.py
|
|
@ -10,6 +10,7 @@ from aiogram.enums import ParseMode
|
||||||
import db
|
import db
|
||||||
import links
|
import links
|
||||||
import payments
|
import payments
|
||||||
|
import webhooks
|
||||||
import xray_manager
|
import xray_manager
|
||||||
from config import BOT_TOKEN, ADMIN_IDS, PLANS, PLANS_BY_CODE, SUB_DOMAIN, SITE_DOMAIN, PAYMENTS_ENABLED
|
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:
|
except Exception:
|
||||||
log.exception("failed to notify user about payment")
|
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:
|
elif status in payments.FAILED_STATUSES:
|
||||||
db.mark_payment_failed(payment["id"])
|
db.mark_payment_failed(payment["id"])
|
||||||
|
|
||||||
|
|
|
||||||
23
webhooks.py
Normal file
23
webhooks.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue