orbitcast 0.3.1-aarch64-linux-gnu
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/LICENSE.txt +28 -0
- data/README.md +100 -0
- data/app/assets/javascripts/orbitcast/recovery.js +196 -0
- data/db/orbitcast_migrate/001_create_orbitcast_payloads.rb +39 -0
- data/db/orbitcast_migrate/002_create_orbitcast_presence.rb +54 -0
- data/db/orbitcast_migrate/003_create_orbitcast_broker.rb +102 -0
- data/exe/orbitcast +11 -0
- data/lib/action_cable/subscription_adapter/orbitcast.rb +102 -0
- data/lib/generators/orbitcast/install/install_generator.rb +223 -0
- data/lib/generators/orbitcast/install/templates/cable.yml.tt +48 -0
- data/lib/generators/orbitcast/install/templates/orbitcast.rb.tt +156 -0
- data/lib/orbitcast/binary.rb +234 -0
- data/lib/orbitcast/channel.rb +459 -0
- data/lib/orbitcast/channel_registry.rb +131 -0
- data/lib/orbitcast/configuration.rb +636 -0
- data/lib/orbitcast/connection.rb +338 -0
- data/lib/orbitcast/control.rb +72 -0
- data/lib/orbitcast/dispatcher.rb +306 -0
- data/lib/orbitcast/engine.rb +98 -0
- data/lib/orbitcast/error.rb +54 -0
- data/lib/orbitcast/launcher.rb +302 -0
- data/lib/orbitcast/protocol/v1/control_message.rb +134 -0
- data/lib/orbitcast/protocol/v1/effects.rb +172 -0
- data/lib/orbitcast/protocol/v1/reply.rb +268 -0
- data/lib/orbitcast/protocol/v1/request.rb +364 -0
- data/lib/orbitcast/protocol/v1.rb +291 -0
- data/lib/orbitcast/protocol.rb +19 -0
- data/lib/orbitcast/publisher/control.rb +67 -0
- data/lib/orbitcast/publisher/http.rb +180 -0
- data/lib/orbitcast/publisher/instrumentation.rb +47 -0
- data/lib/orbitcast/publisher/postgres.rb +407 -0
- data/lib/orbitcast/rails_endpoint.rb +207 -0
- data/lib/orbitcast/recovery.rb +166 -0
- data/lib/orbitcast/state_codec.rb +186 -0
- data/lib/orbitcast/streams.rb +129 -0
- data/lib/orbitcast/version.rb +10 -0
- data/lib/orbitcast.rb +68 -0
- data/vendor/aarch64-linux-gnu/bin/orbitcast +0 -0
- data/vendor/aarch64-linux-gnu/manifest.json +14 -0
- metadata +130 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "ipaddr"
|
|
5
|
+
|
|
6
|
+
require_relative "error"
|
|
7
|
+
|
|
8
|
+
module Orbitcast
|
|
9
|
+
# Validated Orbitcast settings.
|
|
10
|
+
#
|
|
11
|
+
# Configuration is deliberately stricter than the tolerant wire envelopes:
|
|
12
|
+
# unknown keys, unsupported modes, and malformed values raise immediately
|
|
13
|
+
# instead of being defaulted into something permissive. Writers validate at the
|
|
14
|
+
# boundary; `validate!` only checks cross-field requirements.
|
|
15
|
+
class Configuration
|
|
16
|
+
PUBLISH_VIA = %i[postgres http].freeze
|
|
17
|
+
|
|
18
|
+
# `:signed` — the relay authorizes subscriptions from signed stream names
|
|
19
|
+
# and runs no application Ruby.
|
|
20
|
+
# `:dispatch` — the relay calls back into Rails for connection
|
|
21
|
+
# authentication, channel authorization, and actions.
|
|
22
|
+
MODES = %i[signed dispatch].freeze
|
|
23
|
+
|
|
24
|
+
# How `Publisher::Postgres` reaches the cable database.
|
|
25
|
+
# `:pool` — a bounded, fork-safe dedicated `pg` pool for `database_url`.
|
|
26
|
+
# `:active_record` — the application's Active Record connection, so a
|
|
27
|
+
# broadcast joins the surrounding transaction and rolls back with it.
|
|
28
|
+
CONNECTIONS = %i[pool active_record].freeze
|
|
29
|
+
|
|
30
|
+
# Every key `assign` accepts. Also the allowlist used by the installer and
|
|
31
|
+
# by `cable.yml` parsing.
|
|
32
|
+
ATTRIBUTES = %i[
|
|
33
|
+
database_url
|
|
34
|
+
namespace
|
|
35
|
+
publish_via
|
|
36
|
+
mode
|
|
37
|
+
streams_secret
|
|
38
|
+
turbo_secret
|
|
39
|
+
recovery_secret
|
|
40
|
+
pool_size
|
|
41
|
+
pool_timeout
|
|
42
|
+
stream_expires_in
|
|
43
|
+
overflow_enabled
|
|
44
|
+
overflow_ttl
|
|
45
|
+
max_publication_size
|
|
46
|
+
rpc_token
|
|
47
|
+
rpc_endpoint_url
|
|
48
|
+
rpc_mount_path
|
|
49
|
+
connection_class
|
|
50
|
+
channels
|
|
51
|
+
ingress_url
|
|
52
|
+
ingress_token
|
|
53
|
+
listen_addr
|
|
54
|
+
listener_database_url
|
|
55
|
+
trusted_proxies
|
|
56
|
+
outbound_queue_messages
|
|
57
|
+
outbound_queue_bytes
|
|
58
|
+
streams_previous_secrets
|
|
59
|
+
turbo_previous_secrets
|
|
60
|
+
connection
|
|
61
|
+
].freeze
|
|
62
|
+
|
|
63
|
+
NAMESPACE_PATTERN = /\A[a-zA-Z0-9][a-zA-Z0-9_.\-]{0,63}\z/
|
|
64
|
+
POSTGRES_URL_PATTERN = %r{\Apostgres(?:ql)?://}
|
|
65
|
+
HTTP_URL_PATTERN = %r{\Ahttps?://}
|
|
66
|
+
|
|
67
|
+
# Where the Engine mounts the private RPC endpoint in dispatch mode. The
|
|
68
|
+
# relay appends the operation name to it.
|
|
69
|
+
DEFAULT_RPC_MOUNT_PATH = "/_orbitcast/v1/rails"
|
|
70
|
+
|
|
71
|
+
DEFAULT_CONNECTION_CLASS = "ApplicationCable::Connection"
|
|
72
|
+
|
|
73
|
+
# Where the relay listens when a development or test application does not
|
|
74
|
+
# say otherwise. Production has no default: an operator must choose.
|
|
75
|
+
DEFAULT_DEVELOPMENT_LISTEN_ADDR = "127.0.0.1:28080"
|
|
76
|
+
|
|
77
|
+
# Purpose string for the derived Orbitcast stream key:
|
|
78
|
+
# `Rails.application.key_generator.generate_key(purpose, STREAMS_KEY_SIZE)`
|
|
79
|
+
# with the namespace baked into the purpose, so two namespaces sharing one
|
|
80
|
+
# `secret_key_base` still verify with different keys. Changing it
|
|
81
|
+
# invalidates every outstanding signed name.
|
|
82
|
+
STREAMS_KEY_PURPOSE_FORMAT = "orbitcast/streams/v1/%<namespace>s"
|
|
83
|
+
STREAMS_KEY_SIZE = 32
|
|
84
|
+
|
|
85
|
+
DEFAULT_POOL_SIZE = 5
|
|
86
|
+
DEFAULT_POOL_TIMEOUT = 5.0
|
|
87
|
+
DEFAULT_STREAM_EXPIRES_IN = 3600
|
|
88
|
+
|
|
89
|
+
# Overflow relay defaults; both match the Rust server's defaults, because
|
|
90
|
+
# both sides read the same `orbitcast_payloads` rows.
|
|
91
|
+
DEFAULT_OVERFLOW_TTL = 300
|
|
92
|
+
DEFAULT_MAX_PUBLICATION_SIZE = 1024 * 1024
|
|
93
|
+
|
|
94
|
+
# PostgreSQL's NOTIFY payload limit. A publication limit below it would
|
|
95
|
+
# reject payloads that need no relay at all.
|
|
96
|
+
MIN_MAX_PUBLICATION_SIZE = 8000
|
|
97
|
+
|
|
98
|
+
attr_reader :database_url, :namespace, :publish_via, :mode,
|
|
99
|
+
:streams_secret, :turbo_secret, :recovery_secret,
|
|
100
|
+
:pool_size, :pool_timeout, :stream_expires_in,
|
|
101
|
+
:overflow_enabled, :overflow_ttl, :max_publication_size,
|
|
102
|
+
:rpc_token, :rpc_endpoint_url, :rpc_mount_path,
|
|
103
|
+
:connection_class, :channels,
|
|
104
|
+
:ingress_url, :ingress_token,
|
|
105
|
+
:listen_addr, :listener_database_url, :trusted_proxies,
|
|
106
|
+
:outbound_queue_messages, :outbound_queue_bytes,
|
|
107
|
+
:streams_previous_secrets, :turbo_previous_secrets,
|
|
108
|
+
:connection
|
|
109
|
+
|
|
110
|
+
# The application's `ActiveSupport::KeyGenerator` (set by the Engine) that
|
|
111
|
+
# derives a stream key when none is configured explicitly. Never assigned
|
|
112
|
+
# from cable.yml or `config.orbitcast`.
|
|
113
|
+
attr_accessor :key_generator
|
|
114
|
+
|
|
115
|
+
def initialize
|
|
116
|
+
@database_url = nil
|
|
117
|
+
@namespace = nil
|
|
118
|
+
@publish_via = :postgres
|
|
119
|
+
@mode = :signed
|
|
120
|
+
@streams_secret = nil
|
|
121
|
+
@turbo_secret = nil
|
|
122
|
+
@recovery_secret = nil
|
|
123
|
+
@pool_size = DEFAULT_POOL_SIZE
|
|
124
|
+
@pool_timeout = DEFAULT_POOL_TIMEOUT
|
|
125
|
+
@stream_expires_in = DEFAULT_STREAM_EXPIRES_IN
|
|
126
|
+
# Off by default: the relay requires the orbitcast_payloads migration,
|
|
127
|
+
# and nothing here ever creates it implicitly.
|
|
128
|
+
@overflow_enabled = false
|
|
129
|
+
@overflow_ttl = DEFAULT_OVERFLOW_TTL
|
|
130
|
+
@max_publication_size = DEFAULT_MAX_PUBLICATION_SIZE
|
|
131
|
+
# Rails-dispatch mode. The token is held here in raw form; the launcher
|
|
132
|
+
# exports it to the relay as strict base64.
|
|
133
|
+
@rpc_token = nil
|
|
134
|
+
@rpc_endpoint_url = nil
|
|
135
|
+
@rpc_mount_path = DEFAULT_RPC_MOUNT_PATH
|
|
136
|
+
@connection_class = DEFAULT_CONNECTION_CLASS
|
|
137
|
+
# nil derives the allowlist from app/channels; an Array is the reviewed,
|
|
138
|
+
# explicit form a deployment should ship.
|
|
139
|
+
@channels = nil
|
|
140
|
+
@ingress_url = nil
|
|
141
|
+
@ingress_token = nil
|
|
142
|
+
# Relay transport and tuning. All optional here; the launcher fills the
|
|
143
|
+
# development default and refuses a production boot without a listener.
|
|
144
|
+
@listen_addr = nil
|
|
145
|
+
@listener_database_url = nil
|
|
146
|
+
@trusted_proxies = [].freeze
|
|
147
|
+
@outbound_queue_messages = nil
|
|
148
|
+
@outbound_queue_bytes = nil
|
|
149
|
+
# Verify-only previous keys for rotation.
|
|
150
|
+
@streams_previous_secrets = [].freeze
|
|
151
|
+
@turbo_previous_secrets = [].freeze
|
|
152
|
+
@connection = :pool
|
|
153
|
+
@key_generator = nil
|
|
154
|
+
@derived_streams_secret = nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def dispatch?
|
|
158
|
+
mode == :dispatch
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def overflow_enabled?
|
|
162
|
+
@overflow_enabled
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Bulk assignment from `cable.yml`, `config.orbitcast`, or the installer.
|
|
166
|
+
# Unknown keys are a hard error: a typo must never leave a secret unset.
|
|
167
|
+
def assign(attributes)
|
|
168
|
+
unless attributes.respond_to?(:each_pair)
|
|
169
|
+
raise ConfigurationError, "expected a Hash of Orbitcast settings, got #{attributes.class}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
attributes.each_pair do |key, value|
|
|
173
|
+
name = key.to_sym
|
|
174
|
+
# `adapter:` is ActionCable's own selector, not an Orbitcast setting.
|
|
175
|
+
next if name == :adapter
|
|
176
|
+
|
|
177
|
+
unless ATTRIBUTES.include?(name)
|
|
178
|
+
raise ConfigurationError,
|
|
179
|
+
"unknown Orbitcast setting #{name.inspect}; known settings: #{ATTRIBUTES.join(", ")}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
public_send(:"#{name}=", value)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
self
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def database_url=(value)
|
|
189
|
+
@database_url = optional_string(:database_url, value)
|
|
190
|
+
return if @database_url.nil? || @database_url.match?(POSTGRES_URL_PATTERN)
|
|
191
|
+
|
|
192
|
+
raise ConfigurationError,
|
|
193
|
+
"database_url must be a postgres:// or postgresql:// DSN"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def namespace=(value)
|
|
197
|
+
namespace = required_string(:namespace, value)
|
|
198
|
+
unless namespace.match?(NAMESPACE_PATTERN)
|
|
199
|
+
raise ConfigurationError,
|
|
200
|
+
"namespace #{namespace.inspect} must be 1-64 chars of [A-Za-z0-9_.-] and start alphanumeric"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
@namespace = namespace
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def publish_via=(value)
|
|
207
|
+
@publish_via = enum_value(:publish_via, value, PUBLISH_VIA)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def mode=(value)
|
|
211
|
+
@mode = enum_value(:mode, value, MODES)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def streams_secret=(value)
|
|
215
|
+
@streams_secret = secret(:streams_secret, value)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The explicit key when configured; otherwise the key derived from the
|
|
219
|
+
# application's key generator and the namespace (see
|
|
220
|
+
# STREAMS_KEY_PURPOSE_FORMAT). Nil when neither is available.
|
|
221
|
+
def streams_secret
|
|
222
|
+
@streams_secret || derived_streams_secret
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def streams_secret_derived?
|
|
226
|
+
@streams_secret.nil? && !derived_streams_secret.nil?
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# The purpose string the derived key is generated under.
|
|
230
|
+
def streams_key_purpose(namespace = self.namespace)
|
|
231
|
+
raise ConfigurationError, "namespace is required to derive the stream key" if namespace.nil?
|
|
232
|
+
|
|
233
|
+
format(STREAMS_KEY_PURPOSE_FORMAT, namespace: namespace)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def turbo_secret=(value)
|
|
237
|
+
@turbo_secret = secret(:turbo_secret, value)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def recovery_secret=(value)
|
|
241
|
+
@recovery_secret = secret(:recovery_secret, value)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def pool_size=(value)
|
|
245
|
+
@pool_size = positive_integer(:pool_size, value)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def pool_timeout=(value)
|
|
249
|
+
@pool_timeout = positive_float(:pool_timeout, value)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def stream_expires_in=(value)
|
|
253
|
+
@stream_expires_in = positive_integer(:stream_expires_in, value)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Relay oversized publications through `orbitcast_payloads`. Requires the
|
|
257
|
+
# migration on the cable database *and* `overflow_enabled` on the server.
|
|
258
|
+
def overflow_enabled=(value)
|
|
259
|
+
@overflow_enabled = boolean(:overflow_enabled, value)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Relay row lifetime in seconds, measured from the database clock.
|
|
263
|
+
def overflow_ttl=(value)
|
|
264
|
+
@overflow_ttl = positive_integer(:overflow_ttl, value)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Ceiling on one publication's payload, in bytes.
|
|
268
|
+
def max_publication_size=(value)
|
|
269
|
+
size = positive_integer(:max_publication_size, value)
|
|
270
|
+
if size < MIN_MAX_PUBLICATION_SIZE
|
|
271
|
+
raise ConfigurationError,
|
|
272
|
+
"max_publication_size must be at least #{MIN_MAX_PUBLICATION_SIZE} bytes " \
|
|
273
|
+
"(PostgreSQL's NOTIFY payload limit)"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
@max_publication_size = size
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The raw service credential the relay presents as `Authorization: Bearer`.
|
|
280
|
+
#
|
|
281
|
+
# Rails holds the **raw** token; `exported_env` hands the relay the same
|
|
282
|
+
# bytes as strict base64 (`ORBITCAST_RPC_TOKEN_BASE64`), because the relay
|
|
283
|
+
# decodes key material rather than guessing a UTF-8 interpretation. The two
|
|
284
|
+
# are the same secret in two encodings, not two secrets.
|
|
285
|
+
def rpc_token=(value)
|
|
286
|
+
@rpc_token = secret(:rpc_token, value)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Where the relay reaches this application, e.g.
|
|
290
|
+
# `https://app.internal/_orbitcast/v1/rails`. Exported for convenience; an
|
|
291
|
+
# operator may set ORBITCAST_RPC_ENDPOINT_URL on the relay instead.
|
|
292
|
+
def rpc_endpoint_url=(value)
|
|
293
|
+
@rpc_endpoint_url = optional_string(:rpc_endpoint_url, value)
|
|
294
|
+
return if @rpc_endpoint_url.nil? || @rpc_endpoint_url.match?(HTTP_URL_PATTERN)
|
|
295
|
+
|
|
296
|
+
raise ConfigurationError, "rpc_endpoint_url must be an http:// or https:// URL"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Where the Engine mounts the private endpoint inside this application.
|
|
300
|
+
def rpc_mount_path=(value)
|
|
301
|
+
path = required_string(:rpc_mount_path, value)
|
|
302
|
+
unless path.start_with?("/")
|
|
303
|
+
raise ConfigurationError, "rpc_mount_path must start with a slash"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
@rpc_mount_path = path.chomp("/")
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def connection_class=(value)
|
|
310
|
+
@connection_class = value.is_a?(Class) ? value : required_string(:connection_class, value)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The channel allowlist: an Array of class names, or :auto to derive it
|
|
314
|
+
# from app/channels. A client-supplied channel name that is not on it is
|
|
315
|
+
# never constantized.
|
|
316
|
+
def channels=(value)
|
|
317
|
+
@channels =
|
|
318
|
+
case value
|
|
319
|
+
when nil then nil
|
|
320
|
+
when :auto, "auto" then :auto
|
|
321
|
+
when Array then value.map { |entry| entry.is_a?(Class) ? entry.name : entry.to_s }.freeze
|
|
322
|
+
else
|
|
323
|
+
raise ConfigurationError,
|
|
324
|
+
"channels must be an Array of channel class names or :auto, got #{value.class}"
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def ingress_url=(value)
|
|
329
|
+
@ingress_url = optional_string(:ingress_url, value)
|
|
330
|
+
return if @ingress_url.nil? || @ingress_url.match?(HTTP_URL_PATTERN)
|
|
331
|
+
|
|
332
|
+
raise ConfigurationError, "ingress_url must be an http:// or https:// URL"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def ingress_token=(value)
|
|
336
|
+
@ingress_token = secret(:ingress_token, value)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# `host:port` the relay binds its public listener to.
|
|
340
|
+
def listen_addr=(value)
|
|
341
|
+
addr = optional_string(:listen_addr, value)
|
|
342
|
+
if addr && !addr.match?(/\A\S+:\d{1,5}\z/)
|
|
343
|
+
raise ConfigurationError, "listen_addr must look like host:port, got #{addr.inspect}"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
@listen_addr = addr
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# A separate DSN for the relay's long-lived LISTEN session (direct or
|
|
350
|
+
# session-pooled; never PgBouncer transaction pooling). Must address the
|
|
351
|
+
# same cable database as `database_url`.
|
|
352
|
+
def listener_database_url=(value)
|
|
353
|
+
@listener_database_url = optional_string(:listener_database_url, value)
|
|
354
|
+
return if @listener_database_url.nil? || @listener_database_url.match?(POSTGRES_URL_PATTERN)
|
|
355
|
+
|
|
356
|
+
raise ConfigurationError,
|
|
357
|
+
"listener_database_url must be a postgres:// or postgresql:// DSN"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# CIDRs whose forwarded scheme/host/client headers the relay trusts.
|
|
361
|
+
def trusted_proxies=(value)
|
|
362
|
+
list = string_list(:trusted_proxies, value)
|
|
363
|
+
list.each do |cidr|
|
|
364
|
+
IPAddr.new(cidr)
|
|
365
|
+
rescue IPAddr::Error
|
|
366
|
+
raise ConfigurationError,
|
|
367
|
+
"trusted_proxies entry #{cidr.inspect} is not an IP address or CIDR (e.g. \"10.0.0.0/8\")"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
@trusted_proxies = list.freeze
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def outbound_queue_messages=(value)
|
|
374
|
+
@outbound_queue_messages = value.nil? ? nil : positive_integer(:outbound_queue_messages, value)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def outbound_queue_bytes=(value)
|
|
378
|
+
@outbound_queue_bytes = value.nil? ? nil : positive_integer(:outbound_queue_bytes, value)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Retired Orbitcast stream keys that still verify (never sign) during a
|
|
382
|
+
# rotation window. Raw bytes; exported as comma-separated strict base64.
|
|
383
|
+
def streams_previous_secrets=(value)
|
|
384
|
+
@streams_previous_secrets = secret_list(:streams_previous_secrets, value)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def turbo_previous_secrets=(value)
|
|
388
|
+
@turbo_previous_secrets = secret_list(:turbo_previous_secrets, value)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def connection=(value)
|
|
392
|
+
@connection = enum_value(:connection, value, CONNECTIONS)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def active_record_connection?
|
|
396
|
+
connection == :active_record
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Cross-field requirements. Called before anything opens a connection or
|
|
400
|
+
# signs a token.
|
|
401
|
+
def validate!
|
|
402
|
+
raise ConfigurationError, "namespace is required" if namespace.nil?
|
|
403
|
+
|
|
404
|
+
if publish_via == :postgres && database_url.nil?
|
|
405
|
+
raise ConfigurationError,
|
|
406
|
+
"database_url is required when publish_via is :postgres"
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
if publish_via == :http && ingress_url.nil?
|
|
410
|
+
raise ConfigurationError,
|
|
411
|
+
"ingress_url is required when publish_via is :http"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Never fall back to an unauthenticated endpoint: a dispatch-mode
|
|
415
|
+
# application without a service credential is a boot-time error, not a
|
|
416
|
+
# silently open callback surface.
|
|
417
|
+
if dispatch? && rpc_token.nil?
|
|
418
|
+
raise ConfigurationError,
|
|
419
|
+
"rpc_token is required when mode is :dispatch; the relay authenticates every " \
|
|
420
|
+
"call with it (ORBITCAST_RPC_TOKEN_BASE64 on the relay side)"
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
self
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def streams_secret!
|
|
427
|
+
return streams_secret if streams_secret
|
|
428
|
+
|
|
429
|
+
raise ConfigurationError,
|
|
430
|
+
"streams_secret is not configured; set config.orbitcast.streams_secret " \
|
|
431
|
+
"(or ORBITCAST_STREAMS_SECRET) before signing stream names, or let the Engine " \
|
|
432
|
+
"derive one from Rails.application.key_generator (requires a namespace)"
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# The allowlisted `ORBITCAST_*` values handed to the Rust process.
|
|
436
|
+
#
|
|
437
|
+
# Key material is exported with strict base64 so the server can decode raw
|
|
438
|
+
# key bytes instead of guessing a UTF-8 interpretation. Nothing here is
|
|
439
|
+
# ever passed as an argv element.
|
|
440
|
+
def exported_env
|
|
441
|
+
# `mode` and `publish_via` are Ruby-side concerns (adapter behaviour and
|
|
442
|
+
# token production); the relay reads neither, so they are not exported.
|
|
443
|
+
env = {
|
|
444
|
+
"ORBITCAST_NAMESPACE" => namespace,
|
|
445
|
+
"ORBITCAST_DATABASE_URL" => database_url,
|
|
446
|
+
# Both sides of the relay must agree: the publisher writes the row, the
|
|
447
|
+
# server reads it back and owns expiry cleanup.
|
|
448
|
+
"ORBITCAST_OVERFLOW_ENABLED" => overflow_enabled.to_s,
|
|
449
|
+
"ORBITCAST_OVERFLOW_TTL_MS" => (overflow_ttl * 1000).to_s,
|
|
450
|
+
"ORBITCAST_MAX_PUBLICATION_SIZE" => max_publication_size.to_s
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
env["ORBITCAST_LISTEN_ADDR"] = listen_addr
|
|
454
|
+
env["ORBITCAST_LISTENER_DATABASE_URL"] = listener_database_url
|
|
455
|
+
env["ORBITCAST_TRUSTED_PROXIES"] = trusted_proxies.join(",") unless trusted_proxies.empty?
|
|
456
|
+
env["ORBITCAST_OUTBOUND_QUEUE_MESSAGES"] = outbound_queue_messages&.to_s
|
|
457
|
+
env["ORBITCAST_OUTBOUND_QUEUE_BYTES"] = outbound_queue_bytes&.to_s
|
|
458
|
+
|
|
459
|
+
env["ORBITCAST_STREAMS_SECRET_BASE64"] = Base64.strict_encode64(streams_secret) if streams_secret
|
|
460
|
+
env["ORBITCAST_RECOVERY_SECRET_BASE64"] = Base64.strict_encode64(recovery_secret) if recovery_secret
|
|
461
|
+
unless streams_previous_secrets.empty?
|
|
462
|
+
env["ORBITCAST_STREAMS_PREVIOUS_SECRETS_BASE64"] =
|
|
463
|
+
streams_previous_secrets.map { |key| Base64.strict_encode64(key) }.join(",")
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# The Turbo profile is enabled exactly when a Turbo key is configured:
|
|
467
|
+
# a relay handed the key without the switch would reject every
|
|
468
|
+
# `turbo_stream_from` subscription, and vice versa.
|
|
469
|
+
if turbo_secret
|
|
470
|
+
env["ORBITCAST_TURBO_STREAMS"] = "true"
|
|
471
|
+
env["ORBITCAST_TURBO_SECRET_BASE64"] = Base64.strict_encode64(turbo_secret)
|
|
472
|
+
unless turbo_previous_secrets.empty?
|
|
473
|
+
env["ORBITCAST_TURBO_PREVIOUS_SECRETS_BASE64"] =
|
|
474
|
+
turbo_previous_secrets.map { |key| Base64.strict_encode64(key) }.join(",")
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Rails-dispatch mode: the relay needs the endpoint URL and the same
|
|
479
|
+
# service credential, base64 of the exact raw bytes Rails compares
|
|
480
|
+
# against. Exported only in dispatch mode, so a signed-mode deployment
|
|
481
|
+
# cannot accidentally hand a token to a relay that will not use it.
|
|
482
|
+
if dispatch?
|
|
483
|
+
env["ORBITCAST_RPC_ENDPOINT_URL"] = rpc_endpoint_url
|
|
484
|
+
env["ORBITCAST_RPC_TOKEN_BASE64"] = Base64.strict_encode64(rpc_token) if rpc_token
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
env.compact
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Never let a secret reach a log line or an exception message.
|
|
491
|
+
def inspect
|
|
492
|
+
"#<#{self.class.name} namespace=#{namespace.inspect} mode=#{mode.inspect} " \
|
|
493
|
+
"publish_via=#{publish_via.inspect} connection=#{connection.inspect} " \
|
|
494
|
+
"database_url=#{redacted_database_url.inspect} " \
|
|
495
|
+
"listener_database_url=#{redacted_url(listener_database_url).inspect} " \
|
|
496
|
+
"listen_addr=#{listen_addr.inspect} overflow_enabled=#{overflow_enabled.inspect} " \
|
|
497
|
+
"streams_secret=#{streams_secret_derived? ? "[DERIVED]" : redact(streams_secret)} " \
|
|
498
|
+
"turbo_secret=#{redact(turbo_secret)} " \
|
|
499
|
+
"rpc_token=#{redact(rpc_token)} ingress_token=#{redact(ingress_token)}>"
|
|
500
|
+
end
|
|
501
|
+
alias to_s inspect
|
|
502
|
+
|
|
503
|
+
private
|
|
504
|
+
|
|
505
|
+
def redacted_database_url
|
|
506
|
+
redacted_url(database_url)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def redacted_url(url)
|
|
510
|
+
return nil if url.nil?
|
|
511
|
+
|
|
512
|
+
url.sub(%r{://([^:/@]+):[^@]*@}, '://\1:[REDACTED]@')
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Memoized per namespace: a namespace change after derivation must not
|
|
516
|
+
# keep handing out the previous namespace's key.
|
|
517
|
+
def derived_streams_secret
|
|
518
|
+
return nil if key_generator.nil? || namespace.nil?
|
|
519
|
+
|
|
520
|
+
if @derived_streams_secret.nil? || @derived_streams_secret.first != namespace
|
|
521
|
+
key = key_generator.generate_key(streams_key_purpose(namespace), STREAMS_KEY_SIZE)
|
|
522
|
+
@derived_streams_secret = [namespace, key.to_s.b.freeze]
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
@derived_streams_secret.last
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def redact(value)
|
|
529
|
+
value.nil? ? "nil" : "[REDACTED]"
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def optional_string(name, value)
|
|
533
|
+
return nil if value.nil?
|
|
534
|
+
|
|
535
|
+
string = string!(name, value)
|
|
536
|
+
string.empty? ? nil : string
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def required_string(name, value)
|
|
540
|
+
string = string!(name, value)
|
|
541
|
+
raise ConfigurationError, "#{name} must not be empty" if string.empty?
|
|
542
|
+
|
|
543
|
+
string
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def string!(name, value)
|
|
547
|
+
unless value.is_a?(String)
|
|
548
|
+
raise ConfigurationError, "#{name} must be a String, got #{value.class}"
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
value
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def secret(name, value)
|
|
555
|
+
return nil if value.nil?
|
|
556
|
+
|
|
557
|
+
string = string!(name, value)
|
|
558
|
+
raise ConfigurationError, "#{name} must not be empty" if string.empty?
|
|
559
|
+
|
|
560
|
+
string.dup.freeze
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def secret_list(name, value)
|
|
564
|
+
return [].freeze if value.nil?
|
|
565
|
+
|
|
566
|
+
unless value.is_a?(Array)
|
|
567
|
+
raise ConfigurationError, "#{name} must be an Array of secrets, got #{value.class}"
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
value.map { |entry| secret(name, entry) || raise(ConfigurationError, "#{name} must not contain nil") }
|
|
571
|
+
.freeze
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def string_list(name, value)
|
|
575
|
+
return [] if value.nil?
|
|
576
|
+
|
|
577
|
+
list = value.is_a?(String) ? value.split(",").map(&:strip) : value
|
|
578
|
+
unless list.is_a?(Array)
|
|
579
|
+
raise ConfigurationError, "#{name} must be an Array of Strings, got #{value.class}"
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
list.map { |entry| required_string(name, entry) }
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# Strict: a typo must never quietly disable (or enable) a switch.
|
|
586
|
+
def boolean(name, value)
|
|
587
|
+
case value
|
|
588
|
+
when true, false then value
|
|
589
|
+
when "true", "1", 1 then true
|
|
590
|
+
when "false", "0", 0 then false
|
|
591
|
+
else
|
|
592
|
+
raise ConfigurationError,
|
|
593
|
+
"#{name} must be a boolean (true/false), got #{value.inspect}"
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def enum_value(name, value, allowed)
|
|
598
|
+
symbol =
|
|
599
|
+
case value
|
|
600
|
+
when Symbol then value
|
|
601
|
+
when String then value.to_sym
|
|
602
|
+
else
|
|
603
|
+
raise ConfigurationError, "#{name} must be a Symbol or String, got #{value.class}"
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
unless allowed.include?(symbol)
|
|
607
|
+
raise ConfigurationError,
|
|
608
|
+
"#{name} #{symbol.inspect} is not supported; supported values: #{allowed.map(&:inspect).join(", ")}"
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
symbol
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def positive_integer(name, value)
|
|
615
|
+
integer = Integer(value)
|
|
616
|
+
raise ConfigurationError, "#{name} must be greater than zero" unless integer.positive?
|
|
617
|
+
|
|
618
|
+
integer
|
|
619
|
+
rescue TypeError, ArgumentError => e
|
|
620
|
+
raise e if e.is_a?(ConfigurationError)
|
|
621
|
+
|
|
622
|
+
raise ConfigurationError, "#{name} must be an Integer, got #{value.inspect}"
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def positive_float(name, value)
|
|
626
|
+
float = Float(value)
|
|
627
|
+
raise ConfigurationError, "#{name} must be greater than zero" unless float.positive?
|
|
628
|
+
|
|
629
|
+
float
|
|
630
|
+
rescue TypeError, ArgumentError => e
|
|
631
|
+
raise e if e.is_a?(ConfigurationError)
|
|
632
|
+
|
|
633
|
+
raise ConfigurationError, "#{name} must be a Float, got #{value.inspect}"
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
end
|