angarium 0.2.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 +18 -0
- data/README.md +5 -3
- data/SECURITY.md +2 -2
- data/app/controllers/angarium/api/endpoints_controller.rb +12 -2
- data/app/models/angarium/delivery.rb +41 -7
- data/app/models/angarium/delivery_attempt.rb +22 -0
- data/app/models/angarium/endpoint.rb +13 -7
- data/app/models/angarium/event.rb +4 -0
- data/db/angarium_migrate/20260704000001_create_angarium_endpoints.rb +5 -2
- data/db/angarium_migrate/20260704000002_create_angarium_events.rb +3 -1
- data/lib/angarium/client.rb +4 -0
- data/lib/angarium/configuration.rb +5 -0
- data/lib/angarium/version.rb +1 -1
- data/lib/angarium.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,24 @@ 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
|
+
|
|
8
26
|
## [0.2.0] - 2026-07-07
|
|
9
27
|
|
|
10
28
|
### Bug Fixes
|
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 `ping` event
|
|
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!
|
|
@@ -706,6 +707,7 @@ which documents every option inline. The delivery and retry settings:
|
|
|
706
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. |
|
|
707
708
|
| `max_url_length` | `2048` | Maximum length of an endpoint URL. |
|
|
708
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. |
|
|
709
711
|
| `max_response_body_bytes` | `65_536` | Truncate the stored response body; `nil` stores it whole. |
|
|
710
712
|
| `delivery_attempt_retention` | `nil` | Age past which `angarium:prune` deletes attempts; `nil` keeps all. |
|
|
711
713
|
| `delivering_timeout` | `15.minutes` | Age after which `angarium:reap` requeues a stuck `delivering` delivery. |
|
data/SECURITY.md
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
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
|
|
|
@@ -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
|
|
@@ -90,7 +90,7 @@ module Angarium
|
|
|
90
90
|
if addresses.any? { |ip| !AddressPolicy.ip_allowed?(ip, endpoint) }
|
|
91
91
|
payload[:outcome] = :blocked
|
|
92
92
|
payload[:error] = "blocked: destination address not permitted"
|
|
93
|
-
attempt =
|
|
93
|
+
attempt = record_attempt!(error: payload[:error])
|
|
94
94
|
update!(state: "blocked", next_attempt_at: nil)
|
|
95
95
|
endpoint.record_delivery_failure!
|
|
96
96
|
return attempt
|
|
@@ -103,7 +103,7 @@ module Angarium
|
|
|
103
103
|
if addresses.empty?
|
|
104
104
|
payload[:outcome] = :unresolvable
|
|
105
105
|
payload[:error] = "unresolvable host: #{destination_host}"
|
|
106
|
-
attempt =
|
|
106
|
+
attempt = record_attempt!(error: payload[:error])
|
|
107
107
|
handle_failure!
|
|
108
108
|
return attempt
|
|
109
109
|
end
|
|
@@ -129,7 +129,7 @@ module Angarium
|
|
|
129
129
|
addresses: addresses.map(&:to_s)
|
|
130
130
|
)
|
|
131
131
|
|
|
132
|
-
attempt =
|
|
132
|
+
attempt = record_attempt!(
|
|
133
133
|
response_code: result.code,
|
|
134
134
|
response_body: result.body,
|
|
135
135
|
error: result.error,
|
|
@@ -178,9 +178,18 @@ module Angarium
|
|
|
178
178
|
# clean dirty baseline to persist the delivering -> succeeded/pending move).
|
|
179
179
|
def claim_for_attempt!
|
|
180
180
|
now = Time.current
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
)
|
|
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
|
+
)
|
|
184
193
|
return false if claimed.zero?
|
|
185
194
|
|
|
186
195
|
reload
|
|
@@ -200,11 +209,36 @@ module Angarium
|
|
|
200
209
|
# queued. Stop retrying and log why. Recover with redeliver! if the endpoint is
|
|
201
210
|
# later re-enabled. Returns the logged attempt.
|
|
202
211
|
def cancel!(reason:)
|
|
203
|
-
attempt =
|
|
212
|
+
attempt = record_attempt!(error: "canceled: endpoint #{reason}")
|
|
204
213
|
update!(state: "canceled", next_attempt_at: nil)
|
|
205
214
|
attempt
|
|
206
215
|
end
|
|
207
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
|
+
|
|
208
242
|
def succeed!
|
|
209
243
|
update!(state: "succeeded", next_attempt_at: nil)
|
|
210
244
|
endpoint.record_delivery_success!
|
|
@@ -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,6 +37,12 @@ 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
48
|
validates :name, presence: true, length: {maximum: 255}
|
|
@@ -139,14 +145,14 @@ module Angarium
|
|
|
139
145
|
transition_status!(:enabled, from: :unverified) { Angarium.notify(:on_endpoint_verified, self) }
|
|
140
146
|
end
|
|
141
147
|
|
|
142
|
-
# Deliver a synthetic
|
|
143
|
-
# subscription matching (a ping is always
|
|
144
|
-
# ignores the endpoint's status, so you can test
|
|
145
|
-
# disabled, or not yet enabled; pass `force: false`
|
|
146
|
-
# guard instead. Returns the created Angarium::Delivery,
|
|
147
|
-
# after_create_commit enqueues the DeliverJob; reload it to inspect the outcome.
|
|
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.
|
|
148
154
|
def ping!(payload = {message: "ping"}, force: true)
|
|
149
|
-
event = Angarium::Event.create!(name:
|
|
155
|
+
event = Angarium::Event.create!(name: Angarium.config.ping_event_name, payload: payload)
|
|
150
156
|
deliveries.create!(event: event, forced: force)
|
|
151
157
|
end
|
|
152
158
|
|
|
@@ -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
|
|
@@ -8,9 +8,12 @@ class CreateAngariumEndpoints < ActiveRecord::Migration[7.1]
|
|
|
8
8
|
t.text :url, null: false
|
|
9
9
|
t.string :status, null: false, default: "enabled"
|
|
10
10
|
t.text :signing_secret, null: false
|
|
11
|
-
|
|
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
|
|
12
15
|
t.boolean :allow_private_network, null: false, default: false
|
|
13
|
-
t.json :allowed_networks, null: false
|
|
16
|
+
t.json :allowed_networks, null: false
|
|
14
17
|
# Encrypted at rest (may hold a receiver credential), so it's nullable with
|
|
15
18
|
# no DB default — an unencrypted default would fail to decrypt on read.
|
|
16
19
|
t.json :custom_headers
|
|
@@ -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
|
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
|
|
@@ -7,6 +7,7 @@ module Angarium
|
|
|
7
7
|
:max_retry_after, :retry_jitter, :signing_secret_grace_period,
|
|
8
8
|
:delivery_attempt_retention, :delivering_timeout, :dns_timeout,
|
|
9
9
|
:resolve_dns_with_hosts_file, :max_url_length, :max_subscribed_events,
|
|
10
|
+
:ping_event_name,
|
|
10
11
|
:on_delivery_exhausted, :on_endpoint_deactivated, :on_endpoint_verified,
|
|
11
12
|
:parent_controller, :current_user, :policy_class
|
|
12
13
|
|
|
@@ -60,6 +61,10 @@ module Angarium
|
|
|
60
61
|
# every dispatch). Both bound user-supplied input.
|
|
61
62
|
@max_url_length = 2048
|
|
62
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"
|
|
63
68
|
@on_delivery_exhausted = nil # ->(delivery) { ... }
|
|
64
69
|
@on_endpoint_deactivated = nil # ->(endpoint, reason) { ... } reason: :consecutive_failures | :gone
|
|
65
70
|
@on_endpoint_verified = nil # ->(endpoint) { ... } fired when an unverified endpoint is verified
|
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
|
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
|