diff --git a/README.md b/README.md index 5d4bc40..14b9b79 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ - **Drag-n-drop ноды** — порядок нод в списке настраивается мышкой, как у Remnawave. - **Rate-limit на вход** — по IP, отдельно на пароль и на 2FA-код. - **Свой путь входа** — страницу логина можно увести с дефолтного `/admin` на любой другой (`ADMIN_PATH` в `.env`), доп. слой поверх rate-limit и 2FA — у Remnawave это в списке заявленных мер безопасности, у Marzban нет вообще. +- **Пауза подписки** — временно отключить доступ без потери оплаченных дней (Marzban это умеет, Remnawave — нет): при возобновлении срок сдвигается ровно на длительность паузы. +- **Исходящие вебхуки** — на оплату, выдачу/отзыв/паузу/возобновление подписки и на добавление/удаление/вкл-выкл ноды, с HMAC-подписью тела. У Remnawave это события по юзерам и нодам, у Marzban — только по юзерам; мы покрываем оба класса. +- **Поиск и фильтр по подпискам** — по юзернейму/tg id/ноде/тарифу и по статусу, прямо в таблице. ## Архитектура diff --git a/admin.html b/admin.html index e484b25..a59e582 100644 --- a/admin.html +++ b/admin.html @@ -616,7 +616,7 @@

Платежи и вебхуки

Вкладка Платежи → «Настройка приёма платежей» собирает публичную оферту и политику конфиденциальности (/offer, /privacy) из введённых реквизитов — ЮKassa их спросит при регистрации магазина. Дата вступления в силу проставляется один раз, правки реквизитов её не двигают.

Ключи ЮKassa проверяются вживую через их /v3/me перед сохранением; у Platega такого эндпоинта нет, ключи сохраняются без проверки. Оба провайдера включаются независимо.

-

Исходящие вебхуки (Настройки → Webhook на события) — панель стучится на указанный URL при оплате (payment.paid), ручной выдаче подписки админом (subscription.granted_by_admin), отзыве (subscription.revoked), постановке на паузу (subscription.held) и возобновлении (subscription.resumed). Тело подписано X-Signature (HMAC-SHA256). Секрет выдаётся один раз и не меняется при правке URL — для интеграций со своими системами, без опроса API.

+

Исходящие вебхуки (Настройки → Webhook на события) — панель стучится на указанный URL при оплате (payment.paid), ручной выдаче подписки админом (subscription.granted_by_admin), отзыве (subscription.revoked), постановке на паузу (subscription.held) и возобновлении (subscription.resumed), а также при добавлении (node.added), удалении (node.deleted) и включении/выключении ноды (node.enabled/node.disabled — только когда состояние реально поменялось, не на каждое сохранение формы редактирования). Тело подписано X-Signature (HMAC-SHA256). Секрет выдаётся один раз и не меняется при правке URL — для интеграций со своими системами, без опроса API.

Вкладка Платежи → «Тарифы» — цены по срокам и общий рубильник приёма оплаты. Как и реквизиты с ключами провайдеров, это читается панелью напрямую из .env при каждом запросе — правки в UI применяются мгновенно везде (бот, API, проверка вебхуков), рестарт панели нигде не требуется.

@@ -723,7 +723,7 @@

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

diff --git a/api.py b/api.py index 1b64994..eee0d16 100644 --- a/api.py +++ b/api.py @@ -1086,6 +1086,7 @@ def admin_create_node(request: Request, body: dict = Body(...)): sni=body["sni"], flow=body.get("flow", "xtls-rprx-vision"), shared_uuid=body.get("shared_uuid"), ) + webhooks.send("node.added", {"code": node["code"], "label": node["label"], "kind": node["kind"]}) return node @@ -1096,16 +1097,25 @@ def admin_update_node(code: str, request: Request, body: dict = Body(...)): if code == "de1": editable = {"label"} allowed = {k: v for k, v in body.items() if k in editable} - return db.update_node(code, **allowed) + before = db.get_node(code) + updated = db.update_node(code, **allowed) + if before and updated and "enabled" in allowed and bool(before["enabled"]) != bool(updated["enabled"]): + webhooks.send("node.enabled" if updated["enabled"] else "node.disabled", { + "code": code, "label": updated["label"], + }) + return updated @app.delete("/admin/api/nodes/{code}") def admin_delete_node(code: str, request: Request): require_admin(request) + node = db.get_node(code) try: db.delete_node(code) except ValueError as e: raise HTTPException(400, str(e)) + if node: + webhooks.send("node.deleted", {"code": code, "label": node["label"]}) return {"ok": True}