orbitcast 0.3.1-x86_64-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/x86_64-linux-gnu/bin/orbitcast +0 -0
- data/vendor/x86_64-linux-gnu/manifest.json +14 -0
- metadata +130 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
require_relative "../error"
|
|
8
|
+
require_relative "../streams"
|
|
9
|
+
require_relative "instrumentation"
|
|
10
|
+
|
|
11
|
+
module Orbitcast
|
|
12
|
+
module Publisher
|
|
13
|
+
# Direct PostgreSQL publisher.
|
|
14
|
+
#
|
|
15
|
+
# Rails encodes a broadcast, this wraps it in a versioned notice envelope and
|
|
16
|
+
# emits `SELECT pg_notify($1, $2)` on a bounded, fork-safe connection pool.
|
|
17
|
+
# Delivery back to the publishing node happens through that node's own
|
|
18
|
+
# PostgreSQL listener, so there is no local fan-out here.
|
|
19
|
+
#
|
|
20
|
+
# A publication too large to inline either raises `Orbitcast::PayloadTooLarge`
|
|
21
|
+
# or, with `overflow_enabled`, is relayed: the row and its reference are
|
|
22
|
+
# written in one transaction, so `NOTIFY` becomes visible only once the row
|
|
23
|
+
# is readable, and a rollback sends nothing.
|
|
24
|
+
#
|
|
25
|
+
# ## Which connection
|
|
26
|
+
#
|
|
27
|
+
# `config.connection` picks it:
|
|
28
|
+
#
|
|
29
|
+
# * `:pool` (default) — a bounded, fork-safe dedicated `pg` pool for
|
|
30
|
+
# `database_url`. Independent of Active Record: a broadcast is visible as
|
|
31
|
+
# soon as the statement commits, whatever the application's transaction
|
|
32
|
+
# is doing. Safe behind PgBouncer transaction pooling.
|
|
33
|
+
# * `:active_record` — the application's Active Record connection
|
|
34
|
+
# (`ActiveRecord::Base.with_connection { |c| c.raw_connection }`). The
|
|
35
|
+
# `NOTIFY` (and the overflow row) join the surrounding
|
|
36
|
+
# `ActiveRecord::Base.transaction`, so a rollback sends nothing and a
|
|
37
|
+
# commit publishes atomically with the application's writes. The cable
|
|
38
|
+
# database must then be the application's primary database.
|
|
39
|
+
#
|
|
40
|
+
# Either way, every publish emits `publish.orbitcast` through
|
|
41
|
+
# `ActiveSupport::Notifications` (`stream:`, `bytes:`, `via: :postgres`),
|
|
42
|
+
# including when it raises.
|
|
43
|
+
class Postgres
|
|
44
|
+
# PostgreSQL's default NOTIFY payload limit is 8000 bytes *including* the
|
|
45
|
+
# terminator; the encoded notice must stay strictly below it.
|
|
46
|
+
MAX_NOTIFY_BYTES = 8000
|
|
47
|
+
|
|
48
|
+
CHANNEL_PREFIX = "orbitcast_"
|
|
49
|
+
CHANNEL_DIGEST_LENGTH = 48
|
|
50
|
+
|
|
51
|
+
NOTICE_VERSION = 1
|
|
52
|
+
PUBLICATION_VERSION = 1
|
|
53
|
+
INLINE_KIND = "inline"
|
|
54
|
+
OVERFLOW_KIND = "overflow"
|
|
55
|
+
|
|
56
|
+
NOTIFY_SQL = "SELECT pg_notify($1, $2)"
|
|
57
|
+
|
|
58
|
+
# Column contract shared with the Rust server and
|
|
59
|
+
# db/orbitcast_migrate/001_create_orbitcast_payloads.rb. `expires_at` is
|
|
60
|
+
# computed from the *database* clock, never from this process's clock.
|
|
61
|
+
OVERFLOW_INSERT_SQL = <<~SQL.freeze
|
|
62
|
+
INSERT INTO orbitcast_payloads (id, namespace, stream, json_payload, expires_at)
|
|
63
|
+
VALUES ($1::text::uuid, $2, $3, $4, now() + ($5::bigint * interval '1 second'))
|
|
64
|
+
SQL
|
|
65
|
+
|
|
66
|
+
MIGRATION_HINT =
|
|
67
|
+
"run the overflow relay migration on the cable database " \
|
|
68
|
+
"(bin/rails orbitcast:install:migrations && bin/rails db:migrate)"
|
|
69
|
+
|
|
70
|
+
# Routing, not authorization: `orbitcast_` + the first 48 lowercase hex
|
|
71
|
+
# characters of SHA-256 over the length-prefixed UTF-8 (namespace, stream)
|
|
72
|
+
# tuple. 58 ASCII bytes, below PostgreSQL's 63-byte identifier limit.
|
|
73
|
+
#
|
|
74
|
+
# Length prefixes make the tuple unambiguous: ("ab", "c") and ("a", "bc")
|
|
75
|
+
# must not collide. Both parts are hashed as raw bytes (`String#b`) so a
|
|
76
|
+
# Unicode stream name does not raise an encoding error during concat.
|
|
77
|
+
def self.channel_name(namespace, stream)
|
|
78
|
+
namespace = namespace.to_s
|
|
79
|
+
stream = stream.to_s
|
|
80
|
+
|
|
81
|
+
tuple = +"".b
|
|
82
|
+
tuple << [namespace.bytesize].pack("N") << namespace.b
|
|
83
|
+
tuple << [stream.bytesize].pack("N") << stream.b
|
|
84
|
+
|
|
85
|
+
"#{CHANNEL_PREFIX}#{OpenSSL::Digest::SHA256.hexdigest(tuple)[0, CHANNEL_DIGEST_LENGTH]}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @param configuration [Orbitcast::Configuration]
|
|
89
|
+
# @param connection_factory [#call] override used by tests; must return an
|
|
90
|
+
# object responding to `exec_params`, `close`, and `finished?`
|
|
91
|
+
# @param active_record [#with_connection, #transaction] the Active Record
|
|
92
|
+
# base used by `connection: :active_record`; defaults to
|
|
93
|
+
# `::ActiveRecord::Base`, loaded on first use
|
|
94
|
+
# @param instrumenter [#instrument] defaults to
|
|
95
|
+
# `ActiveSupport::Notifications` when loaded
|
|
96
|
+
def initialize(configuration = Orbitcast.config, connection_factory: nil,
|
|
97
|
+
active_record: nil, instrumenter: nil)
|
|
98
|
+
@configuration = configuration.validate!
|
|
99
|
+
@active_record = active_record
|
|
100
|
+
@instrumenter = Instrumentation.resolve(instrumenter)
|
|
101
|
+
@pool =
|
|
102
|
+
if configuration.active_record_connection?
|
|
103
|
+
nil
|
|
104
|
+
else
|
|
105
|
+
Pool.new(
|
|
106
|
+
size: configuration.pool_size,
|
|
107
|
+
timeout: configuration.pool_timeout,
|
|
108
|
+
&(connection_factory || method(:connect))
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
attr_reader :configuration
|
|
114
|
+
|
|
115
|
+
# @param stream [String] logical stream name
|
|
116
|
+
# @param payload [String] JSON text already encoded by ActionCable; it is
|
|
117
|
+
# embedded verbatim, never re-encoded
|
|
118
|
+
# @return [String] publication UUID
|
|
119
|
+
def publish(stream, payload)
|
|
120
|
+
# Both guards run before anything is instrumented or touches SQL: an
|
|
121
|
+
# invalid stream (empty, over 1024 UTF-8 bytes) or a non-String payload
|
|
122
|
+
# is a caller error, not a publication that failed.
|
|
123
|
+
stream = Streams.validated_stream(stream)
|
|
124
|
+
payload = validated_payload(payload)
|
|
125
|
+
|
|
126
|
+
Instrumentation.publish(@instrumenter, stream: stream, bytes: payload.bytesize, via: :postgres) do
|
|
127
|
+
if payload.bytesize > configuration.max_publication_size
|
|
128
|
+
raise PayloadTooLarge,
|
|
129
|
+
"publication payload is #{payload.bytesize} bytes; the configured maximum is " \
|
|
130
|
+
"#{configuration.max_publication_size} bytes (max_publication_size). The overflow " \
|
|
131
|
+
"relay carries large Turbo HTML, not arbitrarily large objects."
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
publication_id = SecureRandom.uuid
|
|
135
|
+
notice = inline_notice(publication_id, stream, payload)
|
|
136
|
+
channel = self.class.channel_name(configuration.namespace, stream)
|
|
137
|
+
|
|
138
|
+
if notice.bytesize < MAX_NOTIFY_BYTES
|
|
139
|
+
run { |connection| connection.exec_params(NOTIFY_SQL, [channel, notice]) }
|
|
140
|
+
next publication_id
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
unless configuration.overflow_enabled?
|
|
144
|
+
raise PayloadTooLarge,
|
|
145
|
+
"encoded notice is #{notice.bytesize} bytes; PostgreSQL NOTIFY payloads must be " \
|
|
146
|
+
"under #{MAX_NOTIFY_BYTES} bytes. Enable the overflow relay " \
|
|
147
|
+
"(config.orbitcast.overflow_enabled = true) and #{MIGRATION_HINT}, or broadcast a " \
|
|
148
|
+
"smaller payload (for example a reference the client re-fetches)."
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
publish_overflow(channel, publication_id, stream, payload)
|
|
152
|
+
publication_id
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def shutdown
|
|
157
|
+
@pool&.shutdown
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
# Store the payload and notify its reference in ONE transaction.
|
|
163
|
+
#
|
|
164
|
+
# A listener that receives the reference is therefore guaranteed to be
|
|
165
|
+
# able to read the row (NOTIFY is delivered on commit), and a failure
|
|
166
|
+
# anywhere in the sequence sends nothing and leaves no row: the pool
|
|
167
|
+
# discards a connection whose statement raised, and closing it aborts the
|
|
168
|
+
# open transaction server-side. On the Active Record connection the same
|
|
169
|
+
# two statements join the application's transaction instead.
|
|
170
|
+
def publish_overflow(channel, publication_id, stream, payload)
|
|
171
|
+
notice = overflow_notice(publication_id, stream)
|
|
172
|
+
|
|
173
|
+
run(transaction: true) do |connection|
|
|
174
|
+
connection.exec_params(
|
|
175
|
+
OVERFLOW_INSERT_SQL,
|
|
176
|
+
[publication_id, configuration.namespace, stream, payload, configuration.overflow_ttl]
|
|
177
|
+
)
|
|
178
|
+
connection.exec_params(NOTIFY_SQL, [channel, notice])
|
|
179
|
+
end
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
raise OverflowTableMissing, "#{MIGRATION_HINT}: #{e.message}" if overflow_table_missing?(e)
|
|
182
|
+
|
|
183
|
+
raise
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Yield a raw PostgreSQL connection (`#exec_params`) from wherever the
|
|
187
|
+
# configuration says it comes from, optionally inside a transaction.
|
|
188
|
+
def run(transaction: false, &block)
|
|
189
|
+
return run_on_active_record(transaction, &block) if configuration.active_record_connection?
|
|
190
|
+
|
|
191
|
+
@pool.with do |connection|
|
|
192
|
+
next yield(connection) unless transaction
|
|
193
|
+
|
|
194
|
+
connection.exec_params("BEGIN", [])
|
|
195
|
+
result = yield(connection)
|
|
196
|
+
connection.exec_params("COMMIT", [])
|
|
197
|
+
result
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# `ActiveRecord::Base.transaction` joins an open transaction rather than
|
|
202
|
+
# starting a second one, which is the whole point: the NOTIFY commits —
|
|
203
|
+
# or rolls back — together with the application's own writes.
|
|
204
|
+
def run_on_active_record(transaction)
|
|
205
|
+
base = active_record
|
|
206
|
+
body = -> { base.with_connection { |connection| yield connection.raw_connection } }
|
|
207
|
+
|
|
208
|
+
transaction ? base.transaction(&body) : body.call
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def active_record
|
|
212
|
+
@active_record ||= begin
|
|
213
|
+
require "active_record"
|
|
214
|
+
::ActiveRecord::Base
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# PG raises `PG::UndefinedTable` (SQLSTATE 42P01); the class check is
|
|
219
|
+
# guarded so this file still works with a test double.
|
|
220
|
+
def overflow_table_missing?(error)
|
|
221
|
+
return true if defined?(PG::UndefinedTable) && error.is_a?(PG::UndefinedTable)
|
|
222
|
+
|
|
223
|
+
error.message.to_s.match?(/orbitcast_payloads.*does not exist/m)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def inline_notice(publication_id, stream, payload)
|
|
227
|
+
JSON.generate(
|
|
228
|
+
version: NOTICE_VERSION,
|
|
229
|
+
kind: INLINE_KIND,
|
|
230
|
+
publication: {
|
|
231
|
+
version: PUBLICATION_VERSION,
|
|
232
|
+
namespace: configuration.namespace,
|
|
233
|
+
id: publication_id,
|
|
234
|
+
stream: stream,
|
|
235
|
+
json_payload: payload
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The reference is routing, not authorization: the server re-validates
|
|
241
|
+
# namespace and stream against the row it fetches.
|
|
242
|
+
def overflow_notice(publication_id, stream)
|
|
243
|
+
JSON.generate(
|
|
244
|
+
version: NOTICE_VERSION,
|
|
245
|
+
kind: OVERFLOW_KIND,
|
|
246
|
+
id: publication_id,
|
|
247
|
+
namespace: configuration.namespace,
|
|
248
|
+
stream: stream
|
|
249
|
+
)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def validated_payload(payload)
|
|
253
|
+
unless payload.is_a?(String)
|
|
254
|
+
raise ArgumentError,
|
|
255
|
+
"payload must be the JSON text encoded by ActionCable, got #{payload.class}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
unless payload.valid_encoding?
|
|
259
|
+
raise ArgumentError, "payload is not valid #{payload.encoding}"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
payload.encoding == Encoding::UTF_8 ? payload : payload.encode(Encoding::UTF_8)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def connect
|
|
266
|
+
require "pg"
|
|
267
|
+
PG.connect(configuration.database_url)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Bounded, fork-safe connection pool.
|
|
271
|
+
#
|
|
272
|
+
# No `connection_pool` dependency: this needs one primitive, and the
|
|
273
|
+
# fork-safety rule (a child must never touch a socket inherited from its
|
|
274
|
+
# parent) is easier to state than to configure.
|
|
275
|
+
class Pool
|
|
276
|
+
def initialize(size:, timeout:, &factory)
|
|
277
|
+
@size = Integer(size)
|
|
278
|
+
@timeout = Float(timeout)
|
|
279
|
+
@factory = factory
|
|
280
|
+
@mutex = Mutex.new
|
|
281
|
+
@resource = ConditionVariable.new
|
|
282
|
+
@available = []
|
|
283
|
+
@created = 0
|
|
284
|
+
@pid = Process.pid
|
|
285
|
+
@shutdown = false
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def with
|
|
289
|
+
connection = checkout
|
|
290
|
+
begin
|
|
291
|
+
yield connection
|
|
292
|
+
rescue StandardError
|
|
293
|
+
# A connection that raised may be in an unknown protocol state.
|
|
294
|
+
discard(connection)
|
|
295
|
+
raise
|
|
296
|
+
else
|
|
297
|
+
checkin(connection)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def shutdown
|
|
302
|
+
connections =
|
|
303
|
+
@mutex.synchronize do
|
|
304
|
+
@shutdown = true
|
|
305
|
+
taken = @available
|
|
306
|
+
@available = []
|
|
307
|
+
@created = 0
|
|
308
|
+
taken
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
connections.each { |connection| close(connection) }
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private
|
|
316
|
+
|
|
317
|
+
def checkout
|
|
318
|
+
deadline = now + @timeout
|
|
319
|
+
|
|
320
|
+
loop do
|
|
321
|
+
connection = nil
|
|
322
|
+
|
|
323
|
+
@mutex.synchronize do
|
|
324
|
+
raise Orbitcast::Error, "publisher pool is shut down" if @shutdown
|
|
325
|
+
|
|
326
|
+
discard_after_fork
|
|
327
|
+
|
|
328
|
+
if (pooled = @available.pop)
|
|
329
|
+
connection = pooled
|
|
330
|
+
elsif @created < @size
|
|
331
|
+
@created += 1
|
|
332
|
+
connection = :build
|
|
333
|
+
else
|
|
334
|
+
remaining = deadline - now
|
|
335
|
+
if remaining <= 0
|
|
336
|
+
raise PoolTimeout,
|
|
337
|
+
"timed out after #{@timeout}s waiting for one of #{@size} orbitcast " \
|
|
338
|
+
"publisher connections"
|
|
339
|
+
end
|
|
340
|
+
@resource.wait(@mutex, remaining)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
next if connection.nil?
|
|
345
|
+
return connection unless connection == :build
|
|
346
|
+
|
|
347
|
+
return build
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def build
|
|
352
|
+
@factory.call
|
|
353
|
+
rescue StandardError
|
|
354
|
+
@mutex.synchronize do
|
|
355
|
+
@created -= 1
|
|
356
|
+
@resource.signal
|
|
357
|
+
end
|
|
358
|
+
raise
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def checkin(connection)
|
|
362
|
+
closed =
|
|
363
|
+
@mutex.synchronize do
|
|
364
|
+
if @shutdown || @pid != Process.pid
|
|
365
|
+
@created -= 1
|
|
366
|
+
true
|
|
367
|
+
else
|
|
368
|
+
@available.push(connection)
|
|
369
|
+
@resource.signal
|
|
370
|
+
false
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
close(connection) if closed
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def discard(connection)
|
|
378
|
+
@mutex.synchronize do
|
|
379
|
+
@created -= 1
|
|
380
|
+
@resource.signal
|
|
381
|
+
end
|
|
382
|
+
close(connection)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Sockets inherited across `fork` belong to the parent process: drop the
|
|
386
|
+
# references without closing them, and let this process open its own.
|
|
387
|
+
def discard_after_fork
|
|
388
|
+
return if @pid == Process.pid
|
|
389
|
+
|
|
390
|
+
@available = []
|
|
391
|
+
@created = 0
|
|
392
|
+
@pid = Process.pid
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def close(connection)
|
|
396
|
+
connection.close if connection.respond_to?(:close)
|
|
397
|
+
rescue StandardError
|
|
398
|
+
nil
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def now
|
|
402
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/security_utils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "error"
|
|
7
|
+
require_relative "protocol"
|
|
8
|
+
|
|
9
|
+
module Orbitcast
|
|
10
|
+
# The private Rack endpoint the relay calls.
|
|
11
|
+
#
|
|
12
|
+
# POST {mount}/{operation}
|
|
13
|
+
# Authorization: Bearer <rpc_token>
|
|
14
|
+
# Content-Type: application/json
|
|
15
|
+
#
|
|
16
|
+
# Mounted by `Orbitcast::Engine` only in `mode: :dispatch`, at
|
|
17
|
+
# `config.orbitcast.rpc_mount_path` (default `/_orbitcast/v1/rails`).
|
|
18
|
+
#
|
|
19
|
+
# !!! This endpoint speaks for every user of the application. Block its path
|
|
20
|
+
# !!! at the public proxy *and* keep the service credential rotating: the
|
|
21
|
+
# !!! bearer check here is the second lock, not the only one.
|
|
22
|
+
#
|
|
23
|
+
# ## Where an error goes
|
|
24
|
+
#
|
|
25
|
+
# Two different failures need two different answers, and conflating them is
|
|
26
|
+
# how a transport bug turns into a silent authorization change:
|
|
27
|
+
#
|
|
28
|
+
# * **Transport and envelope problems answer with an HTTP status.** A missing
|
|
29
|
+
# or wrong credential, an unknown operation path, a body that is not JSON, a
|
|
30
|
+
# body whose `operation` disagrees with the route, a foreign namespace, an
|
|
31
|
+
# envelope version this release cannot read. None of these can produce a
|
|
32
|
+
# *correlated* reply — the correlation fields are in the very envelope that
|
|
33
|
+
# failed to parse — so there is nothing honest to put in a 200 body. The
|
|
34
|
+
# relay treats a non-2xx as a temporary backend failure and closes affected
|
|
35
|
+
# sessions with `reconnect: true`; it never downgrades to acceptance.
|
|
36
|
+
#
|
|
37
|
+
# * **Application problems answer `200` with `outcome: "fault"`.** The
|
|
38
|
+
# envelope was fine, Rails simply could not run it: an exception in a
|
|
39
|
+
# callback, a pool timeout, a missing record. The reply correlates, so the
|
|
40
|
+
# relay can tie the failure to the session and command that caused it.
|
|
41
|
+
#
|
|
42
|
+
# A refusal is neither: `denied` is a decision and travels as a `200`.
|
|
43
|
+
#
|
|
44
|
+
# Every non-2xx body is JSON: `{"version":1,"error":"<code>","message":"..."}`
|
|
45
|
+
# with `content-type: application/json` — the same envelope the relay's
|
|
46
|
+
# ingress uses for its own errors.
|
|
47
|
+
class RailsEndpoint
|
|
48
|
+
# Ceiling on one request body. Matches the relay's own reply cap: neither
|
|
49
|
+
# side reads an unbounded body from the other.
|
|
50
|
+
MAX_REQUEST_BYTES = 2 * 1024 * 1024
|
|
51
|
+
|
|
52
|
+
JSON_CONTENT_TYPE = "application/json"
|
|
53
|
+
BEARER_PREFIX = "Bearer "
|
|
54
|
+
|
|
55
|
+
def initialize(config: nil, dispatcher: nil, executor: nil)
|
|
56
|
+
@config = config
|
|
57
|
+
@dispatcher = dispatcher
|
|
58
|
+
@executor = executor
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def config
|
|
62
|
+
@config || Orbitcast.config
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def dispatcher
|
|
66
|
+
@dispatcher ||= begin
|
|
67
|
+
require_relative "dispatcher"
|
|
68
|
+
Dispatcher.new(config: config)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Every call runs inside the Rails executor: `CurrentAttributes` are reset
|
|
73
|
+
# and Active Record connection leases are released when it completes, so a
|
|
74
|
+
# WebSocket's lifetime never pins a database connection. Mounted in the
|
|
75
|
+
# application's routes the request is already inside the reloader, and a
|
|
76
|
+
# nested wrap is a no-op; driven directly (tests, a bare Rack server) this
|
|
77
|
+
# is the only thing that cleans up.
|
|
78
|
+
def executor
|
|
79
|
+
@executor ||= if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
80
|
+
::Rails.application.executor
|
|
81
|
+
else
|
|
82
|
+
NullExecutor
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def call(env)
|
|
87
|
+
executor.wrap { handle(env) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def handle(env)
|
|
93
|
+
return error(405, "method_not_allowed", "method not allowed") unless env["REQUEST_METHOD"] == "POST"
|
|
94
|
+
|
|
95
|
+
# Authentication first: an unauthenticated caller learns nothing about
|
|
96
|
+
# which operations exist or what this endpoint thinks of its body.
|
|
97
|
+
return error(401, "unauthorized", "unauthorized") unless authenticated?(env)
|
|
98
|
+
|
|
99
|
+
operation = operation_from(env)
|
|
100
|
+
return error(404, "not_found", "unknown operation") if operation.nil?
|
|
101
|
+
|
|
102
|
+
content_type = env["CONTENT_TYPE"].to_s
|
|
103
|
+
unless content_type.empty? || content_type.split(";").first.to_s.strip == JSON_CONTENT_TYPE
|
|
104
|
+
return error(415, "unsupported_media_type", "request body must be application/json")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
body = read_body(env)
|
|
108
|
+
return error(413, "payload_too_large", "request body exceeds #{MAX_REQUEST_BYTES} bytes") if body == :too_large
|
|
109
|
+
|
|
110
|
+
decoded =
|
|
111
|
+
begin
|
|
112
|
+
JSON.parse(body)
|
|
113
|
+
rescue JSON::ParserError
|
|
114
|
+
return error(400, "malformed_json", "malformed JSON")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
unless decoded.is_a?(Hash)
|
|
118
|
+
return error(400, "invalid_request", "request body must be a JSON object")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The path segment and the envelope must agree, which is what stops a
|
|
122
|
+
# captured body being replayed against a different operation.
|
|
123
|
+
unless decoded["operation"] == operation
|
|
124
|
+
return error(400, "operation_mismatch", "operation does not match the request path")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
request =
|
|
128
|
+
begin
|
|
129
|
+
Protocol::V1::Request.from(decoded).validate!
|
|
130
|
+
rescue ProtocolError => e
|
|
131
|
+
return error(400, "invalid_request", "invalid request envelope: #{e.message}")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
unless request.namespace == config.namespace
|
|
135
|
+
return error(403, "namespace_mismatch", "namespace mismatch")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
reply = dispatcher.call(request)
|
|
139
|
+
json(200, reply.to_json)
|
|
140
|
+
rescue ProtocolError => e
|
|
141
|
+
# The dispatcher produced a reply this process would itself reject.
|
|
142
|
+
# Fail closed rather than emit it.
|
|
143
|
+
log_error("Orbitcast endpoint refused to emit an invalid reply: #{e.message}")
|
|
144
|
+
error(500, "invalid_reply", "invalid reply envelope")
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
log_error("Orbitcast endpoint failed: #{e.class}: #{e.message}")
|
|
147
|
+
error(500, "internal_error", "internal error")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The operation is the last path segment, and must be one this release
|
|
151
|
+
# implements. Anything else is a 404: unknown routes do not exist here.
|
|
152
|
+
def operation_from(env)
|
|
153
|
+
segment = env["PATH_INFO"].to_s.split("/").last
|
|
154
|
+
Protocol::V1::OPERATIONS.include?(segment) ? segment : nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def authenticated?(env)
|
|
158
|
+
token = config.rpc_token
|
|
159
|
+
return false if token.nil? || token.empty?
|
|
160
|
+
|
|
161
|
+
header = env["HTTP_AUTHORIZATION"].to_s
|
|
162
|
+
return false unless header.start_with?(BEARER_PREFIX)
|
|
163
|
+
|
|
164
|
+
ActiveSupport::SecurityUtils.secure_compare(header[BEARER_PREFIX.length..].to_s, token)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Bounded read: one byte over the ceiling and the body is refused unread.
|
|
168
|
+
def read_body(env)
|
|
169
|
+
input = env["rack.input"]
|
|
170
|
+
return "" if input.nil?
|
|
171
|
+
|
|
172
|
+
length = env["CONTENT_LENGTH"].to_s
|
|
173
|
+
return :too_large if !length.empty? && length.to_i > MAX_REQUEST_BYTES
|
|
174
|
+
|
|
175
|
+
body = input.read(MAX_REQUEST_BYTES + 1).to_s
|
|
176
|
+
return :too_large if body.bytesize > MAX_REQUEST_BYTES
|
|
177
|
+
|
|
178
|
+
body
|
|
179
|
+
ensure
|
|
180
|
+
input.rewind if input.respond_to?(:rewind)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def json(status, body)
|
|
184
|
+
[status, { "content-type" => JSON_CONTENT_TYPE, "content-length" => body.bytesize.to_s }, [body]]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Every non-2xx answer is the shared error envelope — `version`, a stable
|
|
188
|
+
# machine-readable `error` code, and a human `message` — so the relay can
|
|
189
|
+
# log one shape for an ingress refusal and a Rails-endpoint refusal alike.
|
|
190
|
+
def error(status, code, message)
|
|
191
|
+
json(status, JSON.generate(version: Protocol::V1::VERSION, error: code, message: message))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def log_error(message)
|
|
195
|
+
logger = defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
|
|
196
|
+
logger&.error(message)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Used only when the endpoint runs outside a booted application (the
|
|
200
|
+
# plain-ruby unit suite). A real deployment always has Rails' executor.
|
|
201
|
+
module NullExecutor
|
|
202
|
+
def self.wrap
|
|
203
|
+
yield
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|