angarium 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +25 -5
- data/SECURITY.md +10 -6
- data/app/controllers/angarium/api/base_controller.rb +34 -5
- data/app/controllers/angarium/api/endpoints_controller.rb +12 -2
- data/app/jobs/angarium/deliver_job.rb +5 -2
- data/app/models/angarium/delivery.rb +90 -20
- data/app/models/angarium/delivery_attempt.rb +22 -0
- data/app/models/angarium/endpoint.rb +55 -12
- data/app/models/angarium/event.rb +4 -0
- data/app/policies/angarium/api/policy.rb +10 -0
- data/db/angarium_migrate/20260704000001_create_angarium_endpoints.rb +12 -4
- data/db/angarium_migrate/20260704000002_create_angarium_events.rb +3 -1
- data/db/angarium_migrate/20260704000003_create_angarium_deliveries.rb +12 -1
- data/db/angarium_migrate/20260704000004_create_angarium_delivery_attempts.rb +7 -1
- data/lib/angarium/address_policy.rb +35 -2
- data/lib/angarium/client.rb +4 -0
- data/lib/angarium/configuration.rb +25 -1
- data/lib/angarium/dispatch.rb +7 -3
- data/lib/angarium/version.rb +1 -1
- data/lib/angarium.rb +4 -0
- data/lib/generators/angarium/policy/templates/policy.rb +4 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 29562789c5729a772541a473a348a0273921ea4e9a29e93a8a2de5ca58155610
|
|
4
|
+
data.tar.gz: 4297618824d834c783269cb16c7d05f2434bb1d7057e0617fc62742c89c2291b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4603bd4667fb265cae1eb433b3bb0077535cf7af2f313df2215a18d16f40f1464bf81ae4e5b2bf742470d5cdb5fb4e6720bc4e2f944a20a279729e7782e022f3
|
|
7
|
+
data.tar.gz: d6ba2088037d98660a7be54fcd87a051e923c6404e5d7bb1996512540b795a4f1678660cd6cd3170c6b6a1632b99c783aad9d70b3c7ec9002898ad84ee594d8b
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,52 @@ All notable changes to this project are documented here. The format is based on
|
|
|
5
5
|
follow [Semantic Versioning](https://semver.org). While on 0.x, a minor release
|
|
6
6
|
may include breaking changes; patch releases stay backward compatible.
|
|
7
7
|
|
|
8
|
+
## [0.3.0] - 2026-07-08
|
|
9
|
+
|
|
10
|
+
### Bug Fixes
|
|
11
|
+
|
|
12
|
+
- Persist delivery attempts without stranding on receiver text
|
|
13
|
+
- Require the retry schedule to be due before claiming
|
|
14
|
+
- Treat an echoed boolean network control as a no-op
|
|
15
|
+
- Report swallowed callback errors to Rails.error
|
|
16
|
+
- Set JSON column defaults at the model layer for MySQL
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
- Make the ping event name configurable
|
|
21
|
+
|
|
22
|
+
### Ci
|
|
23
|
+
|
|
24
|
+
- Run the suite against PostgreSQL and MySQL
|
|
25
|
+
|
|
26
|
+
## [0.2.0] - 2026-07-07
|
|
27
|
+
|
|
28
|
+
### Bug Fixes
|
|
29
|
+
|
|
30
|
+
- Harden delivery concurrency and endpoint validation
|
|
31
|
+
- Block special-use ranges and bound DNS resolution
|
|
32
|
+
- Robust pagination, JSON error contract, event preload
|
|
33
|
+
|
|
34
|
+
### Documentation
|
|
35
|
+
|
|
36
|
+
- Add email reporting channel and auto-bump SECURITY.md version
|
|
37
|
+
- Document new config options and SSRF special-use hardening
|
|
38
|
+
|
|
39
|
+
### Features
|
|
40
|
+
|
|
41
|
+
- Expose endpoint owner in the API behind a policy gate
|
|
42
|
+
- Add dns_timeout, hosts-file toggle, and input limits
|
|
43
|
+
- Add hot-path indexes, persisted forced flag, text url column
|
|
44
|
+
|
|
45
|
+
### Performance
|
|
46
|
+
|
|
47
|
+
- Memoize the record-less API policy per request
|
|
48
|
+
- Load only the columns subscription matching needs
|
|
49
|
+
|
|
50
|
+
### Refactoring
|
|
51
|
+
|
|
52
|
+
- Rename ping event and message to plain "ping"
|
|
53
|
+
|
|
8
54
|
## [0.1.0] - 2026-07-07
|
|
9
55
|
|
|
10
56
|
### Bug Fixes
|
|
@@ -26,6 +72,7 @@ may include breaking changes; patch releases stay backward compatible.
|
|
|
26
72
|
- Close the last README review nits
|
|
27
73
|
- Include unverified in the ping status lists
|
|
28
74
|
- Document ActiveSupport::Notifications instrumentation
|
|
75
|
+
- Add RubyGems version badge to README
|
|
29
76
|
|
|
30
77
|
### Features
|
|
31
78
|
|
data/README.md
CHANGED
|
@@ -351,9 +351,10 @@ redelivery is at-least-once-safe either way. Set it to `nil` to disable reaping.
|
|
|
351
351
|
|
|
352
352
|
### Pinging an endpoint
|
|
353
353
|
|
|
354
|
-
Verify an endpoint end-to-end by delivering a synthetic `
|
|
355
|
-
|
|
356
|
-
`
|
|
354
|
+
Verify an endpoint end-to-end by delivering a synthetic `ping` event (the name
|
|
355
|
+
is `config.ping_event_name`, default `"ping"` — change it if your app already
|
|
356
|
+
uses `"ping"`). Subscription matching is bypassed, so a ping is always sent.
|
|
357
|
+
Returns the `Angarium::Delivery`, so you can reload it to inspect the outcome:
|
|
357
358
|
|
|
358
359
|
```ruby
|
|
359
360
|
delivery = endpoint.ping!
|
|
@@ -443,6 +444,7 @@ methods:
|
|
|
443
444
|
| `create_unverified?` | `false` | Whether endpoints created through the API start `unverified` (no deliveries until a successful ping verifies them) instead of live. |
|
|
444
445
|
| `permit_allow_private_network?` | `false` | Whether `allow_private_network` (relax the private-IP block) is API-writable. Dangerous; trusted operators only. |
|
|
445
446
|
| `permit_allowed_networks?` | `false` | Whether `allowed_networks` (a restrictive CIDR allowlist) is API-writable. |
|
|
447
|
+
| `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. |
|
|
446
448
|
| `index?` `show?` `create?` `update?` `destroy?` | `true` | Whether each action is allowed. |
|
|
447
449
|
| `rotate_secret?` `pause?` `enable?` `verify?` `ping?` `redeliver?` | `update?` | Member actions; default to the `update?` capability. |
|
|
448
450
|
|
|
@@ -455,6 +457,11 @@ class WebhookEndpointPolicy < Angarium::Api::Policy
|
|
|
455
457
|
# Multi-tenant visibility: compose on top of the relation you're given.
|
|
456
458
|
def scope(relation) = relation.where(owner_id: current_user.account.owner_ids)
|
|
457
459
|
|
|
460
|
+
# A console spanning owners needs owner_type/owner_id on each endpoint to tell
|
|
461
|
+
# them apart. Pair this with a scope (above) that actually spans owners —
|
|
462
|
+
# expose_owner? only affects serialization, not what the caller can see.
|
|
463
|
+
def expose_owner? = current_user.admin?
|
|
464
|
+
|
|
458
465
|
# Admins may create for any owner in their account (via an owner_id param);
|
|
459
466
|
# everyone else creates for themselves.
|
|
460
467
|
def owner
|
|
@@ -514,6 +521,9 @@ included (see the note below):
|
|
|
514
521
|
|
|
515
522
|
- **Secrets are never echoed.** `signing_secret` is returned only by `create` and
|
|
516
523
|
`rotate_secret`; `custom_headers` (which may hold a credential) is write-only.
|
|
524
|
+
- **Owner is opt-in.** The endpoint object omits `owner_type`/`owner_id` unless the
|
|
525
|
+
policy's `expose_owner?` returns true; `owner_id` is a string (a polymorphic
|
|
526
|
+
owner may have any primary-key type).
|
|
517
527
|
- **Pagination.** List endpoints take `?limit=` (default 50, max 200) and
|
|
518
528
|
`?offset=`, and each list response carries a `pagination` object (`limit`,
|
|
519
529
|
`offset`, `count` in this page, `total` overall); there are more when
|
|
@@ -582,7 +592,12 @@ at delivery time:
|
|
|
582
592
|
- **`config.block_private_ips`** (default `true`) blocks delivery to
|
|
583
593
|
private, loopback, and link-local addresses (e.g. `127.0.0.1`, `10.0.0.0/8`,
|
|
584
594
|
`169.254.169.254`), including IPv4-mapped IPv6 forms (e.g. `::ffff:127.0.0.1`)
|
|
585
|
-
and the unspecified address (`0.0.0.0` / `::`).
|
|
595
|
+
and the unspecified address (`0.0.0.0` / `::`). It also blocks IANA special-use
|
|
596
|
+
ranges that aren't flagged as "private" but are still unsafe targets —
|
|
597
|
+
`0.0.0.0/8` (routes to localhost on Linux), CGNAT (`100.64.0.0/10`), reserved
|
|
598
|
+
space (`240.0.0.0/4`), and the NAT64 prefix (`64:ff9b::/96`, which can embed the
|
|
599
|
+
metadata IP). Resolution is re-checked at delivery time and the connection is
|
|
600
|
+
pinned to the validated IP, so DNS rebinding can't slip past.
|
|
586
601
|
- **`endpoint.allow_private_network`** (default `false`) is the per-endpoint opt-in
|
|
587
602
|
required to deliver to a private address. An allowlist entry alone does **not**
|
|
588
603
|
unlock a private address.
|
|
@@ -687,7 +702,12 @@ which documents every option inline. The delivery and retry settings:
|
|
|
687
702
|
| `max_retry_after` | `3600` | Cap (seconds) on a honored `Retry-After`; `nil` is uncapped. |
|
|
688
703
|
| `auto_disable_endpoint_after` | `nil` | Deactivate an endpoint after N consecutive failures; `nil` never does. |
|
|
689
704
|
| `signing_secret_grace_period` | `24.hours` | How long a rotated endpoint's previous secret stays valid. |
|
|
690
|
-
| `block_private_ips` | `true` | Reject endpoint URLs resolving to private/loopback addresses (SSRF). |
|
|
705
|
+
| `block_private_ips` | `true` | Reject endpoint URLs resolving to private/loopback/special-use addresses (SSRF). |
|
|
706
|
+
| `dns_timeout` | `[1, 3]` | Per-try timeout(s) in seconds for resolving an endpoint host; `nil` uses the resolver default. |
|
|
707
|
+
| `resolve_dns_with_hosts_file` | `true` | Also resolve endpoint hosts via the system hosts file (e.g. `/etc/hosts`), not DNS only. Set `false` to harden to DNS-only. |
|
|
708
|
+
| `max_url_length` | `2048` | Maximum length of an endpoint URL. |
|
|
709
|
+
| `max_subscribed_events` | `100` | Maximum number of event patterns an endpoint may subscribe to. |
|
|
710
|
+
| `ping_event_name` | `"ping"` | Event name for the synthetic event `endpoint.ping!` emits; change it to avoid colliding with your own events. |
|
|
691
711
|
| `max_response_body_bytes` | `65_536` | Truncate the stored response body; `nil` stores it whole. |
|
|
692
712
|
| `delivery_attempt_retention` | `nil` | Age past which `angarium:prune` deletes attempts; `nil` keeps all. |
|
|
693
713
|
| `delivering_timeout` | `15.minutes` | Age after which `angarium:reap` requeues a stuck `delivering` delivery. |
|
data/SECURITY.md
CHANGED
|
@@ -2,23 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## Supported versions
|
|
4
4
|
|
|
5
|
-
Angarium is pre-release. Security fixes are provided for the `0.
|
|
5
|
+
Angarium is pre-release. Security fixes are provided for the `0.3.x` series
|
|
6
6
|
during this phase. Once a stable line is released, this policy will be updated to
|
|
7
7
|
list the versions that receive security updates.
|
|
8
8
|
|
|
9
9
|
| Version | Supported |
|
|
10
10
|
| ------- | --------- |
|
|
11
|
-
| 0.
|
|
11
|
+
| 0.3.x | ✅ |
|
|
12
12
|
|
|
13
13
|
## Reporting a vulnerability
|
|
14
14
|
|
|
15
15
|
Please report security vulnerabilities **privately**. Do **not** open a public
|
|
16
16
|
issue, pull request, or discussion for a suspected vulnerability.
|
|
17
17
|
|
|
18
|
-
Use
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
(
|
|
18
|
+
Use one of the following private channels:
|
|
19
|
+
|
|
20
|
+
- **Preferred:** GitHub's private vulnerability reporting for the
|
|
21
|
+
[`radioactive-labs/angarium`](https://github.com/radioactive-labs/angarium)
|
|
22
|
+
repository — go to the **Security** tab and choose **Report a vulnerability**
|
|
23
|
+
(this opens a private draft advisory visible only to the maintainers).
|
|
24
|
+
- **Email:** [sfroelich01@gmail.com](mailto:sfroelich01@gmail.com) with the
|
|
25
|
+
subject line `[SECURITY] Angarium`.
|
|
22
26
|
|
|
23
27
|
We aim to acknowledge new reports within a few business days, and we'll keep you
|
|
24
28
|
informed as we investigate and prepare a fix. Please give us a reasonable window
|
|
@@ -15,6 +15,7 @@ module Angarium
|
|
|
15
15
|
rescue_from ActiveRecord::RecordInvalid, with: :angarium_render_invalid
|
|
16
16
|
rescue_from Angarium::Api::NotAuthorized, with: :angarium_render_forbidden
|
|
17
17
|
rescue_from Angarium::Api::UnpermittedParameter, with: :angarium_render_unpermitted
|
|
18
|
+
rescue_from ActionController::ParameterMissing, with: :angarium_render_bad_request
|
|
18
19
|
|
|
19
20
|
# Resolved current user (via config.current_user). Public so policies can
|
|
20
21
|
# read it as `controller.angarium_current_user`.
|
|
@@ -31,8 +32,19 @@ module Angarium
|
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
# The policy for this request (config.policy_class), holding scope,
|
|
34
|
-
# create-owner, and per-action permissions.
|
|
35
|
+
# create-owner, and per-action permissions. The record-less policy is
|
|
36
|
+
# memoized: scope/owner/expose_owner? and friends are request-global and
|
|
37
|
+
# get called repeatedly (expose_owner? runs once per serialized row), so a
|
|
38
|
+
# list response would otherwise allocate a fresh policy per endpoint. A
|
|
39
|
+
# record-bound policy (member authorize!) still constructs fresh, since it
|
|
40
|
+
# varies by record.
|
|
35
41
|
def angarium_policy(record = nil)
|
|
42
|
+
return build_angarium_policy(record) if record
|
|
43
|
+
|
|
44
|
+
@angarium_policy ||= build_angarium_policy
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_angarium_policy(record = nil)
|
|
36
48
|
Angarium.config.policy_class.to_s.constantize.new(self, record)
|
|
37
49
|
end
|
|
38
50
|
|
|
@@ -41,9 +53,10 @@ module Angarium
|
|
|
41
53
|
angarium_policy.scope(Angarium::Endpoint.all)
|
|
42
54
|
end
|
|
43
55
|
|
|
44
|
-
# A delivery whose endpoint is within the caller's scope, or 404.
|
|
56
|
+
# A delivery whose endpoint is within the caller's scope, or 404. Eager-load
|
|
57
|
+
# :event since delivery_json serializes event.name (avoids a follow-up query).
|
|
45
58
|
def scoped_delivery(id)
|
|
46
|
-
Angarium::Delivery.where(endpoint_id: endpoint_scope.select(:id)).find(id)
|
|
59
|
+
Angarium::Delivery.includes(:event).where(endpoint_id: endpoint_scope.select(:id)).find(id)
|
|
47
60
|
end
|
|
48
61
|
|
|
49
62
|
# Guard the current action with the policy's `<action>?` predicate.
|
|
@@ -55,8 +68,8 @@ module Angarium
|
|
|
55
68
|
# and advertises the window with a `pagination` object so clients can page
|
|
56
69
|
# (there's more when offset + count < total).
|
|
57
70
|
def render_collection(key, relation, &serializer)
|
|
58
|
-
limit =
|
|
59
|
-
offset = [
|
|
71
|
+
limit = pagination_param(:limit, 50).clamp(1, 200)
|
|
72
|
+
offset = [pagination_param(:offset, 0), 0].max
|
|
60
73
|
records = relation.limit(limit).offset(offset).to_a
|
|
61
74
|
render json: {
|
|
62
75
|
key => records.map(&serializer),
|
|
@@ -64,6 +77,15 @@ module Angarium
|
|
|
64
77
|
}
|
|
65
78
|
end
|
|
66
79
|
|
|
80
|
+
# Coerce a pagination query param to an integer, ignoring non-scalar input.
|
|
81
|
+
# A hostile `?limit[]=1` makes params[:limit] an Array/Parameters, which
|
|
82
|
+
# has no #to_i and would otherwise raise an uncaught 500; fall back to the
|
|
83
|
+
# default for anything that isn't a plain string.
|
|
84
|
+
def pagination_param(key, default)
|
|
85
|
+
raw = params[key]
|
|
86
|
+
raw.is_a?(String) ? raw.to_i : default
|
|
87
|
+
end
|
|
88
|
+
|
|
67
89
|
def render_error(status, message, **extra)
|
|
68
90
|
render json: {error: message, **extra}, status: status
|
|
69
91
|
end
|
|
@@ -71,6 +93,7 @@ module Angarium
|
|
|
71
93
|
def angarium_render_unauthorized = render_error(:unauthorized, "authentication required")
|
|
72
94
|
def angarium_render_forbidden = render_error(:forbidden, "not authorized")
|
|
73
95
|
def angarium_render_not_found = render_error(:not_found, "not found")
|
|
96
|
+
def angarium_render_bad_request(error) = render_error(:bad_request, "bad request", details: [error.message])
|
|
74
97
|
|
|
75
98
|
def angarium_render_invalid(error)
|
|
76
99
|
render_error(:unprocessable_entity, "validation failed", details: error.record.errors.full_messages)
|
|
@@ -99,6 +122,12 @@ module Angarium
|
|
|
99
122
|
created_at: endpoint.created_at.iso8601,
|
|
100
123
|
updated_at: endpoint.updated_at.iso8601
|
|
101
124
|
}
|
|
125
|
+
if angarium_policy.expose_owner?
|
|
126
|
+
# Raw columns off the row (no owner fetch, so no cross-database hit);
|
|
127
|
+
# the caller reassembles the owner via its own type/id convention.
|
|
128
|
+
json[:owner_type] = endpoint.owner_type
|
|
129
|
+
json[:owner_id] = endpoint.owner_id
|
|
130
|
+
end
|
|
102
131
|
json[:signing_secret] = endpoint.signing_secret if include_secret
|
|
103
132
|
json
|
|
104
133
|
end
|
|
@@ -107,8 +107,18 @@ module Angarium
|
|
|
107
107
|
return unless body.key?(attr)
|
|
108
108
|
|
|
109
109
|
current = (@endpoint || Angarium::Endpoint.new).public_send(attr)
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
# Compare like-for-like against the typed attribute, so echoing the
|
|
111
|
+
# serialized endpoint back is a no-op regardless of wire representation:
|
|
112
|
+
# allowed_networks is an array of strings, while allow_private_network is a
|
|
113
|
+
# boolean a form/JSON client may send as "true"/"false"/"0"/"1". Without
|
|
114
|
+
# the cast, a stringly-typed "false" wouldn't == the boolean false and a
|
|
115
|
+
# semantic no-op would raise a spurious 422.
|
|
116
|
+
submitted =
|
|
117
|
+
if attr == :allowed_networks
|
|
118
|
+
Array(body[attr]).map(&:to_s)
|
|
119
|
+
else
|
|
120
|
+
ActiveModel::Type::Boolean.new.cast(body[attr])
|
|
121
|
+
end
|
|
112
122
|
|
|
113
123
|
raise Angarium::Api::UnpermittedParameter, attr unless submitted == current
|
|
114
124
|
end
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
module Angarium
|
|
2
2
|
class DeliverJob < ApplicationJob
|
|
3
|
-
|
|
3
|
+
# The second positional arg is accepted only for backward compatibility with
|
|
4
|
+
# jobs enqueued by an older version (force is now persisted on the delivery as
|
|
5
|
+
# `forced` and read by #deliver!); new enqueues pass just the id.
|
|
6
|
+
def perform(delivery_id, _legacy_force = nil)
|
|
4
7
|
delivery = Delivery.find_by(id: delivery_id)
|
|
5
8
|
return unless delivery
|
|
6
9
|
return unless delivery.pending?
|
|
7
10
|
|
|
8
|
-
delivery.deliver!
|
|
11
|
+
delivery.deliver!
|
|
9
12
|
end
|
|
10
13
|
end
|
|
11
14
|
end
|
|
@@ -13,12 +13,7 @@ module Angarium
|
|
|
13
13
|
belongs_to :endpoint, class_name: "Angarium::Endpoint"
|
|
14
14
|
has_many :delivery_attempts, class_name: "Angarium::DeliveryAttempt", dependent: :destroy
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
# endpoint.ping!(force: true)) so its first attempt bypasses the endpoint
|
|
18
|
-
# status guard. Not persisted; only the enqueued job carries it forward.
|
|
19
|
-
attr_accessor :force_send
|
|
20
|
-
|
|
21
|
-
after_create_commit { force_send ? DeliverJob.perform_later(id, true) : DeliverJob.perform_later(id) }
|
|
16
|
+
after_create_commit { DeliverJob.perform_later(id) }
|
|
22
17
|
|
|
23
18
|
# Recover deliveries stranded in "delivering": a worker set the state to
|
|
24
19
|
# "delivering" (in #deliver!) but died (crash, deploy, OOM) before
|
|
@@ -34,14 +29,30 @@ module Angarium
|
|
|
34
29
|
ids = where(state: "delivering").where(last_attempt_at: ..older_than.ago).pluck(:id)
|
|
35
30
|
return 0 if ids.empty?
|
|
36
31
|
|
|
37
|
-
|
|
38
|
-
ids
|
|
39
|
-
|
|
32
|
+
# Reset each id with a state-scoped compare-and-swap, not a blanket
|
|
33
|
+
# `where(id: ids)`: a delivery can leave `delivering` (succeed, be marked
|
|
34
|
+
# gone, etc.) between the pluck above and this reset. Re-asserting
|
|
35
|
+
# `state: "delivering"` means we never drag a completed delivery back to
|
|
36
|
+
# pending, and — since only the reaper that actually flipped the row
|
|
37
|
+
# enqueues — two reapers racing the same snapshot can't double-enqueue.
|
|
38
|
+
requeued = 0
|
|
39
|
+
ids.each do |id|
|
|
40
|
+
changed = where(id: id, state: "delivering")
|
|
41
|
+
.update_all(state: "pending", next_attempt_at: Time.current, updated_at: Time.current)
|
|
42
|
+
next if changed.zero?
|
|
43
|
+
|
|
44
|
+
DeliverJob.perform_later(id)
|
|
45
|
+
requeued += 1
|
|
46
|
+
end
|
|
47
|
+
requeued
|
|
40
48
|
end
|
|
41
49
|
|
|
42
50
|
# Performs one attempt. Records a DeliveryAttempt, then transitions to
|
|
43
|
-
# succeeded, blocked (SSRF), schedules a retry, or exhausts. Returns the
|
|
44
|
-
|
|
51
|
+
# succeeded, blocked (SSRF), schedules a retry, or exhausts. Returns the
|
|
52
|
+
# attempt. `force` defaults to the persisted `forced` flag so a reaped or
|
|
53
|
+
# redelivered forced attempt keeps bypassing the guard; pass it explicitly
|
|
54
|
+
# only to override in tests.
|
|
55
|
+
def deliver!(client: Client.new, force: forced)
|
|
45
56
|
payload = {delivery_id: id, endpoint_id: endpoint_id, event: event.name, force: force}
|
|
46
57
|
ActiveSupport::Notifications.instrument("deliver.angarium", payload) do
|
|
47
58
|
# An endpoint's status can change after a delivery is queued: auto-disable
|
|
@@ -62,7 +73,12 @@ module Angarium
|
|
|
62
73
|
end
|
|
63
74
|
end
|
|
64
75
|
|
|
65
|
-
|
|
76
|
+
# Atomically claim this delivery (pending -> delivering) so only one
|
|
77
|
+
# worker ever attempts it. Without the compare-and-swap, two jobs for the
|
|
78
|
+
# same id (a reaper requeue racing a stale enqueue, an adapter retry, etc.)
|
|
79
|
+
# would both pass the guard above, both POST, and both read the same stale
|
|
80
|
+
# attempt_count. If the CAS changes no row, another worker owns it: bail.
|
|
81
|
+
return nil unless claim_for_attempt!
|
|
66
82
|
payload[:attempt] = attempt_count
|
|
67
83
|
|
|
68
84
|
# Re-resolve at delivery time (rather than trusting the save-time check)
|
|
@@ -74,7 +90,7 @@ module Angarium
|
|
|
74
90
|
if addresses.any? { |ip| !AddressPolicy.ip_allowed?(ip, endpoint) }
|
|
75
91
|
payload[:outcome] = :blocked
|
|
76
92
|
payload[:error] = "blocked: destination address not permitted"
|
|
77
|
-
attempt =
|
|
93
|
+
attempt = record_attempt!(error: payload[:error])
|
|
78
94
|
update!(state: "blocked", next_attempt_at: nil)
|
|
79
95
|
endpoint.record_delivery_failure!
|
|
80
96
|
return attempt
|
|
@@ -87,7 +103,7 @@ module Angarium
|
|
|
87
103
|
if addresses.empty?
|
|
88
104
|
payload[:outcome] = :unresolvable
|
|
89
105
|
payload[:error] = "unresolvable host: #{destination_host}"
|
|
90
|
-
attempt =
|
|
106
|
+
attempt = record_attempt!(error: payload[:error])
|
|
91
107
|
handle_failure!
|
|
92
108
|
return attempt
|
|
93
109
|
end
|
|
@@ -113,7 +129,7 @@ module Angarium
|
|
|
113
129
|
addresses: addresses.map(&:to_s)
|
|
114
130
|
)
|
|
115
131
|
|
|
116
|
-
attempt =
|
|
132
|
+
attempt = record_attempt!(
|
|
117
133
|
response_code: result.code,
|
|
118
134
|
response_body: result.body,
|
|
119
135
|
error: result.error,
|
|
@@ -144,15 +160,42 @@ module Angarium
|
|
|
144
160
|
|
|
145
161
|
# Reset the retry cycle and re-enqueue immediately. Keeps prior
|
|
146
162
|
# DeliveryAttempt history. `force: true` sends even if the endpoint is no
|
|
147
|
-
# longer enabled (for the re-enqueued attempt)
|
|
163
|
+
# longer enabled (for the re-enqueued attempt); it is persisted so the reaper
|
|
164
|
+
# honors it too. Returns self.
|
|
148
165
|
def redeliver!(force: false)
|
|
149
|
-
update!(state: "pending", next_attempt_at: nil, attempt_count: 0)
|
|
150
|
-
|
|
166
|
+
update!(state: "pending", next_attempt_at: nil, attempt_count: 0, forced: force)
|
|
167
|
+
DeliverJob.perform_later(id)
|
|
151
168
|
self
|
|
152
169
|
end
|
|
153
170
|
|
|
154
171
|
private
|
|
155
172
|
|
|
173
|
+
# Atomic pending -> delivering claim: the single writer gate for an attempt.
|
|
174
|
+
# Returns true if THIS caller won the row (and bumps attempt_count in the same
|
|
175
|
+
# statement so concurrent claimers can't both read a stale count), false if
|
|
176
|
+
# another worker already claimed it. Reload so in-memory state matches the
|
|
177
|
+
# CAS (the rest of #deliver! reads attempt_count, and later update!s need a
|
|
178
|
+
# clean dirty baseline to persist the delivering -> succeeded/pending move).
|
|
179
|
+
def claim_for_attempt!
|
|
180
|
+
now = Time.current
|
|
181
|
+
# Also require the schedule to be due. Without this, a duplicate or stale
|
|
182
|
+
# enqueue (an at-least-once adapter re-running a job whose retry is scheduled
|
|
183
|
+
# for later) would claim and attempt EARLIER than next_attempt_at, defeating
|
|
184
|
+
# the backoff the Retry-After defenses exist to protect. New, held, and
|
|
185
|
+
# redelivered rows carry next_attempt_at: nil and so are always due; a
|
|
186
|
+
# scheduled retry becomes claimable only once its time arrives; the reaper
|
|
187
|
+
# stamps next_attempt_at = now on requeue, so a reaped row is due immediately.
|
|
188
|
+
claimed = self.class.where(id: id, state: "pending")
|
|
189
|
+
.where("next_attempt_at IS NULL OR next_attempt_at <= ?", now)
|
|
190
|
+
.update_all(
|
|
191
|
+
["state = 'delivering', attempt_count = attempt_count + 1, last_attempt_at = ?, updated_at = ?", now, now]
|
|
192
|
+
)
|
|
193
|
+
return false if claimed.zero?
|
|
194
|
+
|
|
195
|
+
reload
|
|
196
|
+
true
|
|
197
|
+
end
|
|
198
|
+
|
|
156
199
|
# Endpoint was paused after this delivery was queued. Park it (no attempt
|
|
157
200
|
# consumed, no failure recorded) by clearing the schedule and leaving it
|
|
158
201
|
# pending; Endpoint#enable! re-enqueues parked deliveries so a pause/resume
|
|
@@ -166,11 +209,36 @@ module Angarium
|
|
|
166
209
|
# queued. Stop retrying and log why. Recover with redeliver! if the endpoint is
|
|
167
210
|
# later re-enabled. Returns the logged attempt.
|
|
168
211
|
def cancel!(reason:)
|
|
169
|
-
attempt =
|
|
212
|
+
attempt = record_attempt!(error: "canceled: endpoint #{reason}")
|
|
170
213
|
update!(state: "canceled", next_attempt_at: nil)
|
|
171
214
|
attempt
|
|
172
215
|
end
|
|
173
216
|
|
|
217
|
+
# Persist a DeliveryAttempt. A raise here lands after the row is already
|
|
218
|
+
# `delivering`, which would strand it for the reaper to redeliver forever (the
|
|
219
|
+
# exact failure F1 exists to prevent), so this must not raise on anything the
|
|
220
|
+
# receiver can influence. DeliveryAttempt#normalizes sanitizes the body and
|
|
221
|
+
# error at assignment (UTF-8 scrub, NUL strip, length cap), so the create!
|
|
222
|
+
# below should succeed; the rescue is a last-resort net for anything that slips
|
|
223
|
+
# through (a transient DB error, a MySQL limit we cannot reproduce on SQLite).
|
|
224
|
+
# We rescue broadly on purpose because any escape means a permanent loop.
|
|
225
|
+
def record_attempt!(attributes)
|
|
226
|
+
delivery_attempts.create!(attributes)
|
|
227
|
+
rescue => e
|
|
228
|
+
# The attempt's `error` is served to the webhook owner via the API, so we
|
|
229
|
+
# must not leak the internal exception there: store a generic marker for
|
|
230
|
+
# them, and surface the real cause internally so we can actually fix it.
|
|
231
|
+
# Reported to Rails.error because a delivery we can't record is a correctness
|
|
232
|
+
# problem, not a transient hiccup.
|
|
233
|
+
Rails.logger.error { "[Angarium] failed to persist delivery attempt for delivery ##{id}: #{e.class}: #{e.message}" }
|
|
234
|
+
Rails.error.report(e, handled: true, severity: :error, source: "angarium", context: {delivery_id: id})
|
|
235
|
+
delivery_attempts.create!(
|
|
236
|
+
response_code: attributes[:response_code],
|
|
237
|
+
duration: attributes[:duration],
|
|
238
|
+
error: "delivery attempt could not be recorded"
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
|
|
174
242
|
def succeed!
|
|
175
243
|
update!(state: "succeeded", next_attempt_at: nil)
|
|
176
244
|
endpoint.record_delivery_success!
|
|
@@ -203,7 +271,9 @@ module Angarium
|
|
|
203
271
|
# misconfigured receiver could send a tiny Retry-After to defeat our
|
|
204
272
|
# backoff and make us hammer it. Retry-After can delay, never expedite.
|
|
205
273
|
wait = [wait, retry_after].max if retry_after
|
|
206
|
-
|
|
274
|
+
# Drop force: a recorded failure means the forced first attempt is spent,
|
|
275
|
+
# and per the deliver! contract any scheduled retry follows normal rules.
|
|
276
|
+
update!(state: "pending", next_attempt_at: Time.current + wait, forced: false)
|
|
207
277
|
DeliverJob.set(wait: wait).perform_later(id)
|
|
208
278
|
end
|
|
209
279
|
end
|
|
@@ -2,6 +2,28 @@ module Angarium
|
|
|
2
2
|
class DeliveryAttempt < ApplicationRecord
|
|
3
3
|
belongs_to :delivery, class_name: "Angarium::Delivery"
|
|
4
4
|
|
|
5
|
+
# response_body and error are partly receiver-controlled. Sanitize them on
|
|
6
|
+
# assignment (so every write path is covered, not just the delivery happy
|
|
7
|
+
# path) into something a text column always accepts — otherwise the INSERT
|
|
8
|
+
# raises, and mid-#deliver! that raise lands after the row is already
|
|
9
|
+
# `delivering`, stranding the delivery for the reaper to redeliver forever.
|
|
10
|
+
# For each: coerce to UTF-8 and scrub invalid bytes (an upstream byte cap can
|
|
11
|
+
# split a multibyte char; a receiver may return non-UTF-8 outright), then drop
|
|
12
|
+
# NUL — it is valid UTF-8 (so scrub keeps it) but PostgreSQL rejects it in text
|
|
13
|
+
# columns, surfacing via the pg driver as an ArgumentError, not even a
|
|
14
|
+
# StatementInvalid. `error` is additionally capped to its column limit (only
|
|
15
|
+
# MySQL sets one) since a client error message can overflow varchar(255).
|
|
16
|
+
normalizes :response_body, with: ->(value) { sanitize_text(value) }
|
|
17
|
+
normalizes :error, with: ->(value) {
|
|
18
|
+
text = sanitize_text(value)
|
|
19
|
+
limit = columns_hash["error"]&.limit
|
|
20
|
+
limit ? text.truncate(limit) : text
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def self.sanitize_text(value)
|
|
24
|
+
value.dup.force_encoding(Encoding::UTF_8).scrub.delete("\u0000")
|
|
25
|
+
end
|
|
26
|
+
|
|
5
27
|
# Delete delivery attempts older than the cutoff. `older_than` may be a
|
|
6
28
|
# duration (e.g. 90.days) or an absolute Time. Returns the number deleted.
|
|
7
29
|
# (A Time also responds to #ago but requires an argument, so branch on the
|
|
@@ -37,13 +37,21 @@ module Angarium
|
|
|
37
37
|
# here keeps behavior correct on every supported Rails version.
|
|
38
38
|
attribute :allow_private_network, :boolean, default: false
|
|
39
39
|
|
|
40
|
+
# Defaults at the model layer, not the column: both are JSON columns and MySQL
|
|
41
|
+
# forbids a DB default there. Procs yield a fresh object per record so the
|
|
42
|
+
# empty default is never shared/mutated across instances.
|
|
43
|
+
attribute :subscribed_events, default: -> { [] }
|
|
44
|
+
attribute :allowed_networks, default: -> { [] }
|
|
45
|
+
|
|
40
46
|
before_validation :ensure_signing_secret, on: :create
|
|
41
47
|
|
|
42
|
-
validates :name, presence: true
|
|
48
|
+
validates :name, presence: true, length: {maximum: 255}
|
|
43
49
|
validates :url, presence: true
|
|
44
50
|
validates :url, "angarium/endpoint_url": true, if: :verify_url_address?
|
|
51
|
+
validate :url_within_length_limit
|
|
45
52
|
validate :allowed_networks_are_valid_cidrs
|
|
46
53
|
validate :custom_headers_are_strings
|
|
54
|
+
validate :subscribed_events_are_bounded
|
|
47
55
|
|
|
48
56
|
def self.generate_signing_secret
|
|
49
57
|
"whsec_#{Base64.strict_encode64(SecureRandom.bytes(32))}"
|
|
@@ -120,8 +128,10 @@ module Angarium
|
|
|
120
128
|
def enable!
|
|
121
129
|
return false unless transition_status!(:enabled, consecutive_failures: 0)
|
|
122
130
|
# Resume deliveries parked while this endpoint was paused (pending with no
|
|
123
|
-
# scheduled attempt).
|
|
124
|
-
#
|
|
131
|
+
# scheduled attempt). This predicate can also match a delivery that already
|
|
132
|
+
# has an in-flight job (a forced ping, a just-redelivered row), but a stray
|
|
133
|
+
# re-enqueue is harmless: the atomic pending->delivering claim in
|
|
134
|
+
# Delivery#deliver! guarantees only one worker ever sends a given delivery.
|
|
125
135
|
deliveries.where(state: "pending", next_attempt_at: nil).find_each { |d| DeliverJob.perform_later(d.id) }
|
|
126
136
|
true
|
|
127
137
|
end
|
|
@@ -135,15 +145,15 @@ module Angarium
|
|
|
135
145
|
transition_status!(:enabled, from: :unverified) { Angarium.notify(:on_endpoint_verified, self) }
|
|
136
146
|
end
|
|
137
147
|
|
|
138
|
-
# Deliver a synthetic
|
|
139
|
-
# subscription matching (a ping is always
|
|
140
|
-
# ignores the endpoint's status, so you can test
|
|
141
|
-
# disabled, or not yet enabled; pass `force: false`
|
|
142
|
-
# guard instead. Returns the created Angarium::Delivery,
|
|
143
|
-
# after_create_commit enqueues the DeliverJob; reload it to inspect the outcome.
|
|
144
|
-
def ping!(payload = {message: "
|
|
145
|
-
event = Angarium::Event.create!(name:
|
|
146
|
-
deliveries.create!(event: event
|
|
148
|
+
# Deliver a synthetic ping event (name from config.ping_event_name, default
|
|
149
|
+
# "ping") to this endpoint, bypassing subscription matching (a ping is always
|
|
150
|
+
# sent). By default a ping also ignores the endpoint's status, so you can test
|
|
151
|
+
# an endpoint that is paused, disabled, or not yet enabled; pass `force: false`
|
|
152
|
+
# to respect the status guard instead. Returns the created Angarium::Delivery,
|
|
153
|
+
# whose after_create_commit enqueues the DeliverJob; reload it to inspect the outcome.
|
|
154
|
+
def ping!(payload = {message: "ping"}, force: true)
|
|
155
|
+
event = Angarium::Event.create!(name: Angarium.config.ping_event_name, payload: payload)
|
|
156
|
+
deliveries.create!(event: event, forced: force)
|
|
147
157
|
end
|
|
148
158
|
|
|
149
159
|
private
|
|
@@ -200,6 +210,31 @@ module Angarium
|
|
|
200
210
|
end
|
|
201
211
|
end
|
|
202
212
|
|
|
213
|
+
def url_within_length_limit
|
|
214
|
+
max = Angarium.config.max_url_length
|
|
215
|
+
return if url.blank? || max.nil? || url.length <= max
|
|
216
|
+
|
|
217
|
+
errors.add(:url, "is too long (maximum is #{max} characters)")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def subscribed_events_are_bounded
|
|
221
|
+
return if subscribed_events.blank?
|
|
222
|
+
|
|
223
|
+
unless subscribed_events.is_a?(Array)
|
|
224
|
+
errors.add(:subscribed_events, "must be an array of event patterns")
|
|
225
|
+
return
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
max = Angarium.config.max_subscribed_events
|
|
229
|
+
if max && subscribed_events.length > max
|
|
230
|
+
errors.add(:subscribed_events, "cannot have more than #{max} subscribed events")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
unless subscribed_events.all? { |e| e.is_a?(String) && !e.empty? && e.length <= 255 }
|
|
234
|
+
errors.add(:subscribed_events, "must be non-empty strings of at most 255 characters")
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
203
238
|
def custom_headers_are_strings
|
|
204
239
|
return if custom_headers.blank?
|
|
205
240
|
|
|
@@ -212,6 +247,14 @@ module Angarium
|
|
|
212
247
|
if custom_headers.keys.any? { |k| RESERVED_HEADERS.include?(k.downcase) }
|
|
213
248
|
errors.add(:custom_headers, "cannot override reserved or transport headers (#{RESERVED_HEADERS.join(", ")})")
|
|
214
249
|
end
|
|
250
|
+
|
|
251
|
+
# Reject CR/LF/NUL in any key or value. The outbound HTTP client writes
|
|
252
|
+
# headers verbatim, so a CRLF in a value would inject an extra header line
|
|
253
|
+
# — smuggling e.g. a forged webhook-signature past the RESERVED_HEADERS
|
|
254
|
+
# denylist (which only guards whole keys). Fail closed instead.
|
|
255
|
+
if custom_headers.any? { |k, v| k.match?(/[\r\n\0]/) || v.match?(/[\r\n\0]/) }
|
|
256
|
+
errors.add(:custom_headers, "must not contain control characters (CR, LF, or NUL)")
|
|
257
|
+
end
|
|
215
258
|
end
|
|
216
259
|
end
|
|
217
260
|
end
|
|
@@ -2,6 +2,10 @@ module Angarium
|
|
|
2
2
|
class Event < ApplicationRecord
|
|
3
3
|
has_many :deliveries, class_name: "Angarium::Delivery", dependent: :destroy
|
|
4
4
|
|
|
5
|
+
# Default at the model layer, not the column: the payload column is JSON and
|
|
6
|
+
# MySQL forbids a DB default there. A proc yields a fresh hash per record.
|
|
7
|
+
attribute :payload, default: -> { {} }
|
|
8
|
+
|
|
5
9
|
validates :name, presence: true
|
|
6
10
|
end
|
|
7
11
|
end
|
|
@@ -61,6 +61,16 @@ module Angarium
|
|
|
61
61
|
false
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
# Should serialized endpoints include their owner (owner_type/owner_id)?
|
|
65
|
+
# Default: no. The owner is the tenancy boundary, resolved server-side, so
|
|
66
|
+
# the single-owner default has no reason to echo it back. Override for an
|
|
67
|
+
# admin/multi-tenant console that lists endpoints across owners and needs
|
|
68
|
+
# to tell them apart. The raw columns are exposed (already loaded on the
|
|
69
|
+
# row), not the owner record, so this adds no per-row database fetch.
|
|
70
|
+
def expose_owner?
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
64
74
|
def index? = true
|
|
65
75
|
def show? = true
|
|
66
76
|
def create? = true
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
class CreateAngariumEndpoints < ActiveRecord::Migration[7.1]
|
|
2
2
|
def change
|
|
3
3
|
create_table :angarium_endpoints, id: Angarium.primary_key_type do |t|
|
|
4
|
-
|
|
4
|
+
# index: false — the owner scope also orders by created_at, so the
|
|
5
|
+
# composite index below covers both the tenancy filter and the list order.
|
|
6
|
+
t.references :owner, polymorphic: true, null: false, type: :string, index: false
|
|
5
7
|
t.string :name, null: false
|
|
6
|
-
t.
|
|
8
|
+
t.text :url, null: false
|
|
7
9
|
t.string :status, null: false, default: "enabled"
|
|
8
10
|
t.text :signing_secret, null: false
|
|
9
|
-
|
|
11
|
+
# No DB-level default on the JSON columns: MySQL forbids defaults on
|
|
12
|
+
# JSON/TEXT/BLOB. The defaults are set on the model (Endpoint#subscribed_events
|
|
13
|
+
# / #allowed_networks) so the null: false constraints hold on every adapter.
|
|
14
|
+
t.json :subscribed_events, null: false
|
|
10
15
|
t.boolean :allow_private_network, null: false, default: false
|
|
11
|
-
t.json :allowed_networks, null: false
|
|
16
|
+
t.json :allowed_networks, null: false
|
|
12
17
|
# Encrypted at rest (may hold a receiver credential), so it's nullable with
|
|
13
18
|
# no DB default — an unencrypted default would fail to decrypt on read.
|
|
14
19
|
t.json :custom_headers
|
|
@@ -17,6 +22,9 @@ class CreateAngariumEndpoints < ActiveRecord::Migration[7.1]
|
|
|
17
22
|
t.datetime :secret_rotated_at
|
|
18
23
|
t.datetime :status_changed_at
|
|
19
24
|
t.timestamps
|
|
25
|
+
# Serves the owner-scoped list endpoint (WHERE owner_type/owner_id ORDER BY
|
|
26
|
+
# created_at DESC) in a single index scan.
|
|
27
|
+
t.index [:owner_type, :owner_id, :created_at], name: "idx_angarium_endpoints_on_owner_created_at"
|
|
20
28
|
end
|
|
21
29
|
end
|
|
22
30
|
end
|
|
@@ -2,7 +2,9 @@ class CreateAngariumEvents < ActiveRecord::Migration[7.1]
|
|
|
2
2
|
def change
|
|
3
3
|
create_table :angarium_events, id: Angarium.primary_key_type do |t|
|
|
4
4
|
t.string :name, null: false
|
|
5
|
-
|
|
5
|
+
# No DB-level default: MySQL forbids defaults on JSON/TEXT/BLOB columns. The
|
|
6
|
+
# default is set on the model (Event#payload) so null: false holds everywhere.
|
|
7
|
+
t.json :payload, null: false
|
|
6
8
|
t.timestamps
|
|
7
9
|
end
|
|
8
10
|
end
|
|
@@ -2,12 +2,23 @@ class CreateAngariumDeliveries < ActiveRecord::Migration[7.1]
|
|
|
2
2
|
def change
|
|
3
3
|
create_table :angarium_deliveries, id: Angarium.primary_key_type do |t|
|
|
4
4
|
t.references :event, null: false, type: Angarium.primary_key_type, foreign_key: {to_table: :angarium_events}
|
|
5
|
-
|
|
5
|
+
# index: false — the endpoint-scoped delivery list orders by created_at, so
|
|
6
|
+
# the composite index below covers both the FK lookup and the list order.
|
|
7
|
+
t.references :endpoint, index: false, null: false, type: Angarium.primary_key_type, foreign_key: {to_table: :angarium_endpoints}
|
|
6
8
|
t.string :state, null: false, default: "pending"
|
|
7
9
|
t.integer :attempt_count, null: false, default: 0
|
|
10
|
+
# A manual ping!/redeliver! delivery whose next attempt bypasses the
|
|
11
|
+
# endpoint status guard. Persisted (not just a job arg) so the reaper can
|
|
12
|
+
# honor it when re-running an attempt that was stranded before completing;
|
|
13
|
+
# cleared once a recorded failure schedules a retry.
|
|
14
|
+
t.boolean :forced, null: false, default: false
|
|
8
15
|
t.datetime :last_attempt_at
|
|
9
16
|
t.datetime :next_attempt_at
|
|
10
17
|
t.timestamps
|
|
18
|
+
# Endpoint-scoped list endpoint (WHERE endpoint_id ORDER BY created_at DESC).
|
|
19
|
+
t.index [:endpoint_id, :created_at], name: "idx_angarium_deliveries_on_endpoint_created_at"
|
|
20
|
+
# Stalled-delivery reaper: WHERE state = 'delivering' AND last_attempt_at < ?.
|
|
21
|
+
t.index [:state, :last_attempt_at], name: "idx_angarium_deliveries_on_state_last_attempt"
|
|
11
22
|
end
|
|
12
23
|
end
|
|
13
24
|
end
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
class CreateAngariumDeliveryAttempts < ActiveRecord::Migration[7.1]
|
|
2
2
|
def change
|
|
3
3
|
create_table :angarium_delivery_attempts, id: Angarium.primary_key_type do |t|
|
|
4
|
-
|
|
4
|
+
# index: false — the delivery-scoped attempt list orders by created_at, so
|
|
5
|
+
# the composite index below covers both the FK lookup and the list order.
|
|
6
|
+
t.references :delivery, index: false, null: false, type: Angarium.primary_key_type, foreign_key: {to_table: :angarium_deliveries}
|
|
5
7
|
t.integer :response_code
|
|
6
8
|
t.text :response_body
|
|
7
9
|
t.string :error
|
|
8
10
|
t.float :duration
|
|
9
11
|
t.timestamps
|
|
12
|
+
# Delivery-scoped attempt list (WHERE delivery_id ORDER BY created_at DESC).
|
|
13
|
+
t.index [:delivery_id, :created_at], name: "idx_angarium_attempts_on_delivery_created_at"
|
|
14
|
+
# Retention prune: DELETE WHERE created_at < cutoff over the largest table.
|
|
15
|
+
t.index :created_at, name: "idx_angarium_attempts_on_created_at"
|
|
10
16
|
end
|
|
11
17
|
end
|
|
12
18
|
end
|
|
@@ -45,17 +45,50 @@ module Angarium
|
|
|
45
45
|
literal = to_ipaddr(host)
|
|
46
46
|
return [literal] if literal
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
# Bound DNS with config.dns_timeout via an explicit Resolv::DNS — an
|
|
49
|
+
# unbounded lookup lets a host with a deliberately slow authoritative
|
|
50
|
+
# nameserver tie up a delivery worker well past our HTTP timeouts. With the
|
|
51
|
+
# hosts file enabled (default), wrap it in a Resolv that consults
|
|
52
|
+
# /etc/hosts first (so an internally-pinned endpoint resolves, and a hosts
|
|
53
|
+
# hit short-circuits DNS); otherwise resolve DNS-only.
|
|
54
|
+
dns = Resolv::DNS.new
|
|
55
|
+
dns.timeouts = Angarium.config.dns_timeout if Angarium.config.dns_timeout
|
|
56
|
+
resolvers = Angarium.config.resolve_dns_with_hosts_file ? [Resolv::Hosts.new, dns] : [dns]
|
|
57
|
+
Resolv.new(resolvers).getaddresses(host).filter_map { |a| to_ipaddr(a.to_s) }
|
|
49
58
|
rescue
|
|
50
59
|
[]
|
|
60
|
+
ensure
|
|
61
|
+
dns&.close
|
|
51
62
|
end
|
|
52
63
|
|
|
64
|
+
# IANA special-use ranges that are unsafe webhook destinations but that
|
|
65
|
+
# Ruby's IPAddr predicates (loopback?/private?/link_local?/unique_local?) do
|
|
66
|
+
# NOT flag. Without these, e.g. `https://0.0.0.1/` (routes to localhost on
|
|
67
|
+
# Linux) or `https://[64:ff9b::a9fe:a9fe]/` (NAT64 of the cloud metadata IP)
|
|
68
|
+
# would pass validation and be delivered to. Documentation ranges
|
|
69
|
+
# (192.0.2.0/24, 2001:db8::/32, etc.) are intentionally omitted: they aren't
|
|
70
|
+
# an SSRF vector and blocking them would reject the TEST-NET fixtures.
|
|
71
|
+
SPECIAL_USE_RANGES = [
|
|
72
|
+
"0.0.0.0/8", # "this host" — routes to localhost on Linux
|
|
73
|
+
"100.64.0.0/10", # CGNAT (RFC 6598)
|
|
74
|
+
"192.0.0.0/24", # IETF protocol assignments (RFC 6890)
|
|
75
|
+
"198.18.0.0/15", # benchmarking (RFC 2544)
|
|
76
|
+
"240.0.0.0/4", # reserved / future use, incl. 255.255.255.255 broadcast
|
|
77
|
+
"64:ff9b::/96", # NAT64 well-known prefix (RFC 6052)
|
|
78
|
+
"64:ff9b:1::/48" # NAT64 local-use prefix (RFC 8215)
|
|
79
|
+
].map { |cidr| IPAddr.new(cidr) }.freeze
|
|
80
|
+
|
|
53
81
|
def private?(ip)
|
|
54
82
|
ip = normalize(ip)
|
|
55
83
|
return true if ip.to_i.zero? # 0.0.0.0 / :: (unspecified -> localhost on Linux)
|
|
56
84
|
|
|
57
85
|
ip.loopback? || ip.private? || ip.link_local? ||
|
|
58
|
-
(ip.respond_to?(:unique_local?) && ip.unique_local?)
|
|
86
|
+
(ip.respond_to?(:unique_local?) && ip.unique_local?) ||
|
|
87
|
+
special_use?(ip)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def special_use?(ip)
|
|
91
|
+
SPECIAL_USE_RANGES.any? { |range| range.family == ip.family && range.include?(ip) }
|
|
59
92
|
end
|
|
60
93
|
|
|
61
94
|
# Collapse IPv4-mapped IPv6 (::ffff:x.x.x.x) to native IPv4 so the predicates
|
data/lib/angarium/client.rb
CHANGED
|
@@ -25,6 +25,10 @@ module Angarium
|
|
|
25
25
|
headers: {})
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Cap the response we hold in memory. The body is receiver-controlled and
|
|
29
|
+
# may not be persistable text (non-UTF-8 bytes, a NUL, or a multibyte char
|
|
30
|
+
# this byte cap splits); DeliveryAttempt#normalizes sanitizes it at
|
|
31
|
+
# persistence time, so this stays a pure transport concern.
|
|
28
32
|
max = Angarium.config.max_response_body_bytes
|
|
29
33
|
body = response.body.to_s
|
|
30
34
|
body = body.byteslice(0, max) if max
|
|
@@ -5,7 +5,9 @@ module Angarium
|
|
|
5
5
|
:primary_key_type, :database, :connects_to, :max_response_body_bytes,
|
|
6
6
|
:auto_disable_endpoint_after, :respect_retry_after,
|
|
7
7
|
:max_retry_after, :retry_jitter, :signing_secret_grace_period,
|
|
8
|
-
:delivery_attempt_retention, :delivering_timeout,
|
|
8
|
+
:delivery_attempt_retention, :delivering_timeout, :dns_timeout,
|
|
9
|
+
:resolve_dns_with_hosts_file, :max_url_length, :max_subscribed_events,
|
|
10
|
+
:ping_event_name,
|
|
9
11
|
:on_delivery_exhausted, :on_endpoint_deactivated, :on_endpoint_verified,
|
|
10
12
|
:parent_controller, :current_user, :policy_class
|
|
11
13
|
|
|
@@ -34,6 +36,10 @@ module Angarium
|
|
|
34
36
|
# Takes precedence over @database for the connection.
|
|
35
37
|
@connects_to = nil
|
|
36
38
|
@max_response_body_bytes = 65_536
|
|
39
|
+
# Disable an endpoint after this many consecutive *failed deliveries* (a
|
|
40
|
+
# delivery that exhausts its whole retry schedule, or is blocked by the SSRF
|
|
41
|
+
# guard) — NOT individual failed HTTP attempts. A single delivery that
|
|
42
|
+
# retries and eventually gives up counts as one. nil disables auto-disable.
|
|
37
43
|
@auto_disable_endpoint_after = nil
|
|
38
44
|
@respect_retry_after = true
|
|
39
45
|
@max_retry_after = 3600
|
|
@@ -41,6 +47,24 @@ module Angarium
|
|
|
41
47
|
@signing_secret_grace_period = 24.hours
|
|
42
48
|
@delivery_attempt_retention = nil
|
|
43
49
|
@delivering_timeout = 15.minutes
|
|
50
|
+
# Per-try timeout(s), in seconds, for resolving an endpoint host before
|
|
51
|
+
# delivery. Bounds how long a hostile or misconfigured slow-resolving host
|
|
52
|
+
# can stall a delivery worker (Resolv::DNS retries across the array). Set to
|
|
53
|
+
# nil to use the resolver's own defaults.
|
|
54
|
+
@dns_timeout = [1, 3]
|
|
55
|
+
# Whether host resolution also consults the system hosts file (/etc/hosts),
|
|
56
|
+
# not just DNS. Default true, so an internal endpoint pinned via /etc/hosts
|
|
57
|
+
# resolves as expected. Set false to harden a deployment to DNS-only.
|
|
58
|
+
@resolve_dns_with_hosts_file = true
|
|
59
|
+
# Max length of an endpoint URL and the cap on how many event patterns an
|
|
60
|
+
# endpoint may subscribe to (each pattern is replayed by EventMatcher on
|
|
61
|
+
# every dispatch). Both bound user-supplied input.
|
|
62
|
+
@max_url_length = 2048
|
|
63
|
+
@max_subscribed_events = 100
|
|
64
|
+
# Event name of the synthetic event emitted by Endpoint#ping!. Configurable
|
|
65
|
+
# so it won't collide with an application that already uses "ping" for its
|
|
66
|
+
# own events (and so pings can be routed/filtered distinctly).
|
|
67
|
+
@ping_event_name = "ping"
|
|
44
68
|
@on_delivery_exhausted = nil # ->(delivery) { ... }
|
|
45
69
|
@on_endpoint_deactivated = nil # ->(endpoint, reason) { ... } reason: :consecutive_failures | :gone
|
|
46
70
|
@on_endpoint_verified = nil # ->(endpoint) { ... } fired when an unverified endpoint is verified
|
data/lib/angarium/dispatch.rb
CHANGED
|
@@ -5,9 +5,13 @@ module Angarium
|
|
|
5
5
|
def call(event_name, payload, owner:)
|
|
6
6
|
notify_payload = {event: event_name, event_id: nil, deliveries: 0}
|
|
7
7
|
ActiveSupport::Notifications.instrument("dispatch.angarium", notify_payload) do
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
# Load only the columns subscription matching needs. Endpoints carry
|
|
9
|
+
# large encrypted blobs (signing_secret, previous_signing_secret,
|
|
10
|
+
# custom_headers); pulling them for every candidate — most of which may
|
|
11
|
+
# not even match — is wasted I/O. The matched endpoints' secrets are
|
|
12
|
+
# loaded later, at delivery time, from the delivery's own association.
|
|
13
|
+
candidates = Endpoint.enabled.where(owner: owner).select(:id, :subscribed_events)
|
|
14
|
+
endpoints = candidates.to_a.select { |endpoint| endpoint.subscribed_to?(event_name) }
|
|
11
15
|
next nil if endpoints.empty?
|
|
12
16
|
|
|
13
17
|
Event.transaction do
|
data/lib/angarium/version.rb
CHANGED
data/lib/angarium.rb
CHANGED
|
@@ -38,7 +38,11 @@ module Angarium
|
|
|
38
38
|
|
|
39
39
|
callback.call(*args)
|
|
40
40
|
rescue => e
|
|
41
|
+
# A raising callback must never break the pipeline, but swallowing it with
|
|
42
|
+
# only a log hides a real bug from error tracking. Log and report it (handled,
|
|
43
|
+
# so delivery continues) so it surfaces where the operator will see it.
|
|
41
44
|
Rails.logger.error { "[Angarium] #{callback_name} callback raised: #{e.class}: #{e.message}" }
|
|
45
|
+
Rails.error.report(e, handled: true, severity: :error, source: "angarium", context: {callback: callback_name})
|
|
42
46
|
end
|
|
43
47
|
end
|
|
44
48
|
end
|
|
@@ -23,6 +23,10 @@ class <%= class_name %> < Angarium::Api::Policy
|
|
|
23
23
|
# def permit_allow_private_network? = current_user.admin?
|
|
24
24
|
# def permit_allowed_networks? = true
|
|
25
25
|
|
|
26
|
+
# Include owner_type/owner_id in serialized endpoints (default off). Turn on
|
|
27
|
+
# for an admin/multi-tenant console that lists endpoints across owners.
|
|
28
|
+
# def expose_owner? = current_user.admin?
|
|
29
|
+
|
|
26
30
|
# Per-action permissions. rotate_secret?/pause?/enable?/ping?/redeliver? all
|
|
27
31
|
# default to update?.
|
|
28
32
|
# def index? = true
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: angarium
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- TheDumbTechGuy
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|