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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +65 -0
  3. data/MIT-LICENSE +22 -0
  4. data/README.md +910 -0
  5. data/Rakefile +13 -0
  6. data/SECURITY.md +51 -0
  7. data/app/assets/config/angarium_manifest.js +1 -0
  8. data/app/assets/stylesheets/angarium/application.css +15 -0
  9. data/app/controllers/angarium/api/attempts_controller.rb +12 -0
  10. data/app/controllers/angarium/api/base_controller.rb +133 -0
  11. data/app/controllers/angarium/api/deliveries_controller.rb +29 -0
  12. data/app/controllers/angarium/api/endpoints_controller.rb +117 -0
  13. data/app/controllers/angarium/api/not_authorized.rb +7 -0
  14. data/app/controllers/angarium/api/unpermitted_parameter.rb +15 -0
  15. data/app/helpers/angarium/application_helper.rb +4 -0
  16. data/app/jobs/angarium/application_job.rb +5 -0
  17. data/app/jobs/angarium/deliver_job.rb +11 -0
  18. data/app/models/angarium/application_record.rb +17 -0
  19. data/app/models/angarium/delivery.rb +254 -0
  20. data/app/models/angarium/delivery_attempt.rb +14 -0
  21. data/app/models/angarium/endpoint.rb +217 -0
  22. data/app/models/angarium/event.rb +7 -0
  23. data/app/policies/angarium/api/policy.rb +78 -0
  24. data/app/validators/angarium/endpoint_url_validator.rb +20 -0
  25. data/app/views/layouts/angarium/application.html.erb +15 -0
  26. data/config/routes.rb +22 -0
  27. data/db/angarium_migrate/20260704000001_create_angarium_endpoints.rb +22 -0
  28. data/db/angarium_migrate/20260704000002_create_angarium_events.rb +9 -0
  29. data/db/angarium_migrate/20260704000003_create_angarium_deliveries.rb +13 -0
  30. data/db/angarium_migrate/20260704000004_create_angarium_delivery_attempts.rb +12 -0
  31. data/lib/angarium/address_policy.rb +82 -0
  32. data/lib/angarium/client.rb +53 -0
  33. data/lib/angarium/configuration.rb +66 -0
  34. data/lib/angarium/dispatch.rb +23 -0
  35. data/lib/angarium/engine.rb +5 -0
  36. data/lib/angarium/event_matcher.rb +17 -0
  37. data/lib/angarium/signature.rb +57 -0
  38. data/lib/angarium/version.rb +3 -0
  39. data/lib/angarium.rb +44 -0
  40. data/lib/generators/angarium/install/install_generator.rb +57 -0
  41. data/lib/generators/angarium/install/templates/initializer.rb +77 -0
  42. data/lib/generators/angarium/migrations/migrations_generator.rb +39 -0
  43. data/lib/generators/angarium/policy/policy_generator.rb +40 -0
  44. data/lib/generators/angarium/policy/templates/policy.rb +33 -0
  45. data/lib/tasks/angarium_tasks.rake +15 -0
  46. 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,7 @@
1
+ module Angarium
2
+ class Event < ApplicationRecord
3
+ has_many :deliveries, class_name: "Angarium::Delivery", dependent: :destroy
4
+
5
+ validates :name, presence: true
6
+ end
7
+ 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
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Angarium</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= stylesheet_link_tag "angarium/application", media: "all" %>
9
+ </head>
10
+ <body>
11
+
12
+ <%= yield %>
13
+
14
+ </body>
15
+ </html>
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,9 @@
1
+ class CreateAngariumEvents < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :angarium_events, id: Angarium.primary_key_type do |t|
4
+ t.string :name, null: false
5
+ t.json :payload, null: false, default: {}
6
+ t.timestamps
7
+ end
8
+ end
9
+ 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