tina4ruby 3.13.37 → 3.13.39
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/README.md +7 -7
- data/lib/tina4/api.rb +43 -1
- data/lib/tina4/auth.rb +118 -7
- data/lib/tina4/cli.rb +110 -2
- data/lib/tina4/database.rb +407 -52
- data/lib/tina4/dev_admin.rb +47 -14
- data/lib/tina4/drivers/sqlite_driver.rb +23 -0
- data/lib/tina4/env.rb +40 -4
- data/lib/tina4/events.rb +54 -8
- data/lib/tina4/field_types.rb +5 -2
- data/lib/tina4/graphql.rb +68 -12
- data/lib/tina4/html_element.rb +55 -7
- data/lib/tina4/log.rb +86 -10
- data/lib/tina4/mcp.rb +35 -8
- data/lib/tina4/messenger.rb +130 -25
- data/lib/tina4/metrics.rb +351 -73
- data/lib/tina4/middleware.rb +136 -13
- data/lib/tina4/migration.rb +113 -24
- data/lib/tina4/orm.rb +196 -32
- data/lib/tina4/query_builder.rb +22 -3
- data/lib/tina4/queue_backends/kafka_backend.rb +39 -2
- data/lib/tina4/rack_app.rb +22 -10
- data/lib/tina4/response.rb +31 -11
- data/lib/tina4/router.rb +34 -4
- data/lib/tina4/seeder.rb +433 -84
- data/lib/tina4/session.rb +94 -17
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +458 -21
- data/lib/tina4/wsdl.rb +25 -2
- data/lib/tina4.rb +91 -12
- metadata +6 -47
data/lib/tina4/websocket.rb
CHANGED
|
@@ -4,15 +4,117 @@ require "digest"
|
|
|
4
4
|
require "base64"
|
|
5
5
|
require "json"
|
|
6
6
|
require "set"
|
|
7
|
+
require "securerandom"
|
|
7
8
|
|
|
8
9
|
module Tina4
|
|
9
10
|
WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-5AB5DC11AD37"
|
|
10
11
|
|
|
12
|
+
# Shared pub/sub channel name + envelope shape for the WebSocket backplane.
|
|
13
|
+
# MUST stay byte-identical across all four frameworks (Python/PHP/Ruby/Node)
|
|
14
|
+
# so a broadcast published by one framework's instance is relayed by another.
|
|
15
|
+
WEBSOCKET_BACKPLANE_CHANNEL = "tina4:ws"
|
|
16
|
+
|
|
11
17
|
# Compute Sec-WebSocket-Accept from Sec-WebSocket-Key per RFC 6455.
|
|
12
18
|
def self.compute_accept_key(key)
|
|
13
19
|
Base64.strict_encode64(Digest::SHA1.digest("#{key}#{WEBSOCKET_GUID}"))
|
|
14
20
|
end
|
|
15
21
|
|
|
22
|
+
# Return true if the request's Origin is permitted to upgrade to a WebSocket.
|
|
23
|
+
#
|
|
24
|
+
# Controlled by TINA4_WS_ALLOWED_ORIGINS (comma-separated exact origins, e.g.
|
|
25
|
+
# "https://app.example.com,https://admin.example.com").
|
|
26
|
+
#
|
|
27
|
+
# Empty/unset = allow ALL origins (current behaviour, non-breaking). When set,
|
|
28
|
+
# only requests whose Origin exactly matches a listed value are allowed; a
|
|
29
|
+
# missing Origin header is rejected once the allow-list is active.
|
|
30
|
+
#
|
|
31
|
+
# +headers+ is a Hash. The Origin is looked up case-insensitively across both
|
|
32
|
+
# the Rack-style "HTTP_ORIGIN" key and a plain "origin"/"Origin" key so the
|
|
33
|
+
# same helper serves the rack_app upgrade path and direct callers/tests.
|
|
34
|
+
def self.websocket_origin_allowed?(headers)
|
|
35
|
+
raw = (ENV["TINA4_WS_ALLOWED_ORIGINS"] || "").strip
|
|
36
|
+
return true if raw.empty? # No allow-list configured — permit everything.
|
|
37
|
+
|
|
38
|
+
allowed = raw.split(",").map(&:strip).reject(&:empty?)
|
|
39
|
+
return true if allowed.empty?
|
|
40
|
+
|
|
41
|
+
origin = headers["HTTP_ORIGIN"] || headers["origin"] || headers["Origin"]
|
|
42
|
+
allowed.include?(origin)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Extract a bearer token from a WebSocket upgrade handshake.
|
|
46
|
+
#
|
|
47
|
+
# Order (mirrors Python's tina4_python.websocket.ws_token):
|
|
48
|
+
# 1. the Authorization: Bearer <jwt> header (server/CLI/mobile clients)
|
|
49
|
+
# 2. the Sec-WebSocket-Protocol subprotocol in the form "bearer, <jwt>"
|
|
50
|
+
# (the only way a *browser* can pass a token — new WebSocket() cannot set
|
|
51
|
+
# headers, but it CAN offer subprotocols)
|
|
52
|
+
# 3. a ?token=<jwt> query-string param
|
|
53
|
+
# Returns the token String, or nil.
|
|
54
|
+
#
|
|
55
|
+
# +headers+ is a Hash. Lookups are case-insensitive across both the Rack-style
|
|
56
|
+
# "HTTP_AUTHORIZATION"/"HTTP_SEC_WEBSOCKET_PROTOCOL" keys and plain
|
|
57
|
+
# "authorization"/"Authorization"/"sec-websocket-protocol" keys so the same
|
|
58
|
+
# helper serves the rack_app upgrade path and direct callers/tests.
|
|
59
|
+
def self.ws_token(headers, query_string = "", subprotocol = "")
|
|
60
|
+
headers ||= {}
|
|
61
|
+
auth = headers["HTTP_AUTHORIZATION"] || headers["authorization"] || headers["Authorization"] || ""
|
|
62
|
+
if auth[0, 7].to_s.downcase == "bearer "
|
|
63
|
+
tok = auth[7..].to_s.strip
|
|
64
|
+
return tok.empty? ? nil : tok
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
proto = subprotocol.to_s
|
|
68
|
+
proto = headers["HTTP_SEC_WEBSOCKET_PROTOCOL"] || headers["sec-websocket-protocol"] ||
|
|
69
|
+
headers["Sec-WebSocket-Protocol"] || "" if proto.empty?
|
|
70
|
+
parts = proto.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
71
|
+
if parts.length >= 2 && parts[0].downcase == "bearer"
|
|
72
|
+
return parts[1].empty? ? nil : parts[1]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
qs = query_string.to_s
|
|
76
|
+
qs = headers["QUERY_STRING"].to_s if qs.empty?
|
|
77
|
+
unless qs.empty?
|
|
78
|
+
tok = qs.split("&").each_with_object(nil) do |pair, _acc|
|
|
79
|
+
k, v = pair.split("=", 2)
|
|
80
|
+
break v if k == "token"
|
|
81
|
+
end
|
|
82
|
+
return tok unless tok.nil? || tok.to_s.empty?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Per-route WebSocket authentication, checked on the upgrade.
|
|
89
|
+
#
|
|
90
|
+
# A route is secured when it requires auth (the WebSocketRoute's #auth_required
|
|
91
|
+
# is truthy — set by .secure on the route or by Tina4.secure_websocket). Public
|
|
92
|
+
# routes (the default) always pass. A secured route needs a valid JWT via the
|
|
93
|
+
# Authorization header, the "bearer" subprotocol, or ?token=.
|
|
94
|
+
#
|
|
95
|
+
# Returns [payload, ok] — the verified token payload (or nil) and whether the
|
|
96
|
+
# upgrade may proceed. Mirrors Python's ws_authorized.
|
|
97
|
+
def self.ws_authorized(auth_required, headers, query_string = "", subprotocol = "")
|
|
98
|
+
return [nil, true] unless auth_required
|
|
99
|
+
|
|
100
|
+
token = ws_token(headers, query_string, subprotocol)
|
|
101
|
+
return [nil, false] unless token
|
|
102
|
+
|
|
103
|
+
payload = Tina4::Auth.valid_token(token)
|
|
104
|
+
[payload, !payload.nil?]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Whether the client offered the "bearer" subprotocol — in which case the
|
|
108
|
+
# handshake response must echo "bearer" as the accepted subprotocol (browsers
|
|
109
|
+
# reject a 101 that doesn't echo back a subprotocol they offered).
|
|
110
|
+
def self.ws_bearer_subprotocol_offered?(headers)
|
|
111
|
+
headers ||= {}
|
|
112
|
+
proto = headers["HTTP_SEC_WEBSOCKET_PROTOCOL"] || headers["sec-websocket-protocol"] ||
|
|
113
|
+
headers["Sec-WebSocket-Protocol"] || ""
|
|
114
|
+
parts = proto.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
115
|
+
!parts.empty? && parts[0].downcase == "bearer"
|
|
116
|
+
end
|
|
117
|
+
|
|
16
118
|
# Build a WebSocket frame (server→client, never masked).
|
|
17
119
|
def self.build_frame(opcode, data, fin: true)
|
|
18
120
|
first_byte = (fin ? 0x80 : 0x00) | opcode
|
|
@@ -44,8 +146,24 @@ module Tina4
|
|
|
44
146
|
error: []
|
|
45
147
|
}
|
|
46
148
|
@rooms = {} # room_name => Set of conn_ids
|
|
149
|
+
|
|
150
|
+
# ── Backplane (multi-instance scaling) ──────────────────────
|
|
151
|
+
# Lazily wired on first broadcast (see ensure_backplane). Each instance
|
|
152
|
+
# owns a stable id so it can ignore its own echoes coming back over the
|
|
153
|
+
# shared pub/sub channel (the origin guard). The backplane listener runs
|
|
154
|
+
# in its own Ruby thread, so the connections structure is guarded by a
|
|
155
|
+
# mutex shared with the broadcast path.
|
|
156
|
+
@backplane = nil
|
|
157
|
+
@backplane_started = false
|
|
158
|
+
@instance_id = SecureRandom.hex(8)
|
|
159
|
+
@backplane_channel = Tina4::WEBSOCKET_BACKPLANE_CHANNEL
|
|
160
|
+
@conn_mutex = Mutex.new
|
|
47
161
|
end
|
|
48
162
|
|
|
163
|
+
# Stable per-process id used as the backplane envelope "src" so an instance
|
|
164
|
+
# drops its own echoes. Exposed for tests / introspection.
|
|
165
|
+
attr_reader :instance_id, :backplane_channel
|
|
166
|
+
|
|
49
167
|
def on(event, &block)
|
|
50
168
|
@handlers[event.to_sym] << block if @handlers.key?(event.to_sym)
|
|
51
169
|
end
|
|
@@ -86,16 +204,31 @@ module Tina4
|
|
|
86
204
|
end
|
|
87
205
|
|
|
88
206
|
def broadcast(message, exclude: nil, path: nil)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
207
|
+
ensure_backplane
|
|
208
|
+
targets = @conn_mutex.synchronize do
|
|
209
|
+
@connections.select do |id, conn|
|
|
210
|
+
!(exclude && id == exclude) && !(path && conn.path != path)
|
|
211
|
+
end.values
|
|
212
|
+
end
|
|
213
|
+
deliver_resilient(targets, message)
|
|
214
|
+
publish_envelope(path ? "path" : "all", message, path: path, exclude: exclude)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Send to ALL connections (no path filter). Resilient + backplane-fanned.
|
|
218
|
+
def broadcast_all(message, exclude: nil)
|
|
219
|
+
ensure_backplane
|
|
220
|
+
targets = @conn_mutex.synchronize do
|
|
221
|
+
@connections.reject { |id, _| exclude && id == exclude }.values
|
|
93
222
|
end
|
|
223
|
+
deliver_resilient(targets, message)
|
|
224
|
+
publish_envelope("all", message, exclude: exclude)
|
|
94
225
|
end
|
|
95
226
|
|
|
96
227
|
def send_to(conn_id, message)
|
|
97
|
-
conn = @connections[conn_id]
|
|
98
|
-
conn
|
|
228
|
+
conn = @conn_mutex.synchronize { @connections[conn_id] }
|
|
229
|
+
return unless conn
|
|
230
|
+
|
|
231
|
+
prune(conn) unless safe_send(conn, message)
|
|
99
232
|
end
|
|
100
233
|
|
|
101
234
|
def close(conn_id, code: 1000, reason: "")
|
|
@@ -136,12 +269,250 @@ module Tina4
|
|
|
136
269
|
end
|
|
137
270
|
|
|
138
271
|
def broadcast_to_room(room_name, message, exclude: nil)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
272
|
+
ensure_backplane
|
|
273
|
+
targets = get_room_connections(room_name).reject { |conn| exclude && conn.id == exclude }
|
|
274
|
+
deliver_resilient(targets, message)
|
|
275
|
+
publish_envelope("room", message, room: room_name, exclude: exclude)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Register an open connection on this manager (thread-safe).
|
|
279
|
+
def register_connection(connection)
|
|
280
|
+
@conn_mutex.synchronize { @connections[connection.id] = connection }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Drop a connection on close (thread-safe) and remove it from all rooms.
|
|
284
|
+
def unregister_connection(conn_id)
|
|
285
|
+
@conn_mutex.synchronize { @connections.delete(conn_id) }
|
|
286
|
+
remove_from_all_rooms(conn_id)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# ── Broadcast resilience ──────────────────────────────────
|
|
290
|
+
#
|
|
291
|
+
# One dead/slow client must never abort delivery to the rest. deliver_resilient
|
|
292
|
+
# walks the target list, wraps each send, logs+prunes anything that throws,
|
|
293
|
+
# and keeps going.
|
|
294
|
+
|
|
295
|
+
# Send to ONE connection without letting a single dead client abort a
|
|
296
|
+
# broadcast loop. Returns true if delivered, false if the connection looks
|
|
297
|
+
# dead (the caller then prunes it). A failed send is logged, never silent.
|
|
298
|
+
# A connection whose own #send swallowed a write error and flipped #closed?
|
|
299
|
+
# is treated as dead too (mirrors Python's `return not ws._closed`).
|
|
300
|
+
def safe_send(conn, message)
|
|
301
|
+
conn.send_text(message)
|
|
302
|
+
# If the connection's own #send swallowed a write error it flips #closed?;
|
|
303
|
+
# treat that as dead. Probe defensively so a stub/double that doesn't
|
|
304
|
+
# define #closed? is simply treated as alive.
|
|
305
|
+
dead = begin
|
|
306
|
+
conn.respond_to?(:closed?) && conn.closed?
|
|
307
|
+
rescue StandardError
|
|
308
|
+
false
|
|
309
|
+
end
|
|
310
|
+
!dead
|
|
311
|
+
rescue StandardError => e
|
|
312
|
+
Tina4::Log.warning("WebSocket send to #{conn.id} failed, pruning: #{e.message}") if defined?(Tina4::Log)
|
|
313
|
+
false
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Deliver +message+ to every connection in +targets+, pruning any that fail.
|
|
317
|
+
def deliver_resilient(targets, message)
|
|
318
|
+
dead = targets.reject { |conn| safe_send(conn, message) }
|
|
319
|
+
dead.each { |conn| prune(conn) }
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Remove a (presumed dead) connection from the manager + all rooms.
|
|
323
|
+
def prune(conn)
|
|
324
|
+
id = conn.respond_to?(:id) ? conn.id : nil
|
|
325
|
+
return unless id
|
|
326
|
+
|
|
327
|
+
@conn_mutex.synchronize { @connections.delete(id) }
|
|
328
|
+
remove_from_all_rooms(id)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# ── Idle reaper ───────────────────────────────────────────
|
|
332
|
+
#
|
|
333
|
+
# Opt-in via TINA4_WS_IDLE_TIMEOUT (seconds; 0/unset = disabled = current
|
|
334
|
+
# behaviour). Tracks last_activity per connection (bumped on every inbound
|
|
335
|
+
# frame in handle_upgrade); reap_idle closes/prunes anything idle past the
|
|
336
|
+
# timeout.
|
|
337
|
+
|
|
338
|
+
# Close connections whose last inbound frame is older than +timeout+ seconds.
|
|
339
|
+
# Returns the number reaped. timeout <= 0 is a no-op. Connections without a
|
|
340
|
+
# last_activity are skipped.
|
|
341
|
+
def reap_idle(timeout)
|
|
342
|
+
return 0 if timeout.to_f <= 0
|
|
343
|
+
|
|
344
|
+
now = Time.now.to_f
|
|
345
|
+
stale = @conn_mutex.synchronize do
|
|
346
|
+
@connections.values.select do |conn|
|
|
347
|
+
la = conn.respond_to?(:last_activity) ? conn.last_activity : nil
|
|
348
|
+
la && (now - la) > timeout
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
stale.each do |conn|
|
|
352
|
+
conn.close(code: 1001, reason: "idle timeout") rescue nil
|
|
353
|
+
prune(conn)
|
|
354
|
+
end
|
|
355
|
+
Tina4::Log.info("WebSocket idle reaper closed #{stale.size} connection(s)") if stale.any? && defined?(Tina4::Log)
|
|
356
|
+
stale.size
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Read the configured idle timeout (seconds). 0 = disabled.
|
|
360
|
+
def idle_timeout
|
|
361
|
+
Float(ENV["TINA4_WS_IDLE_TIMEOUT"] || "0")
|
|
362
|
+
rescue ArgumentError, TypeError
|
|
363
|
+
0.0
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Spin up a background reaper thread when an idle timeout is configured.
|
|
367
|
+
# Opt-in and non-breaking — unset/0 means no thread is created.
|
|
368
|
+
def start_idle_reaper
|
|
369
|
+
timeout = idle_timeout
|
|
370
|
+
return if timeout <= 0 || @reaper_thread&.alive?
|
|
371
|
+
|
|
372
|
+
interval = [1.0, timeout / 2.0].max
|
|
373
|
+
@reaper_running = true
|
|
374
|
+
@reaper_thread = Thread.new do
|
|
375
|
+
while @reaper_running
|
|
376
|
+
sleep interval
|
|
377
|
+
begin
|
|
378
|
+
reap_idle(timeout)
|
|
379
|
+
rescue StandardError => e
|
|
380
|
+
Tina4::Log.error("WebSocket idle reaper sweep failed: #{e.message}") if defined?(Tina4::Log)
|
|
381
|
+
end
|
|
382
|
+
end
|
|
142
383
|
end
|
|
143
384
|
end
|
|
144
385
|
|
|
386
|
+
def stop_idle_reaper
|
|
387
|
+
@reaper_running = false
|
|
388
|
+
@reaper_thread&.kill
|
|
389
|
+
@reaper_thread = nil
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# ── Backplane (multi-instance scaling) ────────────────────
|
|
393
|
+
#
|
|
394
|
+
# When TINA4_WS_BACKPLANE is configured, every broadcast is ALSO published
|
|
395
|
+
# to a shared pub/sub channel so sibling instances relay it to their own
|
|
396
|
+
# local connections. Flow:
|
|
397
|
+
#
|
|
398
|
+
# instance A: broadcast → deliver locally → publish_envelope → channel
|
|
399
|
+
# │
|
|
400
|
+
# instance B: backplane bg-THREAD → on_backplane_message ─────────┘
|
|
401
|
+
# │ (origin guard drops A's echoes by src id)
|
|
402
|
+
# └→ relay_local → B's LOCAL connections only (no re-publish)
|
|
403
|
+
#
|
|
404
|
+
# The subscribe callback runs in the backplane's background Ruby thread, so
|
|
405
|
+
# it touches @connections under @conn_mutex (shared with the broadcast path).
|
|
406
|
+
# The relay NEVER re-publishes (that would loop the cluster).
|
|
407
|
+
|
|
408
|
+
# Lazily wire the configured backplane. Idempotent and best-effort — a
|
|
409
|
+
# failure logs and leaves the manager local-only; it must NEVER crash a
|
|
410
|
+
# broadcast.
|
|
411
|
+
def ensure_backplane
|
|
412
|
+
return if @backplane_started
|
|
413
|
+
|
|
414
|
+
# Set immediately so we only ever attempt the wiring once, even on failure
|
|
415
|
+
# (no retry storm on every broadcast).
|
|
416
|
+
@backplane_started = true
|
|
417
|
+
begin
|
|
418
|
+
backplane = Tina4::WebSocketBackplane.create_backplane
|
|
419
|
+
return if backplane.nil? # No backplane configured — stay local-only.
|
|
420
|
+
|
|
421
|
+
@backplane = backplane
|
|
422
|
+
@backplane.subscribe(@backplane_channel) { |raw| on_backplane_message(raw) }
|
|
423
|
+
Tina4::Log.info("WebSocket backplane active (instance #{@instance_id}, channel '#{@backplane_channel}')") if defined?(Tina4::Log)
|
|
424
|
+
rescue StandardError => e
|
|
425
|
+
@backplane = nil
|
|
426
|
+
Tina4::Log.error("WebSocket backplane wiring failed, continuing local-only: #{e.message}") if defined?(Tina4::Log)
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Receive a raw envelope from the backplane. Runs in the backplane's
|
|
431
|
+
# BACKGROUND THREAD.
|
|
432
|
+
def on_backplane_message(raw)
|
|
433
|
+
env = begin
|
|
434
|
+
JSON.parse(raw)
|
|
435
|
+
rescue JSON::ParserError, TypeError
|
|
436
|
+
return
|
|
437
|
+
end
|
|
438
|
+
return unless env.is_a?(Hash)
|
|
439
|
+
|
|
440
|
+
# Origin guard: ignore our own broadcasts echoed back over the channel.
|
|
441
|
+
# We already delivered them locally; relaying again would double-send.
|
|
442
|
+
return if env["src"] == @instance_id
|
|
443
|
+
|
|
444
|
+
relay_local(env)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Deliver a remote-originated envelope to LOCAL connections only. NEVER
|
|
448
|
+
# re-publishes (that would loop the message around the cluster). Dispatches
|
|
449
|
+
# by "kind": room / path / all.
|
|
450
|
+
def relay_local(env)
|
|
451
|
+
message = decode_envelope_message(env)
|
|
452
|
+
return if message.nil?
|
|
453
|
+
|
|
454
|
+
exclude = env["exclude"]
|
|
455
|
+
targets =
|
|
456
|
+
case env["kind"]
|
|
457
|
+
when "room"
|
|
458
|
+
room = env["room"]
|
|
459
|
+
room ? get_room_connections(room) : []
|
|
460
|
+
when "path"
|
|
461
|
+
path = env["path"]
|
|
462
|
+
path ? @conn_mutex.synchronize { @connections.values.select { |c| c.path == path } } : []
|
|
463
|
+
else # "all" (and anything unknown) → every local connection
|
|
464
|
+
@conn_mutex.synchronize { @connections.values }
|
|
465
|
+
end
|
|
466
|
+
targets = targets.reject { |conn| exclude && conn.id == exclude }
|
|
467
|
+
deliver_resilient(targets, message)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Publish a broadcast to the shared channel for sibling instances. No-op
|
|
471
|
+
# when no backplane is configured. Best-effort — a publish failure logs and
|
|
472
|
+
# is swallowed so the local broadcast that already happened is never undone
|
|
473
|
+
# by a flaky message bus.
|
|
474
|
+
def publish_envelope(kind, message, room: nil, path: nil, exclude: nil)
|
|
475
|
+
return unless @backplane
|
|
476
|
+
|
|
477
|
+
envelope = {
|
|
478
|
+
"src" => @instance_id,
|
|
479
|
+
"kind" => kind,
|
|
480
|
+
"exclude" => exclude,
|
|
481
|
+
"room" => room,
|
|
482
|
+
"path" => path
|
|
483
|
+
}
|
|
484
|
+
# JSON can't carry raw bytes — encode binary as base64, text as text.
|
|
485
|
+
if binary_payload?(message)
|
|
486
|
+
envelope["b64"] = Base64.strict_encode64(message.b)
|
|
487
|
+
else
|
|
488
|
+
envelope["text"] = message
|
|
489
|
+
end
|
|
490
|
+
begin
|
|
491
|
+
@backplane.publish(@backplane_channel, JSON.generate(envelope))
|
|
492
|
+
rescue StandardError => e
|
|
493
|
+
Tina4::Log.warning("WebSocket backplane publish failed: #{e.message}") if defined?(Tina4::Log)
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Reconstruct the original str/bytes message from an envelope. JSON can't
|
|
498
|
+
# carry bytes, so text → {"text": ...} and bytes → {"b64": base64(...)}.
|
|
499
|
+
def decode_envelope_message(env)
|
|
500
|
+
return env["text"] if env.key?("text")
|
|
501
|
+
return Base64.strict_decode64(env["b64"]).b if env.key?("b64")
|
|
502
|
+
|
|
503
|
+
nil
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# A message is "binary" when it is an ASCII-8BIT (BINARY-encoded) string.
|
|
507
|
+
# Ruby has no separate bytes type, so a caller's choice of the BINARY
|
|
508
|
+
# encoding is the signal to treat the payload as bytes: it goes through
|
|
509
|
+
# base64 so it survives the JSON envelope AND arrives with its binary
|
|
510
|
+
# encoding intact on the relaying instance (a JSON "text" value would come
|
|
511
|
+
# back as UTF-8, silently re-encoding binary data).
|
|
512
|
+
def binary_payload?(message)
|
|
513
|
+
message.is_a?(String) && message.encoding == Encoding::ASCII_8BIT
|
|
514
|
+
end
|
|
515
|
+
|
|
145
516
|
# Register a WebSocket handler for a path (class method, matching Python's
|
|
146
517
|
# WebSocketServer.route). The block receives a WebSocketConnection and should
|
|
147
518
|
# call conn.on_message / conn.on_close to wire up event callbacks.
|
|
@@ -153,7 +524,9 @@ module Tina4
|
|
|
153
524
|
# conn.on_close { puts "bye" }
|
|
154
525
|
# end
|
|
155
526
|
#
|
|
156
|
-
|
|
527
|
+
# PUBLIC by default (mirrors GET). Pass secure: true to require a valid JWT
|
|
528
|
+
# on the upgrade (or chain .secure on the returned route).
|
|
529
|
+
def self.route(path, secure: false, &block)
|
|
157
530
|
@route_handlers ||= {}
|
|
158
531
|
@route_handlers[path] = block
|
|
159
532
|
|
|
@@ -169,26 +542,68 @@ module Tina4
|
|
|
169
542
|
end
|
|
170
543
|
end
|
|
171
544
|
|
|
172
|
-
Tina4::Router.websocket(path, &adapter)
|
|
545
|
+
Tina4::Router.websocket(path, secure: secure, &adapter)
|
|
173
546
|
end
|
|
174
547
|
|
|
175
|
-
|
|
548
|
+
# Upgrade a raw socket to a WebSocket connection and run its frame loop.
|
|
549
|
+
#
|
|
550
|
+
# +manager+ is the engine that should OWN the connection (its @connections /
|
|
551
|
+
# rooms / backplane / idle reaper). It defaults to +self+. In integrated
|
|
552
|
+
# (Rack) mode the rack_app passes a process-wide shared engine here so that
|
|
553
|
+
# broadcasts, rooms and the backplane span every route's connections even
|
|
554
|
+
# though each upgrade keeps its own isolated event handlers on +self+.
|
|
555
|
+
def handle_upgrade(env, socket, manager: self, auth_required: false)
|
|
176
556
|
key = env["HTTP_SEC_WEBSOCKET_KEY"]
|
|
177
557
|
return unless key
|
|
178
558
|
|
|
559
|
+
# Origin allow-list (opt-in via TINA4_WS_ALLOWED_ORIGINS). Unset = allow
|
|
560
|
+
# all, so this never breaks an existing deployment. When set, an upgrade
|
|
561
|
+
# from a non-listed Origin is refused with a 403 before the handshake.
|
|
562
|
+
unless Tina4.websocket_origin_allowed?(env)
|
|
563
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n") rescue nil
|
|
564
|
+
socket.close rescue nil
|
|
565
|
+
return
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# Per-route auth — checked AFTER the origin allow-list and BEFORE we accept
|
|
569
|
+
# the handshake. A PUBLIC route (the default, mirrors GET) always passes; a
|
|
570
|
+
# secured route (auth_required) needs a valid JWT via the Authorization
|
|
571
|
+
# header, the "bearer" subprotocol, or ?token=. Missing/invalid → reject
|
|
572
|
+
# the upgrade with a 401 (close code 1008 equivalent) and never accept.
|
|
573
|
+
payload, ok = Tina4.ws_authorized(auth_required, env, env["QUERY_STRING"].to_s)
|
|
574
|
+
unless ok
|
|
575
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n") rescue nil
|
|
576
|
+
socket.close rescue nil
|
|
577
|
+
return
|
|
578
|
+
end
|
|
579
|
+
|
|
179
580
|
accept = Tina4.compute_accept_key(key)
|
|
180
581
|
|
|
582
|
+
# When the client offered the "bearer" subprotocol (the browser transport,
|
|
583
|
+
# since new WebSocket() can't set headers), echo "bearer" back as the
|
|
584
|
+
# accepted subprotocol — browsers reject a 101 that doesn't echo a
|
|
585
|
+
# subprotocol they offered. Mirrors Python's accept-subprotocol behaviour.
|
|
586
|
+
subproto_header = Tina4.ws_bearer_subprotocol_offered?(env) ? "Sec-WebSocket-Protocol: bearer\r\n" : ""
|
|
587
|
+
|
|
181
588
|
response = "HTTP/1.1 101 Switching Protocols\r\n" \
|
|
182
589
|
"Upgrade: websocket\r\n" \
|
|
183
590
|
"Connection: Upgrade\r\n" \
|
|
591
|
+
"#{subproto_header}" \
|
|
184
592
|
"Sec-WebSocket-Accept: #{accept}\r\n\r\n"
|
|
185
593
|
|
|
186
594
|
socket.write(response)
|
|
187
595
|
|
|
188
596
|
conn_id = SecureRandom.hex(16)
|
|
189
597
|
ws_path = env["REQUEST_PATH"] || env["PATH_INFO"] || "/"
|
|
190
|
-
connection = WebSocketConnection.new(conn_id, socket, ws_server:
|
|
191
|
-
|
|
598
|
+
connection = WebSocketConnection.new(conn_id, socket, ws_server: manager, path: ws_path)
|
|
599
|
+
# Expose the verified token payload on the connection (nil on public
|
|
600
|
+
# routes). Mirrors Python's connection.auth = payload.
|
|
601
|
+
connection.auth = payload
|
|
602
|
+
manager.register_connection(connection)
|
|
603
|
+
|
|
604
|
+
# Start the idle reaper lazily once we actually have a connection (opt-in
|
|
605
|
+
# via TINA4_WS_IDLE_TIMEOUT; a no-op when unset/0).
|
|
606
|
+
manager.start_idle_reaper
|
|
192
607
|
|
|
193
608
|
emit(:open, connection)
|
|
194
609
|
|
|
@@ -198,6 +613,8 @@ module Tina4
|
|
|
198
613
|
frame = connection.read_frame
|
|
199
614
|
break unless frame
|
|
200
615
|
|
|
616
|
+
connection.touch # mark activity for the idle reaper
|
|
617
|
+
|
|
201
618
|
case frame[:opcode]
|
|
202
619
|
when 0x1 # Text
|
|
203
620
|
emit(:message, connection, frame[:data])
|
|
@@ -210,8 +627,7 @@ module Tina4
|
|
|
210
627
|
rescue => e
|
|
211
628
|
emit(:error, connection, e)
|
|
212
629
|
ensure
|
|
213
|
-
|
|
214
|
-
remove_from_all_rooms(conn_id)
|
|
630
|
+
manager.unregister_connection(conn_id)
|
|
215
631
|
emit(:close, connection)
|
|
216
632
|
socket.close rescue nil
|
|
217
633
|
end
|
|
@@ -228,8 +644,9 @@ module Tina4
|
|
|
228
644
|
end
|
|
229
645
|
|
|
230
646
|
class WebSocketConnection
|
|
231
|
-
attr_reader :id, :rooms
|
|
232
|
-
attr_accessor :params, :path, :on_message_handler, :on_close_handler, :on_error_handler
|
|
647
|
+
attr_reader :id, :rooms, :last_activity
|
|
648
|
+
attr_accessor :params, :path, :on_message_handler, :on_close_handler, :on_error_handler,
|
|
649
|
+
:auth
|
|
233
650
|
|
|
234
651
|
def initialize(id, socket, ws_server: nil, path: "/")
|
|
235
652
|
@id = id
|
|
@@ -237,10 +654,21 @@ module Tina4
|
|
|
237
654
|
@params = {}
|
|
238
655
|
@ws_server = ws_server
|
|
239
656
|
@path = path
|
|
657
|
+
# Verified JWT payload on a secured WS route, else nil (public route).
|
|
658
|
+
# Mirrors Python's connection.auth.
|
|
659
|
+
@auth = nil
|
|
240
660
|
@rooms = Set.new
|
|
241
661
|
@on_message_handler = nil
|
|
242
662
|
@on_close_handler = nil
|
|
243
663
|
@on_error_handler = nil
|
|
664
|
+
# Updated on every inbound frame; the idle reaper closes connections that
|
|
665
|
+
# have been silent longer than TINA4_WS_IDLE_TIMEOUT (opt-in).
|
|
666
|
+
@last_activity = Time.now.to_f
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# Mark inbound activity for the idle reaper.
|
|
670
|
+
def touch
|
|
671
|
+
@last_activity = Time.now.to_f
|
|
244
672
|
end
|
|
245
673
|
|
|
246
674
|
# Register a message handler (decorator style, matching Python).
|
|
@@ -281,12 +709,21 @@ module Tina4
|
|
|
281
709
|
end
|
|
282
710
|
end
|
|
283
711
|
|
|
712
|
+
# True once a write has failed (broken pipe / closed socket). The manager's
|
|
713
|
+
# resilient broadcast path uses this to prune dead connections.
|
|
714
|
+
def closed?
|
|
715
|
+
@closed == true
|
|
716
|
+
end
|
|
717
|
+
|
|
284
718
|
def send(message)
|
|
285
|
-
data = message.
|
|
719
|
+
data = message.is_a?(String) ? message : message.to_s
|
|
720
|
+
# Text frames must be valid UTF-8; binary payloads are sent verbatim.
|
|
721
|
+
data = data.encode("UTF-8") if data.encoding != Encoding::ASCII_8BIT
|
|
286
722
|
frame = build_frame(0x1, data)
|
|
287
723
|
@socket.write(frame)
|
|
288
724
|
rescue IOError
|
|
289
|
-
# Connection closed
|
|
725
|
+
# Connection closed — mark dead so the broadcast path prunes it.
|
|
726
|
+
@closed = true
|
|
290
727
|
end
|
|
291
728
|
|
|
292
729
|
alias_method :send_text, :send
|
|
@@ -301,7 +738,7 @@ module Tina4
|
|
|
301
738
|
frame = build_frame(0xA, data || "")
|
|
302
739
|
@socket.write(frame)
|
|
303
740
|
rescue IOError
|
|
304
|
-
|
|
741
|
+
@closed = true
|
|
305
742
|
end
|
|
306
743
|
|
|
307
744
|
def close(code: 1000, reason: "")
|
data/lib/tina4/wsdl.rb
CHANGED
|
@@ -273,6 +273,14 @@ module Tina4
|
|
|
273
273
|
def process_soap(xml_body)
|
|
274
274
|
on_request(@request)
|
|
275
275
|
|
|
276
|
+
# SOAP 1.1 (§3) forbids a Document Type Declaration in a SOAP message.
|
|
277
|
+
# Rejecting any DOCTYPE/DTD up front also closes the XML entity-expansion
|
|
278
|
+
# (billion-laughs) and external-entity (XXE) attack surface — REXML expands
|
|
279
|
+
# internal entities, so a DTD is a live DoS vector. Reject before parsing.
|
|
280
|
+
if xml_body =~ /<!DOCTYPE/i
|
|
281
|
+
return soap_fault("Client", "DOCTYPE declarations are not allowed in SOAP messages")
|
|
282
|
+
end
|
|
283
|
+
|
|
276
284
|
begin
|
|
277
285
|
doc = REXML::Document.new(xml_body)
|
|
278
286
|
rescue REXML::ParseException
|
|
@@ -309,7 +317,12 @@ module Tina4
|
|
|
309
317
|
result = send(op_name.to_sym, *meta[:input].keys.map { |k| params[k.to_s] })
|
|
310
318
|
result = on_result(result)
|
|
311
319
|
rescue StandardError => e
|
|
312
|
-
|
|
320
|
+
# Log the real cause, but only leak the detail to the client in debug
|
|
321
|
+
# mode — a resolver exception can carry internal state (DB credentials,
|
|
322
|
+
# file paths) that must not reach a SOAP client.
|
|
323
|
+
Tina4::Log.error("WSDL operation '#{op_name}' failed: #{e.message}")
|
|
324
|
+
detail = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"]) ? e.message : "Internal server error"
|
|
325
|
+
return soap_fault("Server", detail)
|
|
313
326
|
end
|
|
314
327
|
|
|
315
328
|
soap_response(op_name, result)
|
|
@@ -473,6 +486,11 @@ module Tina4
|
|
|
473
486
|
end
|
|
474
487
|
|
|
475
488
|
def handle_soap_request(xml_body)
|
|
489
|
+
# SOAP 1.1 (§3) forbids a DOCTYPE/DTD. Reject before parsing — this
|
|
490
|
+
# closes the REXML internal-entity expansion (billion-laughs) and XXE
|
|
491
|
+
# surface. Mirrors the class-based process_soap path.
|
|
492
|
+
return _soap_fault("DOCTYPE declarations are not allowed in SOAP messages") if xml_body =~ /<!DOCTYPE/i
|
|
493
|
+
|
|
476
494
|
doc = REXML::Document.new(xml_body)
|
|
477
495
|
|
|
478
496
|
# Find Body element (namespace-agnostic)
|
|
@@ -500,7 +518,12 @@ module Tina4
|
|
|
500
518
|
# Build SOAP response
|
|
501
519
|
_build_soap_response(op_name, result)
|
|
502
520
|
rescue StandardError => e
|
|
503
|
-
|
|
521
|
+
# Mask the real cause in production (parity with process_soap) — a
|
|
522
|
+
# handler exception can carry internal state that must not reach a
|
|
523
|
+
# SOAP client. Detail is logged; only surfaced under TINA4_DEBUG.
|
|
524
|
+
Tina4::Log.error("WSDL operation '#{op_name}' failed: #{e.message}")
|
|
525
|
+
detail = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"]) ? e.message : "Internal server error"
|
|
526
|
+
_soap_fault(detail)
|
|
504
527
|
end
|
|
505
528
|
|
|
506
529
|
private
|