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,12 @@
|
|
|
1
|
+
class CreateAngariumDeliveryAttempts < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :angarium_delivery_attempts, id: Angarium.primary_key_type do |t|
|
|
4
|
+
t.references :delivery, null: false, type: Angarium.primary_key_type, foreign_key: {to_table: :angarium_deliveries}
|
|
5
|
+
t.integer :response_code
|
|
6
|
+
t.text :response_body
|
|
7
|
+
t.string :error
|
|
8
|
+
t.float :duration
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require "ipaddr"
|
|
2
|
+
require "resolv"
|
|
3
|
+
|
|
4
|
+
module Angarium
|
|
5
|
+
# Central SSRF policy. Decides whether a destination IP is permitted for a
|
|
6
|
+
# given endpoint, composing three controls:
|
|
7
|
+
# 1. endpoint.allowed_networks (CIDR list) -> if present, ONLY those are allowed
|
|
8
|
+
# 2. endpoint.allow_private_network -> bypass the private-IP denylist
|
|
9
|
+
# 3. Angarium.config.block_private_ips -> default denylist of private ranges
|
|
10
|
+
module AddressPolicy
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Is a single IP (String or IPAddr) permitted for this endpoint?
|
|
14
|
+
#
|
|
15
|
+
# Two independent gates, both must pass:
|
|
16
|
+
# Gate A (private denylist): private/loopback/link-local addresses are
|
|
17
|
+
# blocked unless endpoint.allow_private_network is set. An allowlist entry
|
|
18
|
+
# does NOT by itself unlock a private address.
|
|
19
|
+
# Gate B (allowlist): when endpoint.allowed_networks is non-empty, the
|
|
20
|
+
# address must fall within one of those CIDRs.
|
|
21
|
+
def ip_allowed?(ip, endpoint)
|
|
22
|
+
ip = to_ipaddr(ip)
|
|
23
|
+
return false unless ip
|
|
24
|
+
|
|
25
|
+
if private?(ip) && Angarium.config.block_private_ips
|
|
26
|
+
return false unless endpoint.allow_private_network
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
allowlist = Array(endpoint.allowed_networks).reject(&:blank?)
|
|
30
|
+
unless allowlist.empty?
|
|
31
|
+
return false unless allowlist.any? { |cidr| cidr_include?(cidr, ip) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Resolve host and return true only if EVERY resolved address is allowed.
|
|
38
|
+
# Unresolvable hosts return [] and this returns true (can't prove disallowed);
|
|
39
|
+
# callers that need strictness at connect time check each resolved IP instead.
|
|
40
|
+
def host_permitted_for_validation?(host, endpoint)
|
|
41
|
+
resolve(host).all? { |ip| ip_allowed?(ip, endpoint) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def resolve(host)
|
|
45
|
+
literal = to_ipaddr(host)
|
|
46
|
+
return [literal] if literal
|
|
47
|
+
|
|
48
|
+
Resolv.getaddresses(host).filter_map { |a| to_ipaddr(a) }
|
|
49
|
+
rescue
|
|
50
|
+
[]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def private?(ip)
|
|
54
|
+
ip = normalize(ip)
|
|
55
|
+
return true if ip.to_i.zero? # 0.0.0.0 / :: (unspecified -> localhost on Linux)
|
|
56
|
+
|
|
57
|
+
ip.loopback? || ip.private? || ip.link_local? ||
|
|
58
|
+
(ip.respond_to?(:unique_local?) && ip.unique_local?)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Collapse IPv4-mapped IPv6 (::ffff:x.x.x.x) to native IPv4 so the predicates
|
|
62
|
+
# above see the real address; leave other addresses untouched.
|
|
63
|
+
def normalize(ip)
|
|
64
|
+
ip.respond_to?(:native) ? ip.native : ip
|
|
65
|
+
rescue
|
|
66
|
+
ip
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def cidr_include?(cidr, ip)
|
|
70
|
+
IPAddr.new(cidr.to_s).include?(ip)
|
|
71
|
+
rescue IPAddr::InvalidAddressError
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_ipaddr(value)
|
|
76
|
+
ip = value.is_a?(IPAddr) ? value : IPAddr.new(value.to_s)
|
|
77
|
+
normalize(ip)
|
|
78
|
+
rescue IPAddr::InvalidAddressError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require "httpx"
|
|
2
|
+
|
|
3
|
+
module Angarium
|
|
4
|
+
# Thin wrapper over HTTPX. Returns a plain result struct so callers/tests
|
|
5
|
+
# never touch HTTPX response objects directly. Note: HTTPX does NOT follow
|
|
6
|
+
# redirects unless the :follow_redirects plugin is enabled, so we intentionally
|
|
7
|
+
# leave it disabled so a 3xx to an internal URL can't bypass SSRF checks.
|
|
8
|
+
class Client
|
|
9
|
+
Result = Struct.new(:success, :code, :body, :error, :duration, :headers) do
|
|
10
|
+
def success? = success
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def post(url, body:, headers:, addresses: nil)
|
|
14
|
+
conn = self.class.connection
|
|
15
|
+
conn = conn.with(addresses: addresses) if addresses && !addresses.empty?
|
|
16
|
+
|
|
17
|
+
started = monotonic
|
|
18
|
+
response = conn.post(url, body: body, headers: headers)
|
|
19
|
+
duration = monotonic - started
|
|
20
|
+
|
|
21
|
+
if response.is_a?(HTTPX::ErrorResponse)
|
|
22
|
+
return Result.new(success: false,
|
|
23
|
+
error: "#{response.error.class}: #{response.error.message}",
|
|
24
|
+
duration: duration,
|
|
25
|
+
headers: {})
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
max = Angarium.config.max_response_body_bytes
|
|
29
|
+
body = response.body.to_s
|
|
30
|
+
body = body.byteslice(0, max) if max
|
|
31
|
+
|
|
32
|
+
Result.new(
|
|
33
|
+
success: (200..299).cover?(response.status),
|
|
34
|
+
code: response.status,
|
|
35
|
+
body: body,
|
|
36
|
+
duration: duration,
|
|
37
|
+
headers: response.headers.to_h.transform_keys(&:downcase)
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
42
|
+
|
|
43
|
+
def self.connection
|
|
44
|
+
HTTPX.with(
|
|
45
|
+
headers: {"user-agent" => Angarium.config.user_agent, "content-type" => "application/json"},
|
|
46
|
+
timeout: {
|
|
47
|
+
connect_timeout: Angarium.config.open_timeout,
|
|
48
|
+
read_timeout: Angarium.config.http_timeout
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module Angarium
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :job_queue, :http_timeout, :open_timeout, :user_agent,
|
|
4
|
+
:retry_schedule, :block_private_ips,
|
|
5
|
+
:primary_key_type, :database, :connects_to, :max_response_body_bytes,
|
|
6
|
+
:auto_disable_endpoint_after, :respect_retry_after,
|
|
7
|
+
:max_retry_after, :retry_jitter, :signing_secret_grace_period,
|
|
8
|
+
:delivery_attempt_retention, :delivering_timeout,
|
|
9
|
+
:on_delivery_exhausted, :on_endpoint_deactivated, :on_endpoint_verified,
|
|
10
|
+
:parent_controller, :current_user, :policy_class
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@job_queue = :default
|
|
14
|
+
@http_timeout = 10
|
|
15
|
+
@open_timeout = 5
|
|
16
|
+
@user_agent = "Angarium/#{Angarium::VERSION}"
|
|
17
|
+
# Follows the Standard Webhooks recommendation of a multi-day schedule with
|
|
18
|
+
# exponential backoff (delays between retries; the first delivery is
|
|
19
|
+
# immediate). Spans ~10 days; jitter is added per attempt (see
|
|
20
|
+
# config.retry_jitter).
|
|
21
|
+
@retry_schedule = [
|
|
22
|
+
5.seconds, 5.minutes, 30.minutes, 2.hours, 5.hours,
|
|
23
|
+
10.hours, 14.hours, 20.hours, 24.hours, 36.hours, 48.hours, 72.hours
|
|
24
|
+
]
|
|
25
|
+
@block_private_ips = true
|
|
26
|
+
@primary_key_type = nil
|
|
27
|
+
# Multi-database: the database (a key from config/database.yml) that
|
|
28
|
+
# Angarium's tables live in. Drives both the connection and where the
|
|
29
|
+
# migrations generator installs migrations (db/<database>_migrate).
|
|
30
|
+
# nil (default) keeps Angarium on the app's primary connection.
|
|
31
|
+
@database = nil
|
|
32
|
+
# Advanced multi-database: a hash passed straight to Rails' connects_to for
|
|
33
|
+
# custom roles/shards, e.g. { database: { writing: :angarium, reading: :angarium } }.
|
|
34
|
+
# Takes precedence over @database for the connection.
|
|
35
|
+
@connects_to = nil
|
|
36
|
+
@max_response_body_bytes = 65_536
|
|
37
|
+
@auto_disable_endpoint_after = nil
|
|
38
|
+
@respect_retry_after = true
|
|
39
|
+
@max_retry_after = 3600
|
|
40
|
+
@retry_jitter = 0.15
|
|
41
|
+
@signing_secret_grace_period = 24.hours
|
|
42
|
+
@delivery_attempt_retention = nil
|
|
43
|
+
@delivering_timeout = 15.minutes
|
|
44
|
+
@on_delivery_exhausted = nil # ->(delivery) { ... }
|
|
45
|
+
@on_endpoint_deactivated = nil # ->(endpoint, reason) { ... } reason: :consecutive_failures | :gone
|
|
46
|
+
@on_endpoint_verified = nil # ->(endpoint) { ... } fired when an unverified endpoint is verified
|
|
47
|
+
|
|
48
|
+
# --- Headless JSON API (only used if you mount Angarium::Engine) ---------
|
|
49
|
+
# Base controller the API inherits from, so your app's authentication
|
|
50
|
+
# (Devise/Rodauth/etc.) applies to Angarium's endpoints too.
|
|
51
|
+
@parent_controller = "ApplicationController"
|
|
52
|
+
# Resolves the current user from the controller (your current-user convention).
|
|
53
|
+
@current_user = ->(controller) { controller.current_user }
|
|
54
|
+
# Authorization: scope, create-owner, and per-action permissions, all in one
|
|
55
|
+
# class. Subclass Angarium::Api::Policy to customize any of them.
|
|
56
|
+
@policy_class = "Angarium::Api::Policy"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The database Angarium's migrations belong in, for the migrations generator.
|
|
60
|
+
# Prefers the explicit @database, else the writing role from a connects_to
|
|
61
|
+
# hash. nil => the app's primary db/migrate.
|
|
62
|
+
def migrations_database
|
|
63
|
+
database || connects_to&.dig(:database, :writing)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Angarium
|
|
2
|
+
module Dispatch
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def call(event_name, payload, owner:)
|
|
6
|
+
notify_payload = {event: event_name, event_id: nil, deliveries: 0}
|
|
7
|
+
ActiveSupport::Notifications.instrument("dispatch.angarium", notify_payload) do
|
|
8
|
+
endpoints = Endpoint.enabled.where(owner: owner).select do |endpoint|
|
|
9
|
+
endpoint.subscribed_to?(event_name)
|
|
10
|
+
end
|
|
11
|
+
next nil if endpoints.empty?
|
|
12
|
+
|
|
13
|
+
Event.transaction do
|
|
14
|
+
event = Event.create!(name: event_name, payload: payload)
|
|
15
|
+
endpoints.each { |endpoint| event.deliveries.create!(endpoint: endpoint) }
|
|
16
|
+
notify_payload[:event_id] = event.id
|
|
17
|
+
notify_payload[:deliveries] = endpoints.size
|
|
18
|
+
event
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Angarium
|
|
2
|
+
module EventMatcher
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
# pattern: "*" (all), "prefix.*" (prefix), or an exact event name
|
|
6
|
+
def match?(pattern, event_name)
|
|
7
|
+
return true if pattern == "*"
|
|
8
|
+
|
|
9
|
+
if pattern.end_with?(".*")
|
|
10
|
+
prefix = pattern[0..-3] # strip ".*"
|
|
11
|
+
event_name == prefix || event_name.start_with?("#{prefix}.")
|
|
12
|
+
else
|
|
13
|
+
pattern == event_name
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
require "base64"
|
|
3
|
+
|
|
4
|
+
module Angarium
|
|
5
|
+
module Signature
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# secret may be a String or Array of secrets (dual-secret rotation grace);
|
|
9
|
+
# produces one `v1,<base64>` token per secret, space-delimited.
|
|
10
|
+
def sign(payload:, id:, timestamp:, secret:)
|
|
11
|
+
Array(secret).map { |s| "v1,#{signature_for(s, id, timestamp, payload)}" }.join(" ")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Verify a Standard Webhooks signature. Pass the fields explicitly, or pass a
|
|
15
|
+
# Rails `request:` and Angarium pulls the raw body and webhook-* headers for
|
|
16
|
+
# you, so a receiver is a one-liner:
|
|
17
|
+
#
|
|
18
|
+
# Angarium::Signature.verify(request:, secret: endpoint.signing_secret)
|
|
19
|
+
#
|
|
20
|
+
def verify(secret:, request: nil, payload: nil, id: nil, timestamp: nil, signature: nil,
|
|
21
|
+
tolerance: 300, now: Time.now.to_i)
|
|
22
|
+
if request
|
|
23
|
+
payload ||= request.raw_post
|
|
24
|
+
id ||= request.headers["webhook-id"]
|
|
25
|
+
timestamp ||= request.headers["webhook-timestamp"]
|
|
26
|
+
signature ||= request.headers["webhook-signature"]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
return false unless timestamp.to_s.match?(/\A\d+\z/)
|
|
30
|
+
return false if (now - timestamp.to_i).abs > tolerance
|
|
31
|
+
|
|
32
|
+
expected = signature_for(secret, id, timestamp.to_i, payload)
|
|
33
|
+
parse(signature).any? { |sig| secure_compare(expected, sig) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def signature_for(secret, id, timestamp, payload)
|
|
37
|
+
key = Base64.decode64(secret.to_s.delete_prefix("whsec_"))
|
|
38
|
+
digest = OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{payload}")
|
|
39
|
+
Base64.strict_encode64(digest)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# webhook-signature is space-delimited `v1,<base64>` tokens; keep the base64
|
|
43
|
+
# payload of each v1 token.
|
|
44
|
+
def parse(header)
|
|
45
|
+
header.to_s.split(" ").filter_map { |token|
|
|
46
|
+
version, sig = token.split(",", 2)
|
|
47
|
+
sig if version == "v1" && sig && !sig.empty?
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def secure_compare(a, b)
|
|
52
|
+
ActiveSupport::SecurityUtils.secure_compare(a, b)
|
|
53
|
+
rescue ArgumentError
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/angarium.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "angarium/version"
|
|
2
|
+
require "angarium/engine"
|
|
3
|
+
require "angarium/configuration"
|
|
4
|
+
require "angarium/event_matcher"
|
|
5
|
+
require "angarium/signature"
|
|
6
|
+
require "angarium/address_policy"
|
|
7
|
+
require "angarium/dispatch"
|
|
8
|
+
require "angarium/client"
|
|
9
|
+
|
|
10
|
+
module Angarium
|
|
11
|
+
class << self
|
|
12
|
+
def config
|
|
13
|
+
@config ||= Configuration.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def configure
|
|
17
|
+
yield config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Primary key type for Angarium's own tables. Explicit config wins; otherwise
|
|
21
|
+
# respect the app's global generators setting; otherwise Rails' default (bigint).
|
|
22
|
+
def primary_key_type
|
|
23
|
+
config.primary_key_type ||
|
|
24
|
+
Rails.application.config.generators.options.dig(:active_record, :primary_key_type) ||
|
|
25
|
+
:bigint
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def dispatch(event_name, payload, owner:)
|
|
29
|
+
Dispatch.call(event_name, payload, owner: owner)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Invoke a configured notification callback (e.g. on_delivery_exhausted,
|
|
33
|
+
# on_endpoint_deactivated). A callback raising must never break the delivery
|
|
34
|
+
# pipeline, so errors are logged and swallowed.
|
|
35
|
+
def notify(callback_name, *args)
|
|
36
|
+
callback = config.public_send(callback_name)
|
|
37
|
+
return unless callback
|
|
38
|
+
|
|
39
|
+
callback.call(*args)
|
|
40
|
+
rescue => e
|
|
41
|
+
Rails.logger.error { "[Angarium] #{callback_name} callback raised: #{e.class}: #{e.message}" }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require "rails/generators/base"
|
|
2
|
+
|
|
3
|
+
module Angarium
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
desc "Creates the Angarium initializer and installs its migrations. Pass " \
|
|
9
|
+
"--database=NAME to run Angarium in its own database (multi-db)."
|
|
10
|
+
|
|
11
|
+
class_option :database, type: :string, aliases: "-d", default: nil, banner: "NAME",
|
|
12
|
+
desc: "Place Angarium in its own database: sets config.database and installs " \
|
|
13
|
+
"migrations into db/NAME_migrate instead of db/migrate"
|
|
14
|
+
|
|
15
|
+
def copy_initializer
|
|
16
|
+
template "initializer.rb", "config/initializers/angarium.rb"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# With --database, record config.database in the initializer so a later
|
|
20
|
+
# `angarium:migrations` run (e.g. after a gem upgrade) still targets the
|
|
21
|
+
# right place without the flag.
|
|
22
|
+
def set_database_config
|
|
23
|
+
return unless database
|
|
24
|
+
|
|
25
|
+
gsub_file "config/initializers/angarium.rb", /^\s*#?\s*config\.database\s*=.*$/,
|
|
26
|
+
%( config.database = :#{database})
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The single migration path: delegate to the migrations generator, which
|
|
30
|
+
# installs into db/NAME_migrate (multi-db) or db/migrate (primary).
|
|
31
|
+
def install_migrations
|
|
32
|
+
invoke "angarium:migrations", [], database: database
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def print_next_steps
|
|
36
|
+
if database
|
|
37
|
+
say <<~MSG
|
|
38
|
+
Add the '#{database}' database to config/database.yml (per environment), e.g.:
|
|
39
|
+
|
|
40
|
+
#{database}:
|
|
41
|
+
<<: *default
|
|
42
|
+
database: myapp_#{database}
|
|
43
|
+
migrations_paths: db/#{database}_migrate
|
|
44
|
+
|
|
45
|
+
then run: bin/rails db:migrate:#{database}
|
|
46
|
+
MSG
|
|
47
|
+
else
|
|
48
|
+
say "\nNext: run bin/rails db:migrate"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def database = options[:database]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Angarium.configure do |config|
|
|
2
|
+
# ActiveJob queue used for webhook deliveries.
|
|
3
|
+
# config.job_queue = :default
|
|
4
|
+
|
|
5
|
+
# HTTP read timeout (seconds) per delivery attempt.
|
|
6
|
+
# config.http_timeout = 10
|
|
7
|
+
|
|
8
|
+
# TCP connect timeout (seconds) per delivery attempt.
|
|
9
|
+
# config.open_timeout = 5
|
|
10
|
+
|
|
11
|
+
# User-Agent header sent with each delivery.
|
|
12
|
+
# config.user_agent = "Angarium/#{Angarium::VERSION}"
|
|
13
|
+
|
|
14
|
+
# Backoff schedule between retries. Length = number of retries.
|
|
15
|
+
# Delays between retries (the first delivery is immediate). Standard Webhooks
|
|
16
|
+
# recommended default spans ~10 days; jitter is added per attempt.
|
|
17
|
+
# config.retry_schedule = [5.seconds, 5.minutes, 30.minutes, 2.hours, 5.hours,
|
|
18
|
+
# 10.hours, 14.hours, 20.hours, 24.hours, 36.hours, 48.hours, 72.hours]
|
|
19
|
+
|
|
20
|
+
# Reject endpoint URLs that resolve to private/loopback addresses (SSRF guard).
|
|
21
|
+
# Per-endpoint overrides: endpoint.allow_private_network and endpoint.allowed_networks.
|
|
22
|
+
# config.block_private_ips = true
|
|
23
|
+
|
|
24
|
+
# Primary key type for Angarium's own tables.
|
|
25
|
+
# config.primary_key_type = nil # nil = use the app's default (bigint unless overridden); set :uuid etc. to force
|
|
26
|
+
|
|
27
|
+
# Multi-database: keep Angarium's tables in their own database. Set this to a
|
|
28
|
+
# database name from config/database.yml and Angarium routes all its models and
|
|
29
|
+
# migrations there. `bin/rails g angarium:install --database=NAME` sets this for
|
|
30
|
+
# you; after a gem upgrade, `bin/rails g angarium:migrations` reads it so new
|
|
31
|
+
# migrations still land in db/NAME_migrate. nil (default) uses the primary connection.
|
|
32
|
+
# config.database = :angarium
|
|
33
|
+
#
|
|
34
|
+
# Advanced: for custom roles/shards, pass a hash straight to Rails' connects_to.
|
|
35
|
+
# It wins over config.database for the connection (set config.database too so the
|
|
36
|
+
# migrations generator knows where to install).
|
|
37
|
+
# config.connects_to = { database: { writing: :angarium, reading: :angarium } }
|
|
38
|
+
|
|
39
|
+
# Truncate the stored response body to this many bytes. nil = store the full body.
|
|
40
|
+
# config.max_response_body_bytes = 65_536
|
|
41
|
+
|
|
42
|
+
# Auto-disable an endpoint after this many consecutive failed deliveries. nil = never.
|
|
43
|
+
# config.auto_disable_endpoint_after = nil
|
|
44
|
+
|
|
45
|
+
# Honor a receiver's Retry-After header (seconds or HTTP-date) for the next attempt.
|
|
46
|
+
# config.respect_retry_after = true
|
|
47
|
+
|
|
48
|
+
# Cap (seconds) applied to a honored Retry-After value. nil = uncapped.
|
|
49
|
+
# config.max_retry_after = 3600
|
|
50
|
+
|
|
51
|
+
# Fraction of additive positive jitter applied to each backoff delay.
|
|
52
|
+
# config.retry_jitter = 0.15
|
|
53
|
+
|
|
54
|
+
# Grace window during which a rotated endpoint's previous signing secret stays
|
|
55
|
+
# valid (deliveries are signed with both), so receivers can roll over with no downtime.
|
|
56
|
+
# config.signing_secret_grace_period = 24.hours
|
|
57
|
+
|
|
58
|
+
# Notification callbacks fire on terminal delivery events so you can alert
|
|
59
|
+
# consumers out of band (email, Slack, PagerDuty). A raised callback is logged
|
|
60
|
+
# and swallowed, so it never breaks delivery.
|
|
61
|
+
# config.on_delivery_exhausted = ->(delivery) { } # retry schedule exhausted
|
|
62
|
+
# config.on_endpoint_deactivated = ->(endpoint, reason) { } # reason: :consecutive_failures | :gone (HTTP 410)
|
|
63
|
+
# config.on_endpoint_verified = ->(endpoint) { } # an `unverified` endpoint passed its first delivery
|
|
64
|
+
|
|
65
|
+
# --- Headless JSON API (only used if you `mount Angarium::Engine`) -----------
|
|
66
|
+
# Base controller the API inherits from, so your app's authentication applies.
|
|
67
|
+
# config.parent_controller = "ApplicationController"
|
|
68
|
+
|
|
69
|
+
# Resolve the current user from the controller (your current-user convention).
|
|
70
|
+
# config.current_user = ->(controller) { controller.current_user }
|
|
71
|
+
|
|
72
|
+
# Authorization, all in one class: #scope(relation) (what a user may see),
|
|
73
|
+
# #owner (who a new endpoint belongs to), and #<action>? predicates. Generate a
|
|
74
|
+
# starting point with `bin/rails g angarium:policy`, then override only what you
|
|
75
|
+
# need. Defaults are permissive and single-owner (you manage your own endpoints).
|
|
76
|
+
# config.policy_class = "WebhookEndpointPolicy"
|
|
77
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "rails/generators/base"
|
|
2
|
+
|
|
3
|
+
module Angarium
|
|
4
|
+
module Generators
|
|
5
|
+
# Installs (and, after a gem upgrade, refreshes) Angarium's engine migrations.
|
|
6
|
+
# Angarium keeps its migrations in db/angarium_migrate rather than the
|
|
7
|
+
# conventional db/migrate, so Rails never auto-appends them onto the host's
|
|
8
|
+
# primary connection nor generates a generic install:migrations task: this
|
|
9
|
+
# generator is the single install path. It is multi-database aware: it reads
|
|
10
|
+
# config.database (set at install time) so a host that forgets the flag on a
|
|
11
|
+
# later run still gets new migrations in the right place. Uses Rails' native
|
|
12
|
+
# ActiveRecord::Migration.copy, so re-runs are idempotent (already-installed
|
|
13
|
+
# migrations are skipped).
|
|
14
|
+
class MigrationsGenerator < Rails::Generators::Base
|
|
15
|
+
desc "Installs Angarium's migrations. Multi-db setups (config.database or " \
|
|
16
|
+
"--database) get them in db/NAME_migrate; otherwise the primary db/migrate."
|
|
17
|
+
|
|
18
|
+
class_option :database, type: :string, aliases: "-d", default: nil, banner: "NAME",
|
|
19
|
+
desc: "Install into db/NAME_migrate for this database " \
|
|
20
|
+
"(defaults to config.database / connects_to)"
|
|
21
|
+
|
|
22
|
+
def install_migrations
|
|
23
|
+
database = options[:database].presence || Angarium.config.migrations_database
|
|
24
|
+
# The primary connection always migrates from db/migrate; only a separate
|
|
25
|
+
# database gets its own db/<name>_migrate path.
|
|
26
|
+
dir = (database.nil? || database.to_s == "primary") ? "db/migrate" : "db/#{database}_migrate"
|
|
27
|
+
copied = ActiveRecord::Migration.copy(
|
|
28
|
+
File.join(destination_root, dir),
|
|
29
|
+
{"angarium" => Angarium::Engine.root.join("db/angarium_migrate").to_s}
|
|
30
|
+
)
|
|
31
|
+
if copied.any?
|
|
32
|
+
say_status :installed, "#{copied.size} Angarium migration(s) into #{dir}", :green
|
|
33
|
+
else
|
|
34
|
+
say_status :identical, "Angarium migrations already present in #{dir}", :blue
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "rails/generators/base"
|
|
2
|
+
|
|
3
|
+
module Angarium
|
|
4
|
+
module Generators
|
|
5
|
+
class PolicyGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
desc "Creates an Angarium API policy (a subclass of Angarium::Api::Policy)."
|
|
9
|
+
|
|
10
|
+
argument :policy_name, type: :string, default: "WebhookEndpointPolicy",
|
|
11
|
+
banner: "NAME", desc: "Policy class name (default: WebhookEndpointPolicy)"
|
|
12
|
+
|
|
13
|
+
def create_policy
|
|
14
|
+
template "policy.rb", File.join("app/policies", "#{policy_name.underscore}.rb")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Point config.policy_class at the generated class, uncommenting (or
|
|
18
|
+
# replacing) the line in the initializer so it takes effect immediately.
|
|
19
|
+
def enable_policy
|
|
20
|
+
initializer = "config/initializers/angarium.rb"
|
|
21
|
+
|
|
22
|
+
unless File.exist?(File.join(destination_root, initializer))
|
|
23
|
+
say_status :skip, %(#{initializer} not found; set config.policy_class = "#{class_name}" yourself), :yellow
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
line = %r{^\s*#?\s*config\.policy_class\s*=.*$}
|
|
28
|
+
if File.read(File.join(destination_root, initializer)).match?(line)
|
|
29
|
+
gsub_file initializer, line, %( config.policy_class = "#{class_name}")
|
|
30
|
+
else
|
|
31
|
+
say_status :skip, %(add config.policy_class = "#{class_name}" to #{initializer}), :yellow
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def class_name = policy_name.camelize
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Authorization for Angarium's JSON API. Enable it with
|
|
2
|
+
# config.policy_class = "<%= class_name %>"
|
|
3
|
+
# in config/initializers/angarium.rb.
|
|
4
|
+
#
|
|
5
|
+
# The policy runs in the controller's context, so `current_user`, `params`, and
|
|
6
|
+
# `controller` are available. Override only what you need; the inherited defaults
|
|
7
|
+
# are permissive and single-owner (a user sees and manages their own endpoints).
|
|
8
|
+
class <%= class_name %> < Angarium::Api::Policy
|
|
9
|
+
# Narrow the base relation to the endpoints this user may see and act on.
|
|
10
|
+
# def scope(relation)
|
|
11
|
+
# relation.where(owner: current_user)
|
|
12
|
+
# end
|
|
13
|
+
|
|
14
|
+
# Owner for a newly-created endpoint. Override to create on behalf of another
|
|
15
|
+
# owner (read a param), then gate who may do so in #create?.
|
|
16
|
+
# def owner
|
|
17
|
+
# current_user
|
|
18
|
+
# end
|
|
19
|
+
|
|
20
|
+
# SSRF controls, gated independently (default off). allow_private_network
|
|
21
|
+
# relaxes the private-IP block (dangerous, operators only); allowed_networks
|
|
22
|
+
# only restricts delivery to a CIDR allowlist (safe to expose more widely).
|
|
23
|
+
# def permit_allow_private_network? = current_user.admin?
|
|
24
|
+
# def permit_allowed_networks? = true
|
|
25
|
+
|
|
26
|
+
# Per-action permissions. rotate_secret?/pause?/enable?/ping?/redeliver? all
|
|
27
|
+
# default to update?.
|
|
28
|
+
# def index? = true
|
|
29
|
+
# def show? = true
|
|
30
|
+
# def create? = true
|
|
31
|
+
# def update? = true
|
|
32
|
+
# def destroy? = true
|
|
33
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
namespace :angarium do
|
|
2
|
+
desc "Prune delivery attempts older than Angarium.config.delivery_attempt_retention"
|
|
3
|
+
task prune: :environment do
|
|
4
|
+
retention = Angarium.config.delivery_attempt_retention
|
|
5
|
+
abort "Set Angarium.config.delivery_attempt_retention (e.g. 90.days) before pruning." unless retention
|
|
6
|
+
count = Angarium::DeliveryAttempt.prune(older_than: retention)
|
|
7
|
+
puts "Angarium: pruned #{count} delivery attempt(s) older than #{retention.inspect}."
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
desc "Requeue deliveries stuck in the delivering state past Angarium.config.delivering_timeout"
|
|
11
|
+
task reap: :environment do
|
|
12
|
+
count = Angarium::Delivery.reap_stalled
|
|
13
|
+
puts "Angarium: requeued #{count} stalled deliver(y/ies)."
|
|
14
|
+
end
|
|
15
|
+
end
|