Expand description
Outbound ref-landed webhooks (D32): “something moved, go run this”.
The node speaks to git and to its own clients; until this module it
told nobody else when a ref landed. A subscription is a line in an
operator-owned file — <repo:refname pattern> <url> <secret> [allow-private] — reloaded on mtime like the keys and ACL files, and
deliberately not a ViewOp: who may be notified is configuration,
not sequenced state, and putting it in the log would make it
replayable and unforgettable.
§Invariant 5 is the whole design
A webhook is a request to an address someone else chose, so its
latency is unbounded by construction. The sequencer’s writer thread
therefore does exactly one thing with an event: Hooks::offer,
which is a non-blocking try_send into a bounded queue. Reloading the
subscription file, matching patterns, resolving and vetting the
address, running curl and retrying all happen on the delivery
thread this module spawns. When the queue is full the event is
dropped and counted, never blocked on: a receiver that stops
answering must not be able to stall op admission, and blocking the
writer is the one failure this module exists to make impossible.
§Best-effort, counted, never silent
Delivery is best-effort, not at-least-once. At-least-once needs a
durable spool with its own fsync discipline, and the only durable
writer here is the sequencer — the thread the design keeps out of the
delivery path. So every attempt, every refusal and every queue-full
drop is appended to the delivery log (<root>/.choir/hooks.jsonl), a
production observation beside lag.jsonl: not hashed, not replayed,
never part of the ordered history. A receiver that may not miss a ref
polls GET /api/log?from=N, which is what agents already do.
§Talking to an address someone else chose
This is the node’s first outbound request to an operator-supplied
target, so SSRF is in scope. Vetting refuses non-http(s) schemes,
resolves the host and refuses loopback, private, carrier-NAT,
link-local (which is what covers the cloud metadata service at
169.254.169.254), unique-local, unspecified, broadcast and
multicast addresses unless the subscription says allow-private; the
vetted address is then pinned into curl --resolve so a second DNS
answer cannot land somewhere else, redirects are refused outright
because a redirect is the standard way past exactly this check, and a
non-loopback target must be https, since a bearer secret in clear
over the internet is the same as no secret.
The operator’s guide to configuring them:
§Webhooks (D32)
The node’s only outbound request to an address somebody else chose. It
fires when a named ref moves and is best-effort; a receiver that may not
miss a ref polls GET /api/log?from=N.
--hooks-file, one subscription per line, # comments:
# <repo:refname pattern> <url> <secret> [allow-private]
owner/demo:refs/heads/main https://ci.example/choir <SECRET>
owner/demo:refs/heads/* https://ci.example/branches <SECRET>
owner/notes:refs/tags/* http://127.0.0.1:9000/hook <SECRET> allow-privatePatterns follow --protected-refs: trailing * is a prefix, else exact,
matched against <repo>:<refname>. Re-read on mtime change. Needs
--keys-file. Delivery records go to <repo-root>/.choir/hooks.jsonl.
The body:
{"format_version":1,"event":"ref-landed","repo":"owner/demo","ref":"refs/heads/main",
"ref_key":"owner/demo:refs/heads/main","old":"<git oid>","new":"<git oid>","seq":41,
"entry":"<entry hash>","actor":"<channel>","key_id":"<signing key id>"}old is null for a created ref, new null for a deleted one. entry is
unique per event; discard repeats on it.
Verify the secret. The delivery carries X-Choir-Hook-Secret: <secret>.
It is a bearer secret, so give each subscription its own
(openssl rand -hex 32), keep the file 0600, and use https for any
non-loopback target; the node refuses to send the secret in clear.
Best-effort, never silent. Three attempts per delivery; every attempt,
refusal and dropped event is a line in hooks.jsonl. A bounded queue on
its own thread drops events rather than delaying op admission.
Targets are vetted. Loopback, private, carrier-NAT, link-local
(including 169.254.169.254), unique-local and unspecified addresses are
refused unless the line ends in allow-private. It connects to the vetted
address and follows no redirects.
Structs§
- Hooks
- Handle held by the platform. Cloneable so the writer thread’s copy costs nothing; the delivery thread lives as long as the process.
- RefEvent
- A ref that moved, as the writer thread saw it.
Constants§
- QUEUE_
CAPACITY - Events held in memory while the delivery thread works. Small on purpose: a full queue is a receiver problem the operator should see in the delivery log, not a backlog the node quietly grows a heap for.