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.
@@ -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
- @connections.each do |id, conn|
90
- next if exclude && id == exclude
91
- next if path && conn.path != path
92
- conn.send_text(message)
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&.send_text(message)
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
- (get_room_connections(room_name)).each do |conn|
140
- next if exclude && conn.id == exclude
141
- conn.send_text(message)
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
- def self.route(path, &block)
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
- def handle_upgrade(env, socket)
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: self, path: ws_path)
191
- @connections[conn_id] = connection
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
- @connections.delete(conn_id)
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.encode("UTF-8")
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
- # Connection closed
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
- return soap_fault("Server", e.message)
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
- _soap_fault(e.message)
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