angarium 0.1.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 +7 -0
- data/CHANGELOG.md +65 -0
- data/MIT-LICENSE +22 -0
- data/README.md +910 -0
- data/Rakefile +13 -0
- data/SECURITY.md +51 -0
- data/app/assets/config/angarium_manifest.js +1 -0
- data/app/assets/stylesheets/angarium/application.css +15 -0
- data/app/controllers/angarium/api/attempts_controller.rb +12 -0
- data/app/controllers/angarium/api/base_controller.rb +133 -0
- data/app/controllers/angarium/api/deliveries_controller.rb +29 -0
- data/app/controllers/angarium/api/endpoints_controller.rb +117 -0
- data/app/controllers/angarium/api/not_authorized.rb +7 -0
- data/app/controllers/angarium/api/unpermitted_parameter.rb +15 -0
- data/app/helpers/angarium/application_helper.rb +4 -0
- data/app/jobs/angarium/application_job.rb +5 -0
- data/app/jobs/angarium/deliver_job.rb +11 -0
- data/app/models/angarium/application_record.rb +17 -0
- data/app/models/angarium/delivery.rb +254 -0
- data/app/models/angarium/delivery_attempt.rb +14 -0
- data/app/models/angarium/endpoint.rb +217 -0
- data/app/models/angarium/event.rb +7 -0
- data/app/policies/angarium/api/policy.rb +78 -0
- data/app/validators/angarium/endpoint_url_validator.rb +20 -0
- data/app/views/layouts/angarium/application.html.erb +15 -0
- data/config/routes.rb +22 -0
- data/db/angarium_migrate/20260704000001_create_angarium_endpoints.rb +22 -0
- data/db/angarium_migrate/20260704000002_create_angarium_events.rb +9 -0
- data/db/angarium_migrate/20260704000003_create_angarium_deliveries.rb +13 -0
- data/db/angarium_migrate/20260704000004_create_angarium_delivery_attempts.rb +12 -0
- data/lib/angarium/address_policy.rb +82 -0
- data/lib/angarium/client.rb +53 -0
- data/lib/angarium/configuration.rb +66 -0
- data/lib/angarium/dispatch.rb +23 -0
- data/lib/angarium/engine.rb +5 -0
- data/lib/angarium/event_matcher.rb +17 -0
- data/lib/angarium/signature.rb +57 -0
- data/lib/angarium/version.rb +3 -0
- data/lib/angarium.rb +44 -0
- data/lib/generators/angarium/install/install_generator.rb +57 -0
- data/lib/generators/angarium/install/templates/initializer.rb +77 -0
- data/lib/generators/angarium/migrations/migrations_generator.rb +39 -0
- data/lib/generators/angarium/policy/policy_generator.rb +40 -0
- data/lib/generators/angarium/policy/templates/policy.rb +33 -0
- data/lib/tasks/angarium_tasks.rake +15 -0
- metadata +137 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module Angarium
|
|
4
|
+
class Delivery < ApplicationRecord
|
|
5
|
+
# Delivery lifecycle. Terminal: succeeded, exhausted, blocked (SSRF),
|
|
6
|
+
# gone (410), canceled (endpoint no longer accepting deliveries at attempt time).
|
|
7
|
+
enum :state, {
|
|
8
|
+
pending: "pending", delivering: "delivering", succeeded: "succeeded",
|
|
9
|
+
exhausted: "exhausted", blocked: "blocked", gone: "gone", canceled: "canceled"
|
|
10
|
+
}, default: :pending
|
|
11
|
+
|
|
12
|
+
belongs_to :event, class_name: "Angarium::Event"
|
|
13
|
+
belongs_to :endpoint, class_name: "Angarium::Endpoint"
|
|
14
|
+
has_many :delivery_attempts, class_name: "Angarium::DeliveryAttempt", dependent: :destroy
|
|
15
|
+
|
|
16
|
+
# Transient: set on a delivery built for a manual, forced send (e.g.
|
|
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) }
|
|
22
|
+
|
|
23
|
+
# Recover deliveries stranded in "delivering": a worker set the state to
|
|
24
|
+
# "delivering" (in #deliver!) but died (crash, deploy, OOM) before
|
|
25
|
+
# recording the attempt or rescheduling, so the job's `pending?` guard never
|
|
26
|
+
# re-runs it. Anything still "delivering" whose last attempt started before
|
|
27
|
+
# `older_than.ago` is presumed abandoned and reset to "pending" + re-enqueued.
|
|
28
|
+
# Returns the number requeued. Keep `older_than` well above a single attempt's
|
|
29
|
+
# worst-case duration (open_timeout + http_timeout) so a live-but-slow worker
|
|
30
|
+
# isn't reaped; a redelivery is at-least-once-safe regardless.
|
|
31
|
+
def self.reap_stalled(older_than: Angarium.config.delivering_timeout)
|
|
32
|
+
return 0 unless older_than
|
|
33
|
+
|
|
34
|
+
ids = where(state: "delivering").where(last_attempt_at: ..older_than.ago).pluck(:id)
|
|
35
|
+
return 0 if ids.empty?
|
|
36
|
+
|
|
37
|
+
where(id: ids).update_all(state: "pending", next_attempt_at: Time.current, updated_at: Time.current)
|
|
38
|
+
ids.each { |id| DeliverJob.perform_later(id) }
|
|
39
|
+
ids.size
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Performs one attempt. Records a DeliveryAttempt, then transitions to
|
|
43
|
+
# succeeded, blocked (SSRF), schedules a retry, or exhausts. Returns the attempt.
|
|
44
|
+
def deliver!(client: Client.new, force: false)
|
|
45
|
+
payload = {delivery_id: id, endpoint_id: endpoint_id, event: event.name, force: force}
|
|
46
|
+
ActiveSupport::Notifications.instrument("deliver.angarium", payload) do
|
|
47
|
+
# An endpoint's status can change after a delivery is queued: auto-disable
|
|
48
|
+
# partway through a retry cycle, an operator pause!, or a 410 from a sibling
|
|
49
|
+
# delivery. The dispatch-time `enabled` filter only gates delivery creation,
|
|
50
|
+
# not queued retries, so re-check here before attempting. `force: true`
|
|
51
|
+
# (a manual ping!/redeliver!) overrides the guard for this one attempt, so
|
|
52
|
+
# you can test an endpoint before re-enabling it; any retry it schedules
|
|
53
|
+
# follows the normal status rules again.
|
|
54
|
+
unless force
|
|
55
|
+
if endpoint.paused?
|
|
56
|
+
payload[:outcome] = :held
|
|
57
|
+
return hold_for_pause!
|
|
58
|
+
end
|
|
59
|
+
unless endpoint.enabled?
|
|
60
|
+
payload[:outcome] = :canceled
|
|
61
|
+
return cancel!(reason: endpoint.status)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
update!(state: "delivering", attempt_count: attempt_count + 1, last_attempt_at: Time.current)
|
|
66
|
+
payload[:attempt] = attempt_count
|
|
67
|
+
|
|
68
|
+
# Re-resolve at delivery time (rather than trusting the save-time check)
|
|
69
|
+
# to catch DNS rebinding: a host that now resolves to a private/disallowed
|
|
70
|
+
# IP is blocked even if it was fine when the endpoint was saved. Any
|
|
71
|
+
# disallowed resolved address is a terminal block.
|
|
72
|
+
addresses = AddressPolicy.resolve(destination_host)
|
|
73
|
+
|
|
74
|
+
if addresses.any? { |ip| !AddressPolicy.ip_allowed?(ip, endpoint) }
|
|
75
|
+
payload[:outcome] = :blocked
|
|
76
|
+
payload[:error] = "blocked: destination address not permitted"
|
|
77
|
+
attempt = delivery_attempts.create!(error: payload[:error])
|
|
78
|
+
update!(state: "blocked", next_attempt_at: nil)
|
|
79
|
+
endpoint.record_delivery_failure!
|
|
80
|
+
return attempt
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Fail closed: our resolver is the single source of truth. If we can't
|
|
84
|
+
# resolve the host, do NOT let HTTPX resolve it unvalidated; record a
|
|
85
|
+
# retryable failure. A transient DNS blip is retried; a persistently
|
|
86
|
+
# unresolvable host eventually exhausts.
|
|
87
|
+
if addresses.empty?
|
|
88
|
+
payload[:outcome] = :unresolvable
|
|
89
|
+
payload[:error] = "unresolvable host: #{destination_host}"
|
|
90
|
+
attempt = delivery_attempts.create!(error: payload[:error])
|
|
91
|
+
handle_failure!
|
|
92
|
+
return attempt
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
body = request_body
|
|
96
|
+
ts = Time.now.to_i
|
|
97
|
+
webhook_id = id.to_s
|
|
98
|
+
signature = Signature.sign(payload: body, id: webhook_id, timestamp: ts, secret: endpoint.active_signing_secrets)
|
|
99
|
+
headers = (endpoint.custom_headers || {}).merge(
|
|
100
|
+
"webhook-id" => webhook_id,
|
|
101
|
+
"webhook-timestamp" => ts.to_s,
|
|
102
|
+
"webhook-signature" => signature
|
|
103
|
+
)
|
|
104
|
+
result = client.post(
|
|
105
|
+
endpoint.url,
|
|
106
|
+
body: body,
|
|
107
|
+
headers: headers,
|
|
108
|
+
# Pin the connection to exactly the IP(s) we just validated, so HTTPX
|
|
109
|
+
# can't re-resolve and connect somewhere else after our check (the
|
|
110
|
+
# rebinding window). TLS SNI/cert verification still uses the URL's
|
|
111
|
+
# host. `addresses` is guaranteed non-empty here (the fail-closed
|
|
112
|
+
# branch above returned early otherwise), so the connection always pins.
|
|
113
|
+
addresses: addresses.map(&:to_s)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
attempt = delivery_attempts.create!(
|
|
117
|
+
response_code: result.code,
|
|
118
|
+
response_body: result.body,
|
|
119
|
+
error: result.error,
|
|
120
|
+
duration: result.duration
|
|
121
|
+
)
|
|
122
|
+
payload[:code] = result.code
|
|
123
|
+
payload[:http_duration] = result.duration
|
|
124
|
+
payload[:error] = result.error
|
|
125
|
+
|
|
126
|
+
# Status handling follows the Standard Webhooks receiver-etiquette guidance:
|
|
127
|
+
# 2xx -> success
|
|
128
|
+
# 410 Gone -> the receiver wants no more webhooks: disable + stop (terminal)
|
|
129
|
+
# everything else (3xx, 429, 5xx, ...) -> retryable failure. 429/502/504
|
|
130
|
+
# ("throttle" codes) are retried with backoff and honor Retry-After.
|
|
131
|
+
if result.success?
|
|
132
|
+
payload[:outcome] = :delivered
|
|
133
|
+
succeed!
|
|
134
|
+
elsif result.code == 410
|
|
135
|
+
payload[:outcome] = :gone
|
|
136
|
+
handle_gone!
|
|
137
|
+
else
|
|
138
|
+
payload[:outcome] = :failed
|
|
139
|
+
handle_failure!(retry_after: retry_after_seconds(result.headers))
|
|
140
|
+
end
|
|
141
|
+
attempt
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Reset the retry cycle and re-enqueue immediately. Keeps prior
|
|
146
|
+
# DeliveryAttempt history. `force: true` sends even if the endpoint is no
|
|
147
|
+
# longer enabled (for the re-enqueued attempt). Returns self.
|
|
148
|
+
def redeliver!(force: false)
|
|
149
|
+
update!(state: "pending", next_attempt_at: nil, attempt_count: 0)
|
|
150
|
+
force ? DeliverJob.perform_later(id, true) : DeliverJob.perform_later(id)
|
|
151
|
+
self
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# Endpoint was paused after this delivery was queued. Park it (no attempt
|
|
157
|
+
# consumed, no failure recorded) by clearing the schedule and leaving it
|
|
158
|
+
# pending; Endpoint#enable! re-enqueues parked deliveries so a pause/resume
|
|
159
|
+
# cycle doesn't lose them. Returns nil (no attempt was made).
|
|
160
|
+
def hold_for_pause!
|
|
161
|
+
update!(state: "pending", next_attempt_at: nil)
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Endpoint reached a terminal state (disabled or gone) after this delivery was
|
|
166
|
+
# queued. Stop retrying and log why. Recover with redeliver! if the endpoint is
|
|
167
|
+
# later re-enabled. Returns the logged attempt.
|
|
168
|
+
def cancel!(reason:)
|
|
169
|
+
attempt = delivery_attempts.create!(error: "canceled: endpoint #{reason}")
|
|
170
|
+
update!(state: "canceled", next_attempt_at: nil)
|
|
171
|
+
attempt
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def succeed!
|
|
175
|
+
update!(state: "succeeded", next_attempt_at: nil)
|
|
176
|
+
endpoint.record_delivery_success!
|
|
177
|
+
# A successful delivery to an unverified endpoint (a forced ping!) proves it
|
|
178
|
+
# can receive webhooks, so verify it. No-op for any other status.
|
|
179
|
+
endpoint.verify!
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# HTTP 410 Gone: the receiver is explicitly done with this endpoint. Per the
|
|
183
|
+
# Standard Webhooks guidance, disable the endpoint (no further deliveries) and
|
|
184
|
+
# mark this delivery terminal, with no retries.
|
|
185
|
+
def handle_gone!
|
|
186
|
+
update!(state: "gone", next_attempt_at: nil)
|
|
187
|
+
endpoint.deactivate!(reason: :gone)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def handle_failure!(retry_after: nil)
|
|
191
|
+
schedule = Array(Angarium.config.retry_schedule)
|
|
192
|
+
base = schedule[attempt_count - 1] # attempt_count already incremented for this attempt
|
|
193
|
+
|
|
194
|
+
if base.nil?
|
|
195
|
+
update!(state: "exhausted")
|
|
196
|
+
endpoint.record_delivery_failure!
|
|
197
|
+
Angarium.notify(:on_delivery_exhausted, self)
|
|
198
|
+
else
|
|
199
|
+
wait = jittered(base)
|
|
200
|
+
# Honor Retry-After only when it asks us to wait LONGER than our own
|
|
201
|
+
# backoff: take the later of the two. A receiver must never be able to
|
|
202
|
+
# pull retries EARLIER than our schedule; otherwise a malicious or
|
|
203
|
+
# misconfigured receiver could send a tiny Retry-After to defeat our
|
|
204
|
+
# backoff and make us hammer it. Retry-After can delay, never expedite.
|
|
205
|
+
wait = [wait, retry_after].max if retry_after
|
|
206
|
+
update!(state: "pending", next_attempt_at: Time.current + wait)
|
|
207
|
+
DeliverJob.set(wait: wait).perform_later(id)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Additive positive jitter to spread retries and avoid thundering herds.
|
|
212
|
+
def jittered(base)
|
|
213
|
+
base + (rand * base.to_f * Angarium.config.retry_jitter)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Honor a receiver's Retry-After header (seconds or an HTTP-date), capped by
|
|
217
|
+
# config.max_retry_after. Returns nil when disabled, absent, or invalid.
|
|
218
|
+
def retry_after_seconds(headers)
|
|
219
|
+
return nil unless Angarium.config.respect_retry_after && headers.present?
|
|
220
|
+
|
|
221
|
+
value = headers["retry-after"]
|
|
222
|
+
return nil unless value
|
|
223
|
+
|
|
224
|
+
seconds =
|
|
225
|
+
if value.match?(/\A\d+\z/)
|
|
226
|
+
value.to_i
|
|
227
|
+
else
|
|
228
|
+
begin
|
|
229
|
+
Time.httpdate(value) - Time.now
|
|
230
|
+
rescue ArgumentError
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
return nil if seconds.nil? || seconds.negative?
|
|
236
|
+
|
|
237
|
+
cap = Angarium.config.max_retry_after
|
|
238
|
+
cap ? [seconds, cap].min : seconds
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def destination_host
|
|
242
|
+
URI.parse(endpoint.url).host
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def request_body
|
|
246
|
+
{
|
|
247
|
+
id: id,
|
|
248
|
+
event: event.name,
|
|
249
|
+
created_at: created_at.iso8601,
|
|
250
|
+
data: event.payload
|
|
251
|
+
}.to_json
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Angarium
|
|
2
|
+
class DeliveryAttempt < ApplicationRecord
|
|
3
|
+
belongs_to :delivery, class_name: "Angarium::Delivery"
|
|
4
|
+
|
|
5
|
+
# Delete delivery attempts older than the cutoff. `older_than` may be a
|
|
6
|
+
# duration (e.g. 90.days) or an absolute Time. Returns the number deleted.
|
|
7
|
+
# (A Time also responds to #ago but requires an argument, so branch on the
|
|
8
|
+
# type rather than duck-typing #ago.)
|
|
9
|
+
def self.prune(older_than:)
|
|
10
|
+
cutoff = older_than.is_a?(ActiveSupport::Duration) ? older_than.ago : older_than
|
|
11
|
+
where(created_at: ...cutoff).delete_all
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "base64"
|
|
3
|
+
|
|
4
|
+
module Angarium
|
|
5
|
+
class Endpoint < ApplicationRecord
|
|
6
|
+
# Headers a user-supplied custom header may never set: the Standard Webhooks
|
|
7
|
+
# signature headers (which must not be spoofable) and transport/hop-by-hop
|
|
8
|
+
# headers whose override invites request smuggling or receiver confusion.
|
|
9
|
+
RESERVED_HEADERS = %w[
|
|
10
|
+
webhook-id webhook-timestamp webhook-signature
|
|
11
|
+
host content-length content-type transfer-encoding connection
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
belongs_to :owner, polymorphic: true
|
|
15
|
+
has_many :deliveries, class_name: "Angarium::Delivery", dependent: :destroy
|
|
16
|
+
|
|
17
|
+
# Encrypted at rest: the signing secret(s) and custom_headers; the latter
|
|
18
|
+
# commonly carries a receiver credential (e.g. an Authorization bearer token),
|
|
19
|
+
# so it gets the same protection as the signing secret.
|
|
20
|
+
encrypts :signing_secret, :previous_signing_secret
|
|
21
|
+
encrypts :custom_headers
|
|
22
|
+
|
|
23
|
+
# Lifecycle status. `enabled` endpoints receive deliveries; the rest don't.
|
|
24
|
+
# unverified: created but not yet proven; verified by a successful delivery
|
|
25
|
+
# (a forced #ping!) or #verify!, which moves it to `enabled`
|
|
26
|
+
# paused: turned off manually (resumable via #enable!)
|
|
27
|
+
# disabled: auto-disabled after too many consecutive failures (resumable)
|
|
28
|
+
# gone: the receiver returned HTTP 410; treat as terminal
|
|
29
|
+
enum :status, {enabled: "enabled", paused: "paused", disabled: "disabled", gone: "gone", unverified: "unverified"},
|
|
30
|
+
default: :enabled
|
|
31
|
+
|
|
32
|
+
# Rails < 8.1's SQLite adapter doesn't parse the `DEFAULT FALSE` literal
|
|
33
|
+
# SQLite reports back for boolean columns (fixed in
|
|
34
|
+
# ActiveRecord::ConnectionAdapters::SQLite3::SchemaStatements
|
|
35
|
+
# #extract_value_from_default starting in Rails 8.1), leaving this attribute
|
|
36
|
+
# nil on a new record instead of the schema default. Declaring the default
|
|
37
|
+
# here keeps behavior correct on every supported Rails version.
|
|
38
|
+
attribute :allow_private_network, :boolean, default: false
|
|
39
|
+
|
|
40
|
+
before_validation :ensure_signing_secret, on: :create
|
|
41
|
+
|
|
42
|
+
validates :name, presence: true
|
|
43
|
+
validates :url, presence: true
|
|
44
|
+
validates :url, "angarium/endpoint_url": true, if: :verify_url_address?
|
|
45
|
+
validate :allowed_networks_are_valid_cidrs
|
|
46
|
+
validate :custom_headers_are_strings
|
|
47
|
+
|
|
48
|
+
def self.generate_signing_secret
|
|
49
|
+
"whsec_#{Base64.strict_encode64(SecureRandom.bytes(32))}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def subscribed_to?(event_name)
|
|
53
|
+
Array(subscribed_events).any? { |pattern| EventMatcher.match?(pattern, event_name) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Secrets a receiver may currently hold: always the active secret, plus the
|
|
57
|
+
# previous one while it's still within the rotation grace window. Deliveries
|
|
58
|
+
# sign with every returned secret so receivers can roll over with zero
|
|
59
|
+
# downtime.
|
|
60
|
+
def active_signing_secrets
|
|
61
|
+
secrets = [signing_secret]
|
|
62
|
+
if previous_signing_secret.present? && secret_rotated_at.present? &&
|
|
63
|
+
secret_rotated_at > Angarium.config.signing_secret_grace_period.ago
|
|
64
|
+
secrets << previous_signing_secret
|
|
65
|
+
end
|
|
66
|
+
secrets
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Rotate the signing secret and persist. Returns the new plaintext secret.
|
|
70
|
+
# The previous secret stays valid for `config.signing_secret_grace_period`
|
|
71
|
+
# (deliveries are signed with both during that window), so receivers can
|
|
72
|
+
# roll over to the new secret with zero downtime.
|
|
73
|
+
def rotate_secret!
|
|
74
|
+
update!(
|
|
75
|
+
previous_signing_secret: signing_secret,
|
|
76
|
+
signing_secret: self.class.generate_signing_secret,
|
|
77
|
+
secret_rotated_at: Time.current
|
|
78
|
+
)
|
|
79
|
+
signing_secret
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def record_delivery_success!
|
|
83
|
+
# Reset from the database, not this (possibly stale) in-memory copy: a
|
|
84
|
+
# concurrent failed delivery may have raised the counter since we loaded.
|
|
85
|
+
# The WHERE skips a write when it is already zero.
|
|
86
|
+
self.class.where(id: id).where.not(consecutive_failures: 0).update_all(consecutive_failures: 0)
|
|
87
|
+
self.consecutive_failures = 0
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def record_delivery_failure!
|
|
91
|
+
# Atomic increment. Without it, concurrent deliveries to the same endpoint
|
|
92
|
+
# each read a stale counter and lose increments (last write wins), so
|
|
93
|
+
# auto-disable would undercount. Reload to read the true post-increment
|
|
94
|
+
# count before deciding whether to disable.
|
|
95
|
+
self.class.update_counters(id, consecutive_failures: 1)
|
|
96
|
+
reload
|
|
97
|
+
|
|
98
|
+
threshold = Angarium.config.auto_disable_endpoint_after
|
|
99
|
+
deactivate!(reason: :consecutive_failures) if threshold && consecutive_failures >= threshold
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Move to a non-delivering state (no further deliveries) and fire the
|
|
103
|
+
# on_endpoint_deactivated callback once. `reason` maps to the target status:
|
|
104
|
+
# :consecutive_failures -> disabled (auto-disable, only from `enabled`)
|
|
105
|
+
# :gone -> gone (receiver returned HTTP 410; overrides
|
|
106
|
+
# any non-gone status, so a racing
|
|
107
|
+
# auto-disable can't clobber a 410)
|
|
108
|
+
def deactivate!(reason:)
|
|
109
|
+
target, from = (reason == :gone) ? [:gone, nil] : [:disabled, :enabled]
|
|
110
|
+
transition_status!(target, from: from) { Angarium.notify(:on_endpoint_deactivated, self, reason) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Pause deliveries manually. Resumable via #enable!.
|
|
114
|
+
def pause!
|
|
115
|
+
transition_status!(:paused)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# (Re-)enable deliveries and clear the failure counter. Works from any state,
|
|
119
|
+
# including `gone` (an explicit operator override of a receiver's 410).
|
|
120
|
+
def enable!
|
|
121
|
+
return false unless transition_status!(:enabled, consecutive_failures: 0)
|
|
122
|
+
# Resume deliveries parked while this endpoint was paused (pending with no
|
|
123
|
+
# scheduled attempt). Dispatch creates none while paused, so this only
|
|
124
|
+
# re-enqueues the held ones.
|
|
125
|
+
deliveries.where(state: "pending", next_attempt_at: nil).find_each { |d| DeliverJob.perform_later(d.id) }
|
|
126
|
+
true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Promote an `unverified` endpoint to `enabled` once it has proven it can
|
|
130
|
+
# receive webhooks. A no-op on any other status: a `disabled`/`gone` endpoint
|
|
131
|
+
# is revived with #enable!, not verified. Called automatically when a delivery
|
|
132
|
+
# to an unverified endpoint succeeds (a forced #ping!), or manually. Fires
|
|
133
|
+
# on_endpoint_verified exactly once, even under concurrent verification.
|
|
134
|
+
def verify!
|
|
135
|
+
transition_status!(:enabled, from: :unverified) { Angarium.notify(:on_endpoint_verified, self) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Deliver a synthetic `angarium.ping` event to this endpoint, bypassing
|
|
139
|
+
# subscription matching (a ping is always sent). By default a ping also
|
|
140
|
+
# ignores the endpoint's status, so you can test an endpoint that is paused,
|
|
141
|
+
# disabled, or not yet enabled; pass `force: false` to respect the status
|
|
142
|
+
# guard instead. Returns the created Angarium::Delivery, whose
|
|
143
|
+
# after_create_commit enqueues the DeliverJob; reload it to inspect the outcome.
|
|
144
|
+
def ping!(payload = {message: "Angarium ping"}, force: true)
|
|
145
|
+
event = Angarium::Event.create!(name: "angarium.ping", payload: payload)
|
|
146
|
+
deliveries.create!(event: event) { |d| d.force_send = force }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# Atomically move to `target` status, stamping status_changed_at (and any
|
|
152
|
+
# `extra` columns) only on a real change, so a no-op transition writes nothing.
|
|
153
|
+
# With `from:` the change applies only from those source statuses; otherwise
|
|
154
|
+
# from any status but the target. Returns true iff this call made the change,
|
|
155
|
+
# so a caller's block runs exactly once even under concurrent transitions (no
|
|
156
|
+
# double callback). All status transitions go through here to stay consistent.
|
|
157
|
+
def transition_status!(target, from: nil, **extra)
|
|
158
|
+
target = target.to_s
|
|
159
|
+
from &&= Array(from).map(&:to_s)
|
|
160
|
+
# Two independent guards, both always applied: skip if we're already at the
|
|
161
|
+
# target (no redundant write / no spurious callback), and skip if we're not
|
|
162
|
+
# in a permitted source status. The atomic update enforces both at the DB
|
|
163
|
+
# level, so a concurrent transition can't slip past either.
|
|
164
|
+
return false if status == target || from&.exclude?(status)
|
|
165
|
+
|
|
166
|
+
scope = self.class.where(id: id).where.not(status: target)
|
|
167
|
+
scope = scope.where(status: from) if from
|
|
168
|
+
changed = scope.update_all({status: target, status_changed_at: Time.current, updated_at: Time.current}.merge(extra))
|
|
169
|
+
return false if changed.zero?
|
|
170
|
+
|
|
171
|
+
reload
|
|
172
|
+
yield if block_given?
|
|
173
|
+
true
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def ensure_signing_secret
|
|
177
|
+
self.signing_secret ||= self.class.generate_signing_secret
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Re-run the URL + SSRF address check only when something that affects the
|
|
181
|
+
# decision changes: the URL itself, or the SSRF controls (allow_private_network,
|
|
182
|
+
# allowed_networks). This avoids a DNS lookup on unrelated updates (e.g.
|
|
183
|
+
# toggling `active`), while still catching a URL that a tightened policy now
|
|
184
|
+
# disallows. (Reassign allowed_networks to a new array to trigger dirty
|
|
185
|
+
# tracking; in-place mutation isn't detected.)
|
|
186
|
+
def verify_url_address?
|
|
187
|
+
return false if url.blank?
|
|
188
|
+
|
|
189
|
+
new_record? ||
|
|
190
|
+
will_save_change_to_url? ||
|
|
191
|
+
will_save_change_to_allow_private_network? ||
|
|
192
|
+
will_save_change_to_allowed_networks?
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def allowed_networks_are_valid_cidrs
|
|
196
|
+
Array(allowed_networks).reject(&:blank?).each do |cidr|
|
|
197
|
+
IPAddr.new(cidr.to_s)
|
|
198
|
+
rescue IPAddr::InvalidAddressError
|
|
199
|
+
errors.add(:allowed_networks, "contains an invalid CIDR: #{cidr}")
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def custom_headers_are_strings
|
|
204
|
+
return if custom_headers.blank?
|
|
205
|
+
|
|
206
|
+
unless custom_headers.is_a?(Hash) &&
|
|
207
|
+
custom_headers.all? { |k, v| k.is_a?(String) && v.is_a?(String) }
|
|
208
|
+
errors.add(:custom_headers, "must be a hash of string keys and values")
|
|
209
|
+
return
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
if custom_headers.keys.any? { |k| RESERVED_HEADERS.include?(k.downcase) }
|
|
213
|
+
errors.add(:custom_headers, "cannot override reserved or transport headers (#{RESERVED_HEADERS.join(", ")})")
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Angarium
|
|
2
|
+
module Api
|
|
3
|
+
# The single place for API authorization:
|
|
4
|
+
# #scope(relation) narrows a base relation to what this user may see
|
|
5
|
+
# #owner who a newly-created endpoint belongs to
|
|
6
|
+
# #<action>? whether each action is allowed
|
|
7
|
+
#
|
|
8
|
+
# Angarium instantiates config.policy_class per request with the controller
|
|
9
|
+
# and (for member actions) the target record, and runs it in the controller's
|
|
10
|
+
# context, so `current_user`, `params`, and `controller` are available.
|
|
11
|
+
#
|
|
12
|
+
# Defaults are permissive and single-owner: you see and create your own
|
|
13
|
+
# endpoints and may do anything to them. Subclass and override to change the
|
|
14
|
+
# scope, support multi-tenancy or admin-on-behalf-of, or restrict actions.
|
|
15
|
+
class Policy
|
|
16
|
+
attr_reader :controller, :record
|
|
17
|
+
|
|
18
|
+
def initialize(controller, record = nil)
|
|
19
|
+
@controller = controller
|
|
20
|
+
@record = record
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def current_user = controller.angarium_current_user
|
|
24
|
+
def params = controller.params
|
|
25
|
+
|
|
26
|
+
# Narrow the given base relation to the endpoints this user may see and act
|
|
27
|
+
# on (reads and finds go through this; deliveries and attempts scope through
|
|
28
|
+
# their endpoint). Receives a relation so you can compose on top of it.
|
|
29
|
+
def scope(relation)
|
|
30
|
+
relation.where(owner: current_user)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Owner assigned to a newly-created endpoint. Override to let an admin
|
|
34
|
+
# create on behalf of another owner (e.g. read a param), then gate who may
|
|
35
|
+
# do so in #create? via `record.owner`.
|
|
36
|
+
def owner
|
|
37
|
+
current_user
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Should endpoints created through the API start `unverified` (receiving no
|
|
41
|
+
# deliveries until a successful ping verifies them)? Default: no, they go
|
|
42
|
+
# live immediately. Override to require endpoints to prove themselves first.
|
|
43
|
+
def create_unverified?
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# May the API set allow_private_network? Default: no. This *relaxes* SSRF
|
|
48
|
+
# protection (delivery to private/loopback addresses), so an end user who
|
|
49
|
+
# can set it can point a webhook at your internal network. Trusted operators
|
|
50
|
+
# only.
|
|
51
|
+
def permit_allow_private_network?
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# May the API set allowed_networks (a per-endpoint CIDR allowlist)? Default:
|
|
56
|
+
# no. Unlike allow_private_network this only *restricts* where an endpoint
|
|
57
|
+
# may deliver, so it's safe to expose more widely, but it's gated
|
|
58
|
+
# independently so you can allow it without allowing the private-network
|
|
59
|
+
# relaxation.
|
|
60
|
+
def permit_allowed_networks?
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def index? = true
|
|
65
|
+
def show? = true
|
|
66
|
+
def create? = true
|
|
67
|
+
def update? = true
|
|
68
|
+
def destroy? = true
|
|
69
|
+
|
|
70
|
+
def rotate_secret? = update?
|
|
71
|
+
def pause? = update?
|
|
72
|
+
def enable? = update?
|
|
73
|
+
def verify? = update?
|
|
74
|
+
def ping? = update?
|
|
75
|
+
def redeliver? = update?
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Angarium
|
|
2
|
+
class EndpointUrlValidator < ActiveModel::EachValidator
|
|
3
|
+
def validate_each(record, attribute, value)
|
|
4
|
+
uri = begin
|
|
5
|
+
URI.parse(value.to_s)
|
|
6
|
+
rescue URI::InvalidURIError
|
|
7
|
+
nil
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
unless uri.is_a?(URI::HTTPS) && uri.host.present?
|
|
11
|
+
record.errors.add(attribute, "must be a valid https URL")
|
|
12
|
+
return
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
unless AddressPolicy.host_permitted_for_validation?(uri.host, record)
|
|
16
|
+
record.errors.add(attribute, "resolves to a disallowed address")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Angarium::Engine.routes.draw do
|
|
2
|
+
# Headless JSON API. Mount the engine in your app's routes, e.g.
|
|
3
|
+
# mount Angarium::Engine => "/webhooks"
|
|
4
|
+
# yielding /webhooks/endpoints, /webhooks/deliveries/:id, etc.
|
|
5
|
+
scope module: :api, defaults: {format: :json} do
|
|
6
|
+
resources :endpoints, only: %i[index show create update destroy] do
|
|
7
|
+
member do
|
|
8
|
+
post :rotate_secret
|
|
9
|
+
post :pause
|
|
10
|
+
post :enable
|
|
11
|
+
post :verify
|
|
12
|
+
post :ping
|
|
13
|
+
end
|
|
14
|
+
resources :deliveries, only: %i[index]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
resources :deliveries, only: %i[show] do
|
|
18
|
+
member { post :redeliver }
|
|
19
|
+
resources :attempts, only: %i[index]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class CreateAngariumEndpoints < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :angarium_endpoints, id: Angarium.primary_key_type do |t|
|
|
4
|
+
t.references :owner, polymorphic: true, null: false, type: :string
|
|
5
|
+
t.string :name, null: false
|
|
6
|
+
t.string :url, null: false
|
|
7
|
+
t.string :status, null: false, default: "enabled"
|
|
8
|
+
t.text :signing_secret, null: false
|
|
9
|
+
t.json :subscribed_events, null: false, default: []
|
|
10
|
+
t.boolean :allow_private_network, null: false, default: false
|
|
11
|
+
t.json :allowed_networks, null: false, default: []
|
|
12
|
+
# Encrypted at rest (may hold a receiver credential), so it's nullable with
|
|
13
|
+
# no DB default — an unencrypted default would fail to decrypt on read.
|
|
14
|
+
t.json :custom_headers
|
|
15
|
+
t.integer :consecutive_failures, null: false, default: 0
|
|
16
|
+
t.text :previous_signing_secret
|
|
17
|
+
t.datetime :secret_rotated_at
|
|
18
|
+
t.datetime :status_changed_at
|
|
19
|
+
t.timestamps
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class CreateAngariumDeliveries < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :angarium_deliveries, id: Angarium.primary_key_type do |t|
|
|
4
|
+
t.references :event, null: false, type: Angarium.primary_key_type, foreign_key: {to_table: :angarium_events}
|
|
5
|
+
t.references :endpoint, null: false, type: Angarium.primary_key_type, foreign_key: {to_table: :angarium_endpoints}
|
|
6
|
+
t.string :state, null: false, default: "pending"
|
|
7
|
+
t.integer :attempt_count, null: false, default: 0
|
|
8
|
+
t.datetime :last_attempt_at
|
|
9
|
+
t.datetime :next_attempt_at
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|