radioactive-labs/angarium

GitHub: radioactive-labs/angarium

为 Rails 应用提供生产级出站 Webhook 功能的引擎,涵盖签名、重试、密钥轮换及投递日志。

Stars: 7 | Forks: 0

# Angarium [![Gem Version](https://img.shields.io/gem/v/angarium)](https://rubygems.org/gems/angarium) [![CI](https://static.pigsec.cn/wp-content/uploads/repos/cas/ad/ad5834178f7599af9fdda11629d49cae07f2997beec49821b2920eff5bfd50e7.svg)](https://github.com/radioactive-labs/angarium/actions/workflows/ci.yml) [![Standard Webhooks](https://img.shields.io/badge/Standard%20Webhooks-compliant-3068b7)](https://www.standardwebhooks.com) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](MIT-LICENSE) **Everything your hand-rolled webhook job is missing**: HMAC signing, retries with backoff, zero-downtime secret rotation, SSRF protection, and a queryable log of every delivery attempt. The moment "just POST from a background job" ships to production, the gaps start showing: your customers need signatures they can verify, failed deliveries need to back off and retry for hours, secrets need to rotate without downtime, an endpoint URL shouldn't be able to reach your internal network, and sooner or later someone asks "did we actually send it?". Angarium is a Rails engine that handles all of it, and signs to the [Standard Webhooks](https://www.standardwebhooks.com) spec, so your receivers verify with off-the-shelf libraries in any language and you never write verification docs of your own. That conformance is enforced in CI: any drift from the spec fails the build. Headless by design: models, jobs, and an optional [JSON API](#http-api). Works with any ActiveJob backend on Rails 7.1+. ### 30-second tour Any model can own endpoints (an account, team, or user): class Account < ApplicationRecord has_many :webhook_endpoints, as: :owner, class_name: "Angarium::Endpoint" end # Register an endpoint; the signing secret is generated for you account.webhook_endpoints.create!( name: "Production", url: "https://example.com/webhooks", subscribed_events: ["invoice.*", "user.created"] # exact, "prefix.*", or "*" ) # Fan an event out to every subscribed endpoint Angarium.dispatch("invoice.paid", { id: 123, total: 4200 }, owner: account) Angarium handles the rest: signing, retries with backoff, `Retry-After`, dedup-friendly delivery IDs, SSRF checks, and a full attempt log. See [Delivery guarantees](#delivery-guarantees) for the specifics receivers care about. ## Installation Add to your Gemfile: gem "angarium" Then: bundle install bin/rails g angarium:install bin/rails db:migrate `angarium:install` writes the initializer and installs Angarium's migrations in one step. For a separate database, pass `--database=NAME` (see [Multiple databases](#multiple-databases)). ### Active Record Encryption Angarium encrypts each endpoint's `signing_secret` and `custom_headers` at rest, so it needs Active Record Encryption keys. If you haven't set them up, it's one command: bin/rails db:encryption:init Add the generated keys to your credentials (`config/credentials.yml.enc`) or set `config.active_record.encryption.{primary_key,deterministic_key,key_derivation_salt}`. See the [Rails guide on Active Record Encryption](https://guides.rubyonrails.org/active_record_encryption.html). The migration above creates plain columns, so you can run it before or after setting up keys: encryption applies when rows are written, not when the table is created. ## Dispatching events `Angarium.dispatch` fans a single event out to every enabled, subscribed endpoint, creating one delivery each and one ActiveJob per delivery: Angarium.dispatch("invoice.paid", { id: 123, total: 4200 }, owner: account) Each request is delivered as a JSON envelope: { "id": 42, "event": "invoice.paid", "created_at": "2026-07-04T12:00:00Z", "data": { "id": 123, "total": 4200 } } ## Verifying signatures (receiver side) **Angarium signs webhooks using the [Standard Webhooks](https://www.standardwebhooks.com) specification**, so receivers can verify them with the official [`standardwebhooks` libraries](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries) in any language (Ruby, Python, JavaScript, Go, Rust, PHP, Java, and more), with no Angarium-specific code required. Conformance is enforced in CI: signed requests are verified with the official `standardwebhooks` Ruby library, so any drift from the spec fails the build. Every request carries three headers: | Header | Value | | --- | --- | | `webhook-id` | Unique, retry-stable message id: the delivery's `id`, the **same value** as the envelope's `id`. It is unique per *delivery*, not per event, so the same event delivered to two endpoints has two different ids. | | `webhook-timestamp` | Unix seconds when the request was signed. | | `webhook-signature` | Space-delimited list of `v1,` tokens (one per active signing secret). | The signature is `HMAC-SHA256(secret_key, "{webhook-id}.{webhook-timestamp}.{body}")`, base64-encoded, where `secret_key` is the base64-decoded portion of the `whsec_`-prefixed `signing_secret`. You can verify with any Standard Webhooks library, or with Angarium's own helper. Pass a Rails `request:` and it reads the raw body and `webhook-*` headers for you: Angarium::Signature.verify(request: request, secret: endpoint.signing_secret) # => true / false Or pass the fields explicitly: Angarium::Signature.verify( payload: request.raw_post, id: request.headers["webhook-id"], timestamp: request.headers["webhook-timestamp"], signature: request.headers["webhook-signature"], secret: endpoint.signing_secret ) `verify` also enforces a timestamp tolerance (default 300s) to resist replay. The secret (a `whsec_...` string) is stored encrypted at rest and is only decrypted in memory when signing; `endpoint.signing_secret` returns the plaintext, so deliver it to receivers over a secure channel. ### Rotating a signing secret (zero-downtime) Rotate a secret with `endpoint.rotate_secret!` (returns the new plaintext). During a grace window (`config.signing_secret_grace_period`, default `24.hours`) every delivery is signed with **both** the new and the previous secret. The `webhook-signature` header carries multiple space-delimited `v1,` tokens: webhook-signature: v1, v1, Verification succeeds if the payload matches **any** token in the header (the Standard Webhooks libraries already do this), so a receiver still holding the old secret keeps validating while you roll it over, and one holding the new secret validates immediately. Once the grace period elapses, deliveries are signed with the new secret only. This lets receivers update their copy of the secret with zero downtime and no rejected deliveries. ### Per-endpoint custom headers Attach static headers (e.g. an `Authorization` bearer token the receiver expects) to every request from an endpoint: endpoint.update!(custom_headers: { "Authorization" => "Bearer abc123" }) `custom_headers` must be a hash of string keys and values. Because it commonly carries a receiver credential (like the bearer token above), it's **encrypted at rest** with Active Record Encryption, same as the signing secret. The `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers always win, so a custom header can never override or spoof them. In the same spirit, reserved and transport headers (`webhook-id`, `webhook-timestamp`, `webhook-signature`, `host`, `content-length`, `content-type`, `transfer-encoding`, `connection`) are rejected at validation (case-insensitively) and can't be overridden. ## Retries Failed deliveries (non-2xx or connection errors) are retried on the schedule in `config.retry_schedule`. The default follows the [Standard Webhooks](https://www.standardwebhooks.com) recommendation of a multi-day schedule with exponential backoff and jitter: our instantiation is twelve retries spanning ~10 days (`5s, 5m, 30m, 2h, 5h, 10h, 14h, 20h, 24h, 36h, 48h, 72h`, after an immediate first delivery). Every attempt is recorded as an `Angarium::DeliveryAttempt`. After the schedule is exhausted the delivery is marked `exhausted`. Each `DeliveryAttempt` stores the response body, truncated to `config.max_response_body_bytes` bytes (default `65_536`; set `nil` to store the full body). ### Status-code handling Angarium follows the Standard Webhooks receiver-etiquette guidance: | Response | Handling | | --- | --- | | `2xx` | Success. | | `410 Gone` | The receiver wants no more webhooks. The **endpoint status becomes `gone`** and the delivery is marked `gone`, with no retries. | | `429`, `502`, `504` | Retryable failure, retried with backoff, honoring `Retry-After` when present (the recommended way to throttle). | | `3xx` and everything else | Retryable failure. Redirects are **not** followed (following them loads both sides); update the endpoint URL instead. | ### Backoff jitter Each retry delay gets a small amount of additive positive jitter (`config.retry_jitter`, default `0.15` → up to +15%) so many deliveries failing at once don't retry in lockstep and stampede the receiver. ### Retry-After When a failed response carries a `Retry-After` header (seconds or an HTTP-date), Angarium honors it, but only when it asks for a **longer** wait than the scheduled backoff. It takes the later of the two, so a receiver's `Retry-After` can *delay* the next attempt but never pull it *earlier* than our schedule. This keeps a malicious or misconfigured receiver from using a tiny `Retry-After` to defeat our backoff and make us retry aggressively. The honored value is capped at `config.max_retry_after` (default `3600` seconds), and the whole behavior can be disabled with `config.respect_retry_after = false`. One interaction to note with the default schedule: because the cap is one hour, `Retry-After` can only ever extend the wait during the early steps (up to the `30m` step). Once backoff reaches `2h` and beyond, a capped `Retry-After` is always shorter than the scheduled delay, so it has no effect. If you need receivers to push back harder late in the schedule, raise `config.max_retry_after`. ### Manual redelivery Re-send any delivery, including an exhausted one, with: delivery.redeliver! This resets the retry cycle (`state` → `pending`, `attempt_count` → 0) and enqueues a fresh `DeliverJob`, while keeping the prior `DeliveryAttempt` history. ### Endpoint status Every endpoint has a lifecycle `status` (only `enabled` endpoints receive deliveries): | Status | Meaning | Resumable? | | --- | --- | --- | | `unverified` | Created but not yet proven (opt-in). Receives no dispatched deliveries. | verified by a successful `ping!`, or `endpoint.verify!` | | `enabled` | Delivering normally. | n/a | | `paused` | Turned off manually (`endpoint.pause!`). | `endpoint.enable!` | | `disabled` | Auto-disabled after too many consecutive failures. | `endpoint.enable!` | | `gone` | The receiver returned `410 Gone`. Treat as terminal. | `endpoint.enable!` (explicit override) | Every transition stamps `status_changed_at`. `endpoint.enable!` also clears the failure counter. Scope enabled endpoints with `Angarium::Endpoint.enabled`. The `enabled` filter also applies to deliveries **already queued** when the status changes (a retry cycle that trips auto-disable, a manual `pause!`, or a sibling delivery's `410`). Each delivery re-checks the endpoint before it attempts: `paused` **holds** the delivery (it stays `pending`, consumes no attempt, and `enable!` re-enqueues it), while `disabled`/`gone` **cancels** it (a terminal `canceled` state, logged with the reason). Recover a canceled delivery after re-enabling with `delivery.redeliver!`. (Nothing transitions back to `unverified`, so dispatched and retried deliveries never meet one; a non-forced delivery to an unverified endpoint would `cancel` like `disabled`/`gone`, since only `enabled` passes the guard. `ping!` forces past it, which is how verification happens.) ### Verifying an endpoint Create an endpoint as `unverified` to make it prove itself before it receives any events. An unverified endpoint is excluded from dispatch, so no webhooks are sent until it is verified: endpoint = Angarium::Endpoint.create!(owner: current_user, url: params[:url], subscribed_events: ["*"], status: :unverified) endpoint.ping! # forces past the status guard (force: true default), so it sends endpoint.reload.enabled? # => true once the ping is delivered (2xx) A successful delivery to an unverified endpoint (a `ping!` in practice, since dispatch skips unverified endpoints) verifies it: the status moves to `enabled` and the `on_endpoint_verified` callback fires. You can also verify manually with `endpoint.verify!`. Verification only promotes `unverified` endpoints; a `disabled` or `gone` endpoint is revived with `enable!`, never silently by a ping. Through the JSON API, set the `create_unverified?` policy predicate to `true` to create endpoints `unverified`, then `POST /endpoints/:id/ping` (which verifies on a successful delivery) or `POST /endpoints/:id/verify` to promote one manually. ### Auto-disabling failing endpoints Set `config.auto_disable_endpoint_after` to a number of **consecutive** failed deliveries after which an endpoint is automatically moved to `disabled`. `endpoint.consecutive_failures` tracks the running count and resets to `0` on the next successful delivery. Left `nil` (the default), endpoints are never auto-disabled. (A `410 Gone` response moves the endpoint to `gone` immediately, regardless of this setting.) ### Notification callbacks When delivery fails for good, the Standard Webhooks guidance is to notify the consumer out of band (email, Slack, PagerDuty). Angarium is headless, so it hands you the events and lets you do the notifying via config callbacks. Two fire when delivery fails for good; a third, `on_endpoint_verified`, fires when an `unverified` endpoint passes its first delivery (see [Verifying an endpoint](#verifying-an-endpoint)): Angarium.configure do |config| # A delivery has exhausted its whole retry schedule. config.on_delivery_exhausted = ->(delivery) do AdminMailer.webhook_failed(delivery).deliver_later end # An endpoint was deactivated. reason is :consecutive_failures (status becomes # `disabled`) or :gone (HTTP 410, status becomes `gone`). config.on_endpoint_deactivated = ->(endpoint, reason) do AdminMailer.endpoint_deactivated(endpoint, reason).deliver_later end end All callbacks are optional. A callback that raises is logged and swallowed, so a broken notifier never breaks delivery. ### Recovering interrupted deliveries If a worker dies mid-delivery (crash, deploy, OOM) after a delivery is marked `delivering` but before the attempt is recorded or rescheduled, that delivery would otherwise be stranded (the job only re-runs `pending` deliveries). Requeue these with a periodic reaper: Angarium::Delivery.reap_stalled # requeues deliveries stuck in `delivering` # or from cron/scheduler: bin/rails angarium:reap Anything `delivering` whose last attempt began more than `config.delivering_timeout` ago (default `15.minutes`) is presumed abandoned and reset to `pending`. Keep the timeout well above a single attempt's worst case (`open_timeout + http_timeout`) so a slow-but-alive worker isn't reaped; a redelivery is at-least-once-safe either way. Set it to `nil` to disable reaping. ### Pinging an endpoint Verify an endpoint end-to-end by delivering a synthetic `ping` event (subscription matching is bypassed, so a ping is always sent). Returns the `Angarium::Delivery`, so you can reload it to inspect the outcome: delivery = endpoint.ping! # optionally: endpoint.ping!(message: "hello") delivery.reload.succeeded? # => true once delivered A ping always sends, even to an `unverified`, `paused`, `disabled`, or `gone` endpoint, so you can test one before enabling it. Pass `endpoint.ping!(force: false)` to respect the endpoint status guard instead (held while `paused`, canceled while `unverified`/`disabled`/`gone`). The lower-level `delivery.deliver!(force: true)` and `Delivery#redeliver!(force: true)` override the guard the same way, for a single attempt; any retry that attempt schedules follows the normal status rules again. ### At-least-once delivery Delivery is **at-least-once**: a webhook may arrive more than once, from a retry after a receiver processed the request but the response was lost, or a rare duplicate job enqueue. **Make your receivers idempotent**: dedupe on the envelope's `id` (stable across every attempt of the same delivery) and treat a repeat as a no-op. ## Data retention Every delivery attempt stores the receiver's response body (capped at `config.max_response_body_bytes`, 64KB by default). Because `angarium_delivery_attempts` grows with delivery volume × retries, a busy app talking to a flapping receiver can accumulate rows quickly. You have three options to keep it bounded: # 1. Set a retention window and prune on a schedule (cron / your scheduler): Angarium.config.delivery_attempt_retention = 90.days # then run periodically: bin/rails angarium:prune # 2. Or prune inline, wherever you like: Angarium::DeliveryAttempt.prune(older_than: 90.days) # 3. Or store less per attempt by lowering the response-body cap: Angarium.config.max_response_body_bytes = 4_096 ## HTTP API Angarium ships an optional **JSON API** for managing endpoints and browsing deliveries. It has no HTML views or UI of its own. Mount the engine wherever you like: # config/routes.rb mount Angarium::Engine => "/webhooks" ### Authentication The API has no auth of its own; it uses yours. Its controllers inherit from `config.parent_controller` (default `"ApplicationController"`), so every `before_action` your app already runs (Devise, Rodauth, etc.) applies here too. Angarium reads the signed-in user via your current-user convention: config.parent_controller = "ApplicationController" # or your API base controller config.current_user = ->(controller) { controller.current_user } Requests without a resolved current user get a `401`. ### Authorization Authorization lives in one place: a **policy** class, `config.policy_class` (default `Angarium::Api::Policy`). Generate one to start from (it creates the class and points `config.policy_class` at it in your initializer): bin/rails g angarium:policy # app/policies/webhook_endpoint_policy.rb Angarium instantiates the policy per request with the controller and (for member actions) the target record, and runs it in the controller's context, so `current_user`, `params`, `controller`, and `record` are all available. Its methods: | Method | Default | Purpose | | --- | --- | --- | | `scope(relation)` | `relation.where(owner: current_user)` | Narrows a base relation to the endpoints this user may see and act on. Reads, finds, and delivery/attempt access all go through it. | | `owner` | `current_user` | The owner assigned to a newly-created endpoint. Set before `create?` runs, so you can gate the target owner there via `record.owner`. | | `create_unverified?` | `false` | Whether endpoints created through the API start `unverified` (no deliveries until a successful ping verifies them) instead of live. | | `permit_allow_private_network?` | `false` | Whether `allow_private_network` (relax the private-IP block) is API-writable. Dangerous; trusted operators only. | | `permit_allowed_networks?` | `false` | Whether `allowed_networks` (a restrictive CIDR allowlist) is API-writable. | | `expose_owner?` | `false` | Whether serialized endpoints include `owner_type`/`owner_id`. Off by default (the owner is the tenancy boundary, resolved server-side). Turn on for a cross-owner console that must tell endpoints apart. | | `index?` `show?` `create?` `update?` `destroy?` | `true` | Whether each action is allowed. | | `rotate_secret?` `pause?` `enable?` `verify?` `ping?` `redeliver?` | `update?` | Member actions; default to the `update?` capability. | Override only what you need; the defaults are single-owner (you see and manage your own endpoints). A denied action returns `403`; anything outside `scope` is a `404`. class WebhookEndpointPolicy < Angarium::Api::Policy # Multi-tenant visibility: compose on top of the relation you're given. def scope(relation) = relation.where(owner_id: current_user.account.owner_ids) # A console spanning owners needs owner_type/owner_id on each endpoint to tell # them apart. Pair this with a scope (above) that actually spans owners — # expose_owner? only affects serialization, not what the caller can see. def expose_owner? = current_user.admin? # Admins may create for any owner in their account (via an owner_id param); # everyone else creates for themselves. def owner id = params[:owner_id] id && current_user.admin? ? current_user.account.owners.find(id) : current_user end # Restrict individual actions (members default to update?, which defaults true). def update? = current_user.can?(:manage_webhooks) def destroy? = current_user.admin? end config.policy_class = "WebhookEndpointPolicy" ### Objects Responses wrap these objects. `signing_secret` and `custom_headers` are never included (see the note below): // endpoint { "id": 1, "name": "Production", "url": "https://example.com/webhooks", "status": "enabled", "subscribed_events": ["invoice.*"], "allow_private_network": false, "allowed_networks": [], "consecutive_failures": 0, "status_changed_at": null, "created_at": "2026-07-04T12:00:00Z", "updated_at": "2026-07-04T12:00:00Z" } // delivery { "id": 42, "endpoint_id": 1, "event": "invoice.paid", "state": "succeeded", "attempt_count": 1, "next_attempt_at": null, "last_attempt_at": "2026-07-04T12:00:01Z", "created_at": "2026-07-04T12:00:00Z", "updated_at": "2026-07-04T12:00:01Z" } // attempt { "id": 7, "delivery_id": 42, "response_code": 200, "response_body": "ok", "error": null, "duration": 0.12, "created_at": "2026-07-04T12:00:01Z" } // pagination (on every list response) { "limit": 50, "offset": 0, "count": 20, "total": 137 } ### Routes | Method & path | Request body | Response | | --- | --- | --- | | `GET /endpoints?limit=&offset=` | none | `200 { "endpoints": [endpoint, ...], "pagination": pagination }` | | `POST /endpoints` | `{ "endpoint": { "name", "url", "subscribed_events": [...] } }` | `201 { "endpoint": {...endpoint, "signing_secret": "whsec_..."} }` | | `GET /endpoints/:id` | none | `200 { "endpoint": endpoint }` | | `PATCH /endpoints/:id` | `{ "endpoint": { "name": "New name" } }` | `200 { "endpoint": endpoint }` | | `DELETE /endpoints/:id` | none | `204` (no body) | | `POST /endpoints/:id/rotate_secret` | none | `200 { "endpoint": endpoint, "signing_secret": "whsec_..." }` | | `POST /endpoints/:id/pause`, `/enable`, `/verify` | none | `200 { "endpoint": endpoint }` | | `POST /endpoints/:id/ping` | none | `202 { "delivery": delivery }` | | `GET /endpoints/:id/deliveries?limit=&offset=` | none | `200 { "deliveries": [delivery, ...], "pagination": pagination }` | | `GET /deliveries/:id` | none | `200 { "delivery": delivery }` | | `POST /deliveries/:id/redeliver` | none | `202 { "delivery": delivery }` | | `GET /deliveries/:id/attempts?limit=&offset=` | none | `200 { "attempts": [attempt, ...], "pagination": pagination }` | - **Secrets are never echoed.** `signing_secret` is returned only by `create` and `rotate_secret`; `custom_headers` (which may hold a credential) is write-only. - **Owner is opt-in.** The endpoint object omits `owner_type`/`owner_id` unless the policy's `expose_owner?` returns true; `owner_id` is a string (a polymorphic owner may have any primary-key type). - **Pagination.** List endpoints take `?limit=` (default 50, max 200) and `?offset=`, and each list response carries a `pagination` object (`limit`, `offset`, `count` in this page, `total` overall); there are more when `offset + count < total`. - **Errors are JSON.** `422 { "error": "validation failed", "details": [...] }` for an invalid body, plus `401` (unauthenticated), `403` (policy denied), and `404` (out of scope). ### Permitted attributes `POST`/`PATCH /endpoints` accept these keys under `endpoint`; anything else is ignored (strong parameters). The exception is the two privileged controls below: when your policy hasn't permitted them, they are **rejected with a `422`, not silently ignored**, so an attempt to enable one never looks like it succeeded. | Attribute | Type | Notes | | --- | --- | --- | | `name` | string | | | `url` | string | the receiver URL (SSRF-validated on every change) | | `subscribed_events` | array of strings | event patterns: exact, `"prefix.*"`, or `"*"` | | `custom_headers` | object | write-only; sent with each delivery, never echoed back | | `allow_private_network` | boolean | privileged; **not writable by default**, see below | | `allowed_networks` | array of CIDRs | privileged; **not writable by default**, see below | `status` is not writable (use the `pause` / `enable` actions), and the owner of a created endpoint comes from the policy's `owner`, not the request. `allow_private_network` and `allowed_networks` are **independent** SSRF controls, each gated by its own policy predicate (default off), because they do opposite things: - `allow_private_network` **relaxes** protection: it lets an endpoint deliver to private and loopback addresses. This is the dangerous one; an end user who can set it can point a webhook at your internal network. - `allowed_networks` **restricts** delivery to a CIDR allowlist (both it and the private-IP denylist must still pass), so it's safe to expose more widely. You can always set them from trusted code, regardless of the API: endpoint.update!(allow_private_network: true, allowed_networks: ["10.0.5.0/24"]) To permit either through the API, override its predicate. Being independent, you can allow the safe one without the dangerous one: class WebhookEndpointPolicy < Angarium::Api::Policy def permit_allow_private_network? = current_user.operator? # dangerous: operators only def permit_allowed_networks? = true # restrictive: safe to expose end A request that tries to **change** a control it isn't permitted to set gets a `422` naming the attribute, rather than the change being silently dropped, so a misconfigured client fails loudly instead of appearing to work. (Sending a control's current value is a no-op.) ## Security (SSRF protection) Because endpoint URLs are user-supplied, Angarium guards against Server-Side Request Forgery. Three controls, validated when an endpoint is created or when its `url`, `allow_private_network`, or `allowed_networks` change, and re-checked at delivery time: - **`config.block_private_ips`** (default `true`) blocks delivery to private, loopback, and link-local addresses (e.g. `127.0.0.1`, `10.0.0.0/8`, `169.254.169.254`), including IPv4-mapped IPv6 forms (e.g. `::ffff:127.0.0.1`) and the unspecified address (`0.0.0.0` / `::`). - **`endpoint.allow_private_network`** (default `false`) is the per-endpoint opt-in required to deliver to a private address. An allowlist entry alone does **not** unlock a private address. - **`endpoint.allowed_networks`** (CIDR array), when set, restricts this endpoint's deliveries to those CIDRs. It only narrows; to allow a private range you must also set `allow_private_network`. **Connect-time IP pinning:** the delivery-time check re-resolves the host, rejects disallowed addresses, and then pins the connection to exactly the validated IP(s), so HTTPX does not re-resolve or connect elsewhere, while TLS SNI and certificate verification still use the original hostname. This closes the DNS-rebinding window between resolution and connection. Angarium's own resolver is the single source of truth: if it can't resolve a host, the delivery fails (retryable) rather than falling back to an unvalidated HTTPX resolution, so there is no unpinned path. The only cost is that hosts resolvable *only* via non-DNS mechanisms Angarium's resolver doesn't use (e.g. mDNS `.local`) won't be delivered to, which is not a concern for real webhook endpoints. HTTPX does not follow redirects, so redirect-based bypasses are already closed. Found a gap in any of this? Report it privately: see [SECURITY.md](SECURITY.md) for the disclosure process (GitHub's private advisory workflow) and the surfaces we hold ourselves to. ## Delivery guarantees What Angarium actually promises about delivery, so a receiver knows what it can rely on: - **At-least-once, not exactly-once.** A delivery is retried until it succeeds, the schedule is exhausted, or its endpoint is deactivated mid-cycle (the delivery is `canceled`, kept, and recoverable with `redeliver!`), so the same event can arrive more than once (for example, a retry after your `200` was lost in transit). Every request carries a `webhook-id` that stays constant across a delivery's retries: dedupe on it and treat repeats as no-ops. - **No ordering.** Deliveries are independent jobs and each retries on its own schedule, so events can arrive out of order. If order matters, put a sequence number or timestamp in the payload and sort on the receiver. - **Durable; nothing is silently dropped.** Every attempt is persisted as an `Angarium::DeliveryAttempt` (response code, body, error, duration), and a delivery that exhausts its retries is kept in the `exhausted` state, not deleted. You can always tell whether an event was delivered, and re-send with `delivery.redeliver!`. - **Authenticated per request.** Every request is signed and timestamped per the [Standard Webhooks](https://www.standardwebhooks.com) spec (HMAC-SHA256 over `{id}.{timestamp}.{body}`, 5-minute tolerance), so a receiver can confirm it came from you and reject replays, independent of transport. Secret rotation, SSRF protection, and encryption harden delivery but aren't delivery-semantics guarantees; they have their own sections above. ## Instrumentation Angarium emits `ActiveSupport::Notifications` events so you can feed delivery metrics and traces into your own backend (StatsD, Prometheus, OpenTelemetry, or structured logs). This is the metrics leg beside the notification callbacks (alerting) and the `DeliveryAttempt` rows (audit); it is off unless you subscribe. **`deliver.angarium`** fires once per delivery attempt: | Key | Notes | | --- | --- | | `delivery_id`, `endpoint_id` | ids | | `event` | the event name being delivered | | `outcome` | `delivered` \| `failed` \| `gone` \| `held` \| `canceled` \| `blocked` \| `unresolvable` | | `attempt` | attempt number (absent for `held`/`canceled`) | | `code` | HTTP status, when a response was received | | `http_duration` | wire time in seconds, when a request went out | | `error` | failure reason string, on `blocked`/`unresolvable` and a transport-error `failed` (a non-2xx `failed` carries its status in `code` instead) | | `force` | whether the status guard was bypassed | **`dispatch.angarium`** fires once per `Angarium.dispatch`: `event`, `event_id`, and `deliveries` (fan-out count). Payloads carry ids, codes, and timings only, never the signing secret or the request/response body, so they are safe to ship to third-party backends. ActiveSupport::Notifications.subscribe("deliver.angarium") do |*, payload| StatsD.increment("webhooks.delivery.#{payload[:outcome]}") StatsD.histogram("webhooks.delivery.ms", payload[:http_duration] * 1000) if payload[:http_duration] end ## Configuration Run `bin/rails g angarium:install` to generate `config/initializers/angarium.rb`, which documents every option inline. The delivery and retry settings: | Option | Default | What it controls | | --- | --- | --- | | `job_queue` | `:default` | ActiveJob queue used for deliveries. | | `http_timeout` | `10` | HTTP read timeout (seconds) per attempt. | | `open_timeout` | `5` | TCP connect timeout (seconds) per attempt. | | `user_agent` | `"Angarium/"` | User-Agent header on each delivery. | | `retry_schedule` | 12 delays over ~10 days | Backoff between retries; its length is the retry count. | | `retry_jitter` | `0.15` | Fraction of additive positive jitter on each backoff delay. | | `respect_retry_after` | `true` | Honor a receiver's `Retry-After` header (delay-only). | | `max_retry_after` | `3600` | Cap (seconds) on a honored `Retry-After`; `nil` is uncapped. | | `auto_disable_endpoint_after` | `nil` | Deactivate an endpoint after N consecutive failures; `nil` never does. | | `signing_secret_grace_period` | `24.hours` | How long a rotated endpoint's previous secret stays valid. | | `block_private_ips` | `true` | Reject endpoint URLs resolving to private/loopback addresses (SSRF). | | `max_response_body_bytes` | `65_536` | Truncate the stored response body; `nil` stores it whole. | | `delivery_attempt_retention` | `nil` | Age past which `angarium:prune` deletes attempts; `nil` keeps all. | | `delivering_timeout` | `15.minutes` | Age after which `angarium:reap` requeues a stuck `delivering` delivery. | | `primary_key_type` | `nil` | Primary key type for Angarium's tables (see below). | | `on_delivery_exhausted` | `nil` | Callback `->(delivery)` when a delivery exhausts its retries. | | `on_endpoint_deactivated` | `nil` | Callback `->(endpoint, reason)` when an endpoint is disabled or gone. | | `on_endpoint_verified` | `nil` | Callback `->(endpoint)` when an `unverified` endpoint is verified. | Mounting the JSON API adds `parent_controller`, `current_user`, and `policy_class` (see [Authentication](#authentication) and [Authorization](#authorization)). Multi-database setups add `database` and `connects_to` (see [Multiple databases](#multiple-databases)). ### Primary keys Angarium's own tables (`angarium_endpoints`, `angarium_events`, `angarium_deliveries`, `angarium_delivery_attempts`) follow `config.primary_key_type`: leave it `nil` (the default) to inherit your app's own default (its `config.generators.active_record.primary_key_type`, or bigint if that's unset), or set it explicitly (e.g. `:uuid`) to force a type regardless of the app's default. `owner_id` on `angarium_endpoints` is always a string column, since a polymorphic owner can point at models with different primary key types (an integer-keyed `User` and a UUID-keyed `Account` in the same app). This works transparently with any owner primary key (integer, UUID, or a mix) without any configuration. ### Multiple databases To keep Angarium's tables out of your primary database, install with a `--database` flag. It records `config.database` and puts the migrations in that database's own path in one step: bin/rails g angarium:install --database=angarium That writes `config.database = :angarium` to your initializer and installs the engine's migrations into `db/angarium_migrate` (not the primary `db/migrate`), so each database keeps its own `schema_migrations`. All four Angarium models inherit from one abstract `Angarium::ApplicationRecord`, so `config.database` points the whole engine at that connection. Add the database to `database.yml`: # config/database.yml production: primary: <<: *default database: my_app_production angarium: <<: *default database: my_app_angarium migrations_paths: db/angarium_migrate The generator has already written the matching initializer: # config/initializers/angarium.rb Angarium.configure do |c| c.database = :angarium end Then run `bin/rails db:migrate:angarium`. **After a gem upgrade**, pull in new migrations with `bin/rails g angarium:migrations` (no flag needed): it reads `config.database` and installs them into `db/angarium_migrate` for you. For custom roles or shards, set `config.connects_to` to a hash passed straight to Rails' [`connects_to`](https://guides.rubyonrails.org/active_record_multiple_databases.html) instead; it takes precedence over `config.database` for the connection. Left unset (the default), Angarium stays on the app's primary connection. The owner association still works across databases (it's a polymorphic reference, not a foreign key), so your `User`/`Account` can live in the primary database while Angarium's tables live in their own. This is independent of where your ActiveJob backend (Solid Queue, etc.) stores its own tables. ## Why not just POST from a job? `HTTP.post(endpoint.url, body: payload)` in a background job works right up until it's in front of customers. Then the edge cases arrive one at a time, and each is a small project: - **Signatures.** Receivers won't (and shouldn't) trust an unsigned POST. Roll your own and you now own an HMAC scheme, a header format, and the verification docs your customers need in every language they use. Angarium signs to [Standard Webhooks](https://www.standardwebhooks.com), so they verify with an off-the-shelf library and you write none. - **Retries that don't stampede.** A receiver has a bad 30 minutes; a naive retry either gives up too early or hammers them in lockstep. Angarium retries on a backoff schedule (~10 days by default) with jitter, and honors `Retry-After`, but only to *delay* a retry, never to pull it earlier than your schedule. - **Duplicate suppression.** Retries mean the same event lands more than once. Without a stable ID that's invariant across a delivery's retries, receivers can't dedupe. Angarium gives every delivery exactly that. - **SSRF.** An endpoint URL is user input. POST to it blindly and a customer can point it at `169.254.169.254` or `10.0.0.1` and read your internal network. Angarium blocks private ranges, pins the connection to the validated IP, and fails closed on hosts it can't resolve. - **Secret rotation.** Rotating a signing secret through a single POST path means a window where the old or new secret gets rejected. Angarium signs with both during a grace window, so receivers roll over without dropping a webhook. - **Stranded deliveries.** A worker crashes mid-send and the delivery is stuck half-done, with no retry and no record. Angarium reaps deliveries stranded in `delivering` and re-queues them. - **"Did we actually send it?"** When support asks, you need the answer. Angarium persists every attempt (response code, body, error, duration) and never silently drops a delivery. None of these is hard on its own. Building and maintaining all of them together, as Rails and your receivers change, is the work Angarium takes off your plate. ## How Angarium compares There are several ways to send outbound webhooks from a Rails app. Angarium aims to be the maintained middle ground between rolling your own delivery system and adopting external webhook infrastructure. | | Angarium | ActionHook | bullet_train-outgoing_webhooks | active_webhook | Svix / Hookdeck Outpost | |---|---|---|---|---|---| | Type | Rails engine (headless + JSON API) | Ruby delivery library | Rails engine (Bullet Train) | Ruby library | Hosted / self-hosted service | | [Persisted endpoints & subscriptions](#30-second-tour) | ✅ per-endpoint event subscriptions | ❌ bring your own model | ✅ (tied to BT teams) | ✅ topics | ✅ | | [Endpoint-management JSON API](#http-api) | ✅ auth + policy | ❌ | ❌ | ❌ | ✅ | | [HMAC request signing](#verifying-signatures-receiver-side) | ✅ | ✅ (SHA256 fingerprint) | ✅ | ✅ | ✅ | | [Standard Webhooks](https://www.standardwebhooks.com) compliant | ✅ | ❌ | ❌ | ❌ | ✅ (Svix initiated the spec) | | [Automatic retries with backoff](#retries) | ✅ jitter + `Retry-After` | ❌ (delegates to your job runner) | ✅ | ✅ (via queue adapter) | ✅ | | [Manual redelivery](#manual-redelivery) | ✅ | ❌ | ✅ `deliver(force:)` | ❌ | ✅ | | [Auto-disable failing endpoints](#auto-disabling-failing-endpoints) | ✅ (opt-in) | ❌ | ✅ (opt-in) | ❌ | ✅ | | [SSRF protection](#security-ssrf-protection) | ✅ block + pin + fail-closed | ✅ private-IP blocking | ❌ | ❌ | ✅ | | [Signing secrets encrypted at rest](#active-record-encryption) | ✅ Active Record Encryption | n/a (you store secrets) | ❌ | ❌ | ✅ | | [Zero-downtime secret rotation](#rotating-a-signing-secret-zero-downtime) | ✅ dual-signing grace window | ❌ | ❌ | ❌ | ✅ | | Job backend | Any ActiveJob backend | n/a | ActiveJob | Multiple adapters | Managed workers | | Runs inside your app | ✅ | ✅ | ✅ | ✅ | ❌ separate service | | Framework requirements | Rails 7.1+ | Any Ruby | Bullet Train | Rails 5+ | Any (HTTP API) | All columns verified by reading each project's source: actionhook 1.0.2, active_webhook 1.0.0, bullet_train-outgoing_webhooks 1.45.1, as of July 2026; Svix / Hookdeck Outpost cells reflect their published docs. Corrections welcome via issue or PR. ### When to choose Angarium - You want customer-facing webhooks (endpoints, subscriptions, signing, retries) without standing up separate infrastructure like Svix or Outpost. - You want SSRF protection and encrypted signing secrets out of the box instead of remembering to build them. - You want receivers to verify with an off-the-shelf library, since Angarium is [Standard Webhooks](https://www.standardwebhooks.com) compliant. - You already run ActiveJob and don't want a Redis- or Sidekiq-specific dependency. ### When to choose something else - **You need massive multi-tenant scale, a customer-facing delivery portal, or fan-out to queues (SQS, Kafka, EventBridge):** use [Svix](https://www.svix.com) or [Hookdeck Outpost](https://github.com/hookdeck/outpost). They are dedicated infrastructure and will outgrow any in-app gem. - **You only need a hardened HTTP delivery primitive** and want to own the data model yourself: [ActionHook](https://github.com/smsohan/actionhook) is a solid low-level choice. - **You're building on Bullet Train:** use [bullet_train-outgoing_webhooks](https://rubygems.org/gems/bullet_train-outgoing_webhooks), which integrates with its team and account model. ## Development After cloning the repo, install dependencies and generate the per-Rails-version gemfiles: bundle install bundle exec appraisal install # writes gemfiles/*.gemfile for each Rails version Tests run through a `test/dummy` app against the supported Rails versions via [Appraisal](https://github.com/thoughtbot/appraisal) (there is no `rake test` task; the runner is `bin/rails test`): bundle exec appraisal bin/rails test # all Rails versions bundle exec appraisal rails-8.1 bin/rails test # a single version bin/rails test # just your default bundle bin/rails test test/lib/angarium/signature_test.rb # a single file Available appraisals: `rails-7.1`, `rails-7.2`, `rails-8.0`, `rails-8.1`. CI runs the same matrix across Ruby 3.2 and 3.3 (8 jobs). After changing a dependency or the `Appraisals` file, re-run `bundle exec appraisal install` and commit the updated `gemfiles/`. ### Linting and security CI also runs [Standard](https://github.com/standardrb/standard) and [Brakeman](https://brakemanscanner.org): bundle exec rake standard # lint (rake standard:fix to autocorrect) bundle exec brakeman -q -z # static security analysis ### Cutting a release Publishing runs from a laptop; CI only cuts the GitHub Release when the tag lands. Version math and the changelog come from [git-cliff](https://git-cliff.org) over your [Conventional Commits](https://www.conventionalcommits.org) (`brew install git-cliff`): rake release:prepare # bump version.rb + regenerate CHANGELOG.md, then STAGE (nothing committed) git diff --cached # review rake release:publish # commit, gem build + push, tag + push (idempotent/resumable) `rake release:prepare[1.2.3]` forces a version instead of computing it. The bare `rake release` is disabled in favor of this two-step flow. ## License Angarium is released under the [MIT License](MIT-LICENSE).
标签:ActiveJob, API集成, Ruby on Rails, Webhook, 内存转储, 力导向图, 可观测性, 批量扫描, 消息分发