feat: outbound webhooks for node lifecycle — closes the "users + nodes" gap from the comparison

Last remaining actionable row from the docs.rw comparison table pulled
two commits ago: "Webhook event support — Users + nodes (Remnawave) /
Users only (Marzban)". Every webhook we send is subscription/payment
events — user-side only, same as Marzban, even after last commit's
revoke/hold/resume additions. Zero node events.

node.added on creation, node.deleted on deletion (captures the node's
label before it's gone, since delete_node doesn't return the row),
node.enabled/node.disabled on the PATCH route — but only when the
enabled field actually changes value, not on every save. Editing just
the label, or PATCHing enabled to the same value it already had,
correctly fires nothing — checked this specifically since a naive
"enabled is in the request body" check would have spammed an event on
every harmless edit of an already-enabled node.

Verification: same two-part approach as the subscription lifecycle
webhooks. AST-extracted the actual admin_update_node() body out of
api.py (still can't import it directly) and ran it against a fake
db/webhooks module — 5 cases: enabling, disabling, a same-value no-op
save, and an unrelated-field-only edit, confirming the webhook fires
exactly when and only when it should. Then a real local HTTP server for
all four event types through the actual webhooks.send(), receiver-side
HMAC recomputed independently from its own copy of the secret and
compared against X-Signature, not trusted from the sender. README's
feature list had also fallen behind the last three commits (webhooks,
hold/pause, subscription search never got a bullet) — caught up all
three while I was in there, not just the one this commit adds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Savsis? 2026-09-14 02:56:25 +05:00
parent b8c4949201
commit bbbab9998e
3 changed files with 16 additions and 3 deletions

12
api.py
View file

@ -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}