Webhook receiver: Plane deliveries trigger a tick (PAD-7)

agentd webhook-serve (agentd/webhook.py, stdlib): verifies X-Plane-Signature
(HMAC-SHA256 hex of the body, constant-time; 401 on missing/bad), routes by
data.project to a configured tenant (others 202), coalesces deliveries within
3 s into one tick, runs one more tick when deliveries arrive during a tick,
and retries every 15 s for up to 2 min while the lock is held (tick command
exit 75: flock -E 75 in the cron scripts, cli tick on Busy). Ticks run in a
systemd scope so restarting the receiver never kills one. Log:
<log_dir>/webhook.log; GET /healthz.

Config: webhook section (key names only, PLANE_WEBHOOK_SECRET scrubbed from
child envs like the Plane tokens), notify: {kind: none} as a no-op hook
called on every state move; the unused per-project webhook_secret_key is
gone (Plane webhooks are workspace-level). systemd unit for the LXC and
tools/plane-webhook.py to create/rotate the Plane webhook as the workspace
admin (secret straight into the Vault, never printed).
This commit is contained in:
jj.wang
2026-10-21 13:24:04 +00:00
parent 72681db46b
commit 6dc7e72ac8
14 changed files with 651 additions and 9 deletions
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Plane workspace webhooks, as the workspace admin kappiee (session login; the internal /api/workspaces/<slug>/webhooks/
endpoint needs admin). Never prints secrets: a new or regenerated secret goes straight into the Vault as
PLANE_WEBHOOK_SECRET (then restart plane-agentd-webhook). Run under
agent-access.py --config <identity> exec --secret-env PLANE_KAPPIEE_PASSWORD=PLANE_KAPPIEE_PASSWORD -- python3 -I tools/plane-webhook.py ...
usage: list | create URL (inactive; work item + comment events) | patch ID key=value... (e.g. is_active=true)
| regenerate ID | delete ID | logs ID [n]"""
import http.cookiejar, json, os, ssl, sys, urllib.parse, urllib.request
BASE = "https://plane.kappiee.com"
WS = "kappiee"
jar = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
HDR = {"Referer": BASE + "/", "Origin": BASE, "User-Agent": "plane-agentd-admin"}
def csrf():
return next((c.value for c in jar if c.name == "csrftoken"), "")
def call(method, path, body=None, form=None):
h = dict(HDR)
data = None
if form is not None:
data = urllib.parse.urlencode(form).encode()
h["Content-Type"] = "application/x-www-form-urlencoded"
elif body is not None:
data = json.dumps(body).encode()
h["Content-Type"] = "application/json"
if method != "GET":
h["X-CSRFToken"] = csrf()
req = urllib.request.Request(BASE + path, data=data, headers=h, method=method)
try:
with op.open(req, timeout=30) as r:
raw = r.read()
return r.status, raw, r.geturl()
except urllib.error.HTTPError as e:
return e.code, e.read(), path
def login():
st, raw, _ = call("GET", "/auth/get-csrf-token/")
tok = json.loads(raw).get("csrf_token") or csrf()
st, raw, url = call("POST", "/auth/sign-in/", form={"csrfmiddlewaretoken": tok, "email": "kappiee@kappiee.com",
"password": os.environ["PLANE_KAPPIEE_PASSWORD"]})
ok = any(c.name.startswith("session") for c in jar)
print("login", st, "session cookie" if ok else "NO SESSION", urllib.parse.urlparse(url).path)
if not ok:
sys.exit(1)
def redact(d):
return {k: ("<%d chars>" % len(v) if k == "secret_key" and v else v) for k, v in d.items()}
def vault_store(name, value):
ident = json.load(urllib.request.urlopen("https://nas.tail9531b.ts.net:8443/identity/agent.json", timeout=30))
v = ident["vault"]
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(v["url"].rstrip("/") + "/v1/credentials", method="POST",
data=json.dumps({"vault": "default", "credentials": {name: value}}).encode(),
headers={"Authorization": "Bearer " + v["token"], "X-Vault": "default",
"Content-Type": "application/json"})
with urllib.request.urlopen(req, context=ctx, timeout=30) as r:
print("vault store", name, r.status)
def store_secret(w):
if w.get("secret_key"):
vault_store("PLANE_WEBHOOK_SECRET", w["secret_key"])
login()
P = "/api/workspaces/%s/webhooks/" % WS
cmd = sys.argv[1]
if cmd == "list":
st, raw, _ = call("GET", P)
print("list", st)
if st == 200:
for w in json.loads(raw):
print(json.dumps(redact(w)))
else:
print(raw[:300])
elif cmd == "create":
body = {"url": sys.argv[2], "is_active": False, "project": False, "issue": True, "module": False, "cycle": False,
"issue_comment": True}
st, raw, _ = call("POST", P, body)
print("create", st)
if st in (200, 201):
w = json.loads(raw)
store_secret(w)
print(json.dumps(redact(w)))
else:
print(raw[:500])
elif cmd == "patch":
body = {}
for kv in sys.argv[3:]:
k, v = kv.split("=", 1)
body[k] = {"true": True, "false": False}.get(v, v)
st, raw, _ = call("PATCH", P + sys.argv[2] + "/", body)
print("patch", st, json.dumps(redact(json.loads(raw))) if st == 200 else raw[:500])
elif cmd == "regenerate":
st, raw, _ = call("POST", P + sys.argv[2] + "/regenerate/", {})
print("regenerate", st)
if st in (200, 201):
w = json.loads(raw)
store_secret(w)
print(json.dumps(redact(w)))
else:
print(raw[:500])
elif cmd == "delete":
st, raw, _ = call("DELETE", P + sys.argv[2] + "/")
print("delete", st, raw[:200])
elif cmd == "logs":
st, raw, _ = call("GET", "/api/workspaces/%s/webhook-logs/%s/" % (WS, sys.argv[2]))
print("logs", st)
if st == 200:
for l in json.loads(raw)[: int(sys.argv[3]) if len(sys.argv) > 3 else 10]:
print(l.get("created_at"), l.get("event_type"), l.get("request_method"), l.get("response_status"), (l.get("response_body") or "")[:120], l.get("retry_count"))