Skip to main content

Module hooks

Module hooks 

Source
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-private

Patterns 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.