forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
6.0 KiB

Webhooks (contract between workers)

The global rules from the earlier interface docs apply:

  • No JS and no inline styles; no unwrap in non-test code.
  • Tests use tempfile::TempDir.
  • Never put cargo target dirs or big files in /tmp.
  • Every new crate is added at its latest version with cargo add and recorded in the root [workspace.dependencies].

Model

  • A repo webhook has:
    • a URL (https, or http only when the config allows it);
    • a secret, encrypted at rest with the instance key the same way TOTP secrets are;
    • a content type (always application/json);
    • an event list: push, pull_request, issues, issue_comment, pull_request_review, create, delete, ping;
    • an active flag.
  • Managing hooks needs Admin on the repo.
  • Deliveries are rows in the DB:
    • the request: headers and body. The headers are queued without X-Forge-Delivery or the signature; after each attempt the stored headers are replaced with the ones actually sent;
    • the response: status, the first 64 KiB of the body, and timing;
    • attempt, a state (pending | ok | failed | gave_up), next_attempt_at, and a GUID.
  • Retries use backoff 1m, 5m, 30m, 2h, 6h, then gave_up. "Redeliver" in the UI creates a new delivery with the same payload.
  • Headers:
    • X-Forge-Event: <event>, X-Forge-Delivery: <guid>;
    • X-Forge-Signature-256: sha256=<hex HMAC-SHA256(secret, raw body)>;
    • User-Agent: forge-hookshot/<version>.
  • Payloads are our own JSON. We deliberately don't copy the GitHub or Gitea schemas, but a similar shape is fine:
    • repository {id, owner, name, full_name, private, html_url, clone_url}, sender {id, username};
    • push: ref, before, after, commits[{id, message, author{name,email}, timestamp, url}] (max 20), total_commits, compare_url;
    • pull_request: action (opened|closed|reopened|merged|synchronize|edited|labeled|unlabeled|assigned|unassigned|milestoned|demilestoned), number, pull_request {title, body, state, merged, base, head, html_url}; the label, assignee and milestone actions add label {name, color}, assignee {username} or milestone {title} at the top level, as the issues event does;
    • issues: action (opened|closed|reopened|edited|labeled|unlabeled|assigned|unassigned|milestoned|demilestoned), issue {number, title, body, state, html_url}, plus label {name, color}, assignee {username} or milestone {title} for those actions;
    • issue_comment: action (created|edited|deleted), issue {…}, comment {id, body, html_url};
    • pull_request_review: action submitted, review {id, state, body}, pull_request {…}.

SSRF guard (mandatory)

  • Resolve the host once, check every resolved address, then connect to exactly that checked address.
    • In reqwest, use a custom dns::Resolve that filters addresses, so a DNS rebind can't bypass the check.
  • Reject: loopback, private (RFC1918, fc00::/7), link-local (169.254/16, fe80::/10), CGNAT 100.64/10, unspecified, multicast, broadcast, documentation ranges, IPv4-mapped IPv6 of any of these, and 0.0.0.0/8.
  • Config [webhooks] allowed_hosts = [] (exact hostnames or CIDRs) whitelists internal targets; the default is empty.
  • No redirects. Timeouts: 10s connect, 20s total. Response bodies are read up to 64 KiB.
  • The URL must have no userinfo, and its port must be 1–65535.
  • allow_http = false by default.

Delivery worker

  • A tokio task started at boot. Each loop it claims due deliveries (state = 'pending' AND next_attempt_at <= now), sends them with bounded concurrency (4), and records the result.
  • A 2xx response means ok.
  • It is woken by a Notify when a new delivery is queued, so the first attempt is immediate; otherwise it polls every 30s.
  • Deliveries older than 14 days are purged daily by the same task.

Integration points

  • Enqueue through the sender-aware helpers in crates/forge-web/src/webhooks/emit.rs. Each one builds its event's payload, looks up the sender, and hands off to the generic emit(state, repo, sender, event, action, payload). That function finds the active hooks subscribed to the event and inserts a delivery for each. Nothing here fails the caller; errors are logged.
    • Sender says who made the change. lookup_sender(state, actor_id) builds one; an empty id means a system change and is sent as "sender": null.
    • emit_ref_update(…): push, plus create/delete for branch and tag creation and deletion. Only refs/heads/* and refs/tags/* produce events.
    • emit_pull(…): pull_request (for example, on PR create in pages/compare.rs).
    • emit_item_action(…): pull_request or issues action events, reloaded from the store. Used for close, reopen, merge and synchronize.
    • emit_issue(…): issues create (pages/issues_list.rs).
    • emit_label_changes(…) and emit_assignee_changes(…): one labeled/unlabeled or assigned/unassigned event per changed label (matched by name) or username. pull picks the pull_request event for a pull request and issues for an issue.
    • emit_milestone_change(…): demilestoned for the milestone before and milestoned for the one after, given their titles; nothing when they match. Same pull choice.
    • emit_comment(…): issue_comment create, edit and delete.
    • emit_review(…): pull_request_review submit.
  • Call sites: post_update.rs, pulls_sync.rs, pages/{compare,issue_view,issue_parts,issue_meta,issues_list,pulls_merge,pull_reviews}.rs. The label, assignee and milestone saves in pages/issue_meta.rs run for issues and pull requests alike.
  • A "ping" event is sent by emit_ping(state, hook) when a hook is created and from the "Test" button. It ignores the hook's subscriptions.

UI (repo settings, a "Webhooks" section)

  • List hooks with their last delivery status.
  • Create and edit form: URL, secret (write-only; leave it blank to keep the current one), event checkboxes, active.
  • Delete hooks.
  • Hook detail page: the last 50 deliveries, each expandable with <details> to show the request headers and body and the response; a Redeliver button and a Test (ping) button.