tina4ruby 3.13.81 → 3.13.82

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.
data/lib/tina4/mqtt.rb ADDED
@@ -0,0 +1,800 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "securerandom"
5
+ require_relative "mqtt_message"
6
+
7
+ module Tina4
8
+ # Any MQTT protocol / connection failure.
9
+ class MqttError < StandardError; end
10
+
11
+ # The broker did not answer inside the timeout.
12
+ class MqttTimeoutError < MqttError; end
13
+
14
+ # Zero-dependency MQTT 3.1.1 client — the protocol every broker and every
15
+ # IoT device already speaks. Built on `socket` and Array#pack/String#unpack
16
+ # only: no gem, so an app that talks to Mosquitto / EMQX / HiveMQ / AWS IoT
17
+ # adds nothing to its dependency tree.
18
+ #
19
+ # Shaped like Tina4::Queue on purpose — publish / subscribe / consume:
20
+ #
21
+ # mqtt = Tina4::Mqtt.new # TINA4_MQTT_URL
22
+ # mqtt.publish("fleet/meter-42/telemetry", '{"kwh":12.5}', qos: 1)
23
+ #
24
+ # mqtt.consume("fleet/+/telemetry", qos: 1) do |message|
25
+ # next if message.duplicate? # QoS 1 is at-least-once
26
+ # store(message.topic, message.payload)
27
+ # end
28
+ #
29
+ # Environment: TINA4_MQTT_URL (default mqtt://127.0.0.1:1883),
30
+ # TINA4_MQTT_CLIENT_ID, TINA4_MQTT_KEEPALIVE (seconds, default 60).
31
+ #
32
+ # No background thread is started by default. When a connection is otherwise
33
+ # idle the broker will drop it past 1.5x keepalive, so opt in to the
34
+ # cooperative keepalive with `start_keepalive` — it registers a
35
+ # Tina4::Background task exactly like the queue consumers do, rather than
36
+ # spawning a thread of its own.
37
+ #
38
+ # Single reader: like every MQTT client (paho included) the socket has ONE
39
+ # network reader. `receive`/`consume` from one thread; `publish` and the
40
+ # keepalive may be called from others (writes are serialised by a mutex).
41
+ class Mqtt
42
+ # Control packet types. The low nibble of SUBSCRIBE/UNSUBSCRIBE is 0x2
43
+ # because MQTT 3.1.1 mandates QoS 1 on those two packets.
44
+ CONNECT = 0x10
45
+ CONNACK = 0x20
46
+ PUBLISH = 0x30
47
+ PUBACK = 0x40
48
+ SUBSCRIBE = 0x82
49
+ SUBACK = 0x90
50
+ PINGREQ = 0xC0
51
+ PINGRESP = 0xD0
52
+ DISCONNECT = 0xE0
53
+
54
+ PROTOCOL_LEVEL = 0x04 # 4 == MQTT 3.1.1
55
+ DEFAULT_PORT = 1883
56
+ DEFAULT_TLS_PORT = 8883
57
+ DEFAULT_URL = "mqtt://127.0.0.1:1883"
58
+ DEFAULT_KEEPALIVE = 60
59
+ SUBSCRIPTION_REFUSED = 0x80
60
+ MAX_REMAINING_LENGTH = 268_435_455 # 4 varint bytes
61
+ # Read granularity. Always reading a full chunk keeps BOTH hidden buffers
62
+ # (Ruby's OpenSSL::Buffering @rbuf and OpenSSL's own SSL_pending) drained, so
63
+ # IO.select never blocks while decrypted plaintext is already waiting.
64
+ READ_CHUNK = 16_384
65
+
66
+ # QoS 2 is refused, never silently downgraded. A caller who asked for
67
+ # exactly-once and quietly got at-least-once would double-process every
68
+ # duplicate forever without ever seeing an error.
69
+ QOS2_REFUSED_MESSAGE =
70
+ "MQTT QoS 2 (exactly-once delivery) is not supported by Tina4 — this " \
71
+ "client speaks QoS 0 and QoS 1 only, and refuses QoS 2 rather than " \
72
+ "silently downgrading it to QoS 1. Use QoS 1 with an idempotent " \
73
+ "consumer keyed on (device_id, device_timestamp): duplicates are then " \
74
+ "harmless, which is what exactly-once was for."
75
+
76
+ # Human-readable CONNACK return codes (MQTT 3.1.1 section 3.2.2.3).
77
+ CONNACK_RETURN_CODES = {
78
+ 1 => "unacceptable protocol version",
79
+ 2 => "client identifier rejected",
80
+ 3 => "server unavailable",
81
+ 4 => "bad user name or password",
82
+ 5 => "not authorised"
83
+ }.freeze
84
+
85
+ attr_reader :host, :port, :client_id, :keepalive, :clean_session, :username
86
+
87
+ # url: mqtt://host:port, or mqtts://host:port for TLS. Credentials
88
+ # may be carried in the userinfo (mqtt://user:pass@host), and
89
+ # are percent-decoded, so a password may contain @ : or /.
90
+ # Falls back to TINA4_MQTT_URL, then localhost.
91
+ # client_id: broker-visible session id (TINA4_MQTT_CLIENT_ID, else generated).
92
+ # A durable session (clean_session: false) needs a STABLE id —
93
+ # the generated one changes every process.
94
+ # username: broker username; overrides the url's userinfo when given
95
+ # password: broker password; MQTT 3.1.1 forbids one without a username
96
+ # ca_file: PEM CA bundle used to verify the broker's certificate
97
+ # (TINA4_MQTT_CA_FILE). Needed for a private or self-signed CA.
98
+ # tls_verify: false disables certificate verification (TINA4_MQTT_TLS_VERIFY).
99
+ # NEVER the default, and it logs a warning naming the risk —
100
+ # an unverified TLS session is encrypted but not authenticated,
101
+ # so a man in the middle can read and rewrite it.
102
+ # keepalive: seconds (TINA4_MQTT_KEEPALIVE, default 60)
103
+ # clean_session: false keeps the subscription + queues QoS 1 messages for us
104
+ # while we are offline, and replays them on reconnect
105
+ # will_*: Last Will, published by the broker when we die WITHOUT a
106
+ # DISCONNECT — dead-device detection that is observed, not
107
+ # inferred from silence
108
+ # timeout: seconds to wait for a control-packet answer (CONNACK / PUBACK / SUBACK)
109
+ # read_timeout: seconds to wait for an application message; nil blocks
110
+ # connect: false builds the client without opening a socket
111
+ def initialize(url: nil, client_id: nil, username: nil, password: nil,
112
+ ca_file: nil, tls_verify: nil, keepalive: nil, clean_session: true,
113
+ will_topic: nil, will_payload: nil, will_qos: 0, will_retain: false,
114
+ timeout: 5, read_timeout: nil, connect: true)
115
+ parsed = self.class.parse_url(url || ENV["TINA4_MQTT_URL"] || DEFAULT_URL)
116
+ @host = parsed[:host]
117
+ @port = parsed[:port]
118
+ @tls = parsed[:tls]
119
+ # Explicit arguments win over the url's userinfo: the more specific source.
120
+ @username = username || parsed[:username]
121
+ @password = password || parsed[:password]
122
+ if @password && (@username.nil? || @username.empty?)
123
+ raise ArgumentError,
124
+ "MQTT password without a username is not allowed by MQTT 3.1.1 — " \
125
+ "supply both (mqtt://user:pass@host, or username:/password:) or neither"
126
+ end
127
+ @ca_file = ca_file || presence(ENV["TINA4_MQTT_CA_FILE"])
128
+ @tls_verify = tls_verify.nil? ? Tina4::Env.is_truthy(ENV.fetch("TINA4_MQTT_TLS_VERIFY", "true")) : tls_verify
129
+ @client_id = client_id || ENV["TINA4_MQTT_CLIENT_ID"]
130
+ @client_id = "tina4-#{SecureRandom.hex(8)}" if @client_id.nil? || @client_id.empty?
131
+ @keepalive = (keepalive || ENV["TINA4_MQTT_KEEPALIVE"] || DEFAULT_KEEPALIVE).to_i
132
+ @clean_session = clean_session
133
+ @will_topic = will_topic
134
+ @will_payload = will_payload
135
+ @will_qos = will_qos
136
+ @will_retain = will_retain
137
+ @timeout = timeout
138
+ @read_timeout = read_timeout
139
+
140
+ refuse_unsupported_qos(will_qos) if will_topic
141
+
142
+ @packet_id = 0
143
+ @inbox = []
144
+ @write_mutex = Mutex.new
145
+ @last_write_at = 0.0
146
+ @socket = nil
147
+ @keepalive_task = nil
148
+ @read_buffer = String.new(capacity: READ_CHUNK, encoding: Encoding::BINARY)
149
+ @read_cursor = 0
150
+
151
+ self.connect if connect
152
+ end
153
+
154
+ # Split an MQTT url into its parts:
155
+ # { host:, port:, tls:, username:, password: }
156
+ #
157
+ # "mqtt://host:port" and "tcp://host:port" are plain TCP (default port 1883),
158
+ # "mqtts://host:port" is TLS (default port 8883). A bare "host" or
159
+ # "host:port" works too, and an IPv6 literal is bracketed ("mqtt://[::1]:1883").
160
+ # Credentials ride in the userinfo and are percent-decoded, so a password
161
+ # containing @ : or / survives.
162
+ def self.parse_url(url)
163
+ raw = url.to_s.strip
164
+ raise ArgumentError, "MQTT url is empty — set TINA4_MQTT_URL (e.g. #{DEFAULT_URL})" if raw.empty?
165
+
166
+ scheme = raw[%r{\A([A-Za-z][A-Za-z0-9+.-]*)://}, 1]&.downcase
167
+ unless scheme.nil? || %w[mqtt tcp mqtts].include?(scheme)
168
+ raise ArgumentError,
169
+ "unsupported MQTT url scheme #{scheme.inspect} in #{raw.inspect} — " \
170
+ "this client speaks mqtt://, tcp:// or mqtts:// (TLS). " \
171
+ "WebSocket transports are not implemented."
172
+ end
173
+
174
+ tls = scheme == "mqtts"
175
+ rest = scheme ? raw.sub(%r{\A[A-Za-z][A-Za-z0-9+.-]*://}, "") : raw
176
+
177
+ # Split on the LAST "@" so a password containing an un-encoded "@" still
178
+ # leaves the host intact.
179
+ username = password = nil
180
+ if rest.include?("@")
181
+ userinfo, _, rest = rest.rpartition("@")
182
+ raw_username, has_password, raw_password = userinfo.partition(":")
183
+ username = percent_decode(raw_username)
184
+ password = percent_decode(raw_password) unless has_password.empty?
185
+ end
186
+
187
+ match = rest.match(%r{\A(?<host>\[[^\]]+\]|[^:/]+)(?::(?<port>\d+))?(?:/.*)?\z})
188
+ raise ArgumentError, "malformed MQTT url #{raw.inspect} — expected mqtt://host:port" unless match
189
+
190
+ {
191
+ host: match[:host].delete_prefix("[").delete_suffix("]"),
192
+ port: (match[:port] || (tls ? DEFAULT_TLS_PORT : DEFAULT_PORT)).to_i,
193
+ tls: tls,
194
+ username: presence(username),
195
+ password: password
196
+ }
197
+ end
198
+
199
+ # %XX in url userinfo. Not CGI.unescape / URI.decode_www_form_component:
200
+ # those also turn "+" into a space, which silently corrupts a password.
201
+ def self.percent_decode(value)
202
+ value.to_s.gsub(/%([0-9A-Fa-f]{2})/) { ::Regexp.last_match(1).hex.chr }
203
+ end
204
+
205
+ def self.presence(value)
206
+ value.nil? || value.empty? ? nil : value
207
+ end
208
+
209
+ # Remaining Length: 7 bits per byte, high bit means "another byte follows".
210
+ # A single-byte assumption works for every packet under 128 bytes and then
211
+ # fails, so this is exercised directly at 0 / 127 / 128 / 16383.
212
+ def self.encode_remaining_length(value)
213
+ length = Integer(value)
214
+ if length.negative? || length > MAX_REMAINING_LENGTH
215
+ raise ArgumentError, "remaining length #{length} is outside 0..#{MAX_REMAINING_LENGTH}"
216
+ end
217
+
218
+ encoded = String.new(capacity: 4, encoding: Encoding::BINARY)
219
+ loop do
220
+ byte = length & 0x7F
221
+ length >>= 7
222
+ encoded << (length.positive? ? (byte | 0x80) : byte)
223
+ break unless length.positive?
224
+ end
225
+ encoded
226
+ end
227
+
228
+ # Open the socket and complete the CONNECT / CONNACK handshake. Also the
229
+ # reconnect path: an existing socket is closed first, and a durable session
230
+ # (clean_session: false) resumes with the same client_id.
231
+ def connect
232
+ close_socket
233
+ raw_socket = Socket.tcp(@host, @port, connect_timeout: @timeout)
234
+ # Telemetry frames are tiny; Nagle would add latency for no gain.
235
+ raw_socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
236
+ @socket = @tls ? wrap_in_tls(raw_socket) : raw_socket
237
+ @inbox.clear
238
+ @read_buffer.clear
239
+ @read_cursor = 0
240
+
241
+ body = String.new(capacity: 64, encoding: Encoding::BINARY)
242
+ body << mqtt_string("MQTT")
243
+ body << [PROTOCOL_LEVEL, connect_flags].pack("C2")
244
+ body << [@keepalive].pack("n")
245
+ # Payload order is FIXED: client id, will topic, will message, username,
246
+ # password. Emitting them in any other order shifts every field after it.
247
+ body << mqtt_string(@client_id)
248
+ if @will_topic
249
+ body << mqtt_string(@will_topic)
250
+ will_bytes = payload_bytes(@will_payload)
251
+ body << [will_bytes.bytesize].pack("n") << will_bytes
252
+ end
253
+ body << mqtt_string(@username) if @username
254
+ body << mqtt_string(@password) if @password
255
+ write_packet(CONNECT, body)
256
+
257
+ header, payload = read_packet(deadline_in(@timeout))
258
+ unless header == CONNACK && payload.bytesize >= 2
259
+ raise MqttError, format("expected CONNACK, got %#04x", header)
260
+ end
261
+
262
+ return_code = payload.getbyte(1)
263
+ unless return_code.zero?
264
+ reason = CONNACK_RETURN_CODES.fetch(return_code, "unknown return code")
265
+ raise MqttError, "broker refused the connection: #{reason} (CONNACK return code #{return_code})"
266
+ end
267
+ true
268
+ rescue SystemCallError, IOError => e
269
+ close_socket
270
+ raise MqttError, "could not connect to MQTT broker at #{@host}:#{@port}: #{e.message}"
271
+ end
272
+
273
+ def connected?
274
+ !@socket.nil? && !@socket.closed?
275
+ end
276
+
277
+ # True when this connection runs over TLS (mqtts://).
278
+ def tls?
279
+ @tls
280
+ end
281
+
282
+ # The negotiated cipher suite name, or nil on a plain connection. A real
283
+ # name here is proof the TLS handshake actually completed.
284
+ def cipher
285
+ return nil unless @tls && connected?
286
+
287
+ @socket.cipher&.first
288
+ end
289
+
290
+ # The negotiated TLS protocol version ("TLSv1.3"), or nil when plain.
291
+ def tls_version
292
+ return nil unless @tls && connected?
293
+
294
+ @socket.ssl_version
295
+ end
296
+
297
+ # Never dump @password: a client can end up in a log line, an exception
298
+ # report or the debug overlay, and Ruby's default inspect prints every ivar.
299
+ def inspect
300
+ format("#<%s %s://%s:%d client_id=%s%s connected=%s>",
301
+ self.class.name, @tls ? "mqtts" : "mqtt", @host, @port,
302
+ @client_id.inspect, @username ? " username=#{@username.inspect}" : "", connected?)
303
+ end
304
+
305
+ # Publish an application message. Returns the packet identifier for QoS 1
306
+ # (the broker's PUBACK must carry it back) and nil for QoS 0.
307
+ #
308
+ # retain: true tells the broker to keep this as the topic's last known
309
+ # value and hand it to every FUTURE subscriber — how a dashboard shows
310
+ # current state the moment it connects. Publishing an EMPTY payload with
311
+ # retain: true is how a retained value is cleared.
312
+ def publish(topic, payload, qos: 0, retain: false)
313
+ refuse_unsupported_qos(qos)
314
+ bytes = payload_bytes(payload)
315
+ topic_field = mqtt_string(topic)
316
+ # The packet identifier exists ONLY when QoS > 0. Emitting it at QoS 0 (or
317
+ # omitting it at QoS 1) desynchronises the stream and every later packet
318
+ # mis-parses.
319
+ packet_id = qos.positive? ? next_packet_id : nil
320
+
321
+ body = String.new(capacity: topic_field.bytesize + bytes.bytesize + 2,
322
+ encoding: Encoding::BINARY)
323
+ body << topic_field
324
+ body << [packet_id].pack("n") if packet_id
325
+ body << bytes
326
+ write_packet(PUBLISH | (qos << 1) | (retain ? 0x01 : 0x00), body)
327
+
328
+ wait_for_acknowledgement(PUBACK, packet_id, "PUBACK") if qos == 1
329
+ packet_id
330
+ end
331
+
332
+ # Subscribe to a topic filter ("fleet/+/telemetry", "fleet/#"). Returns the
333
+ # QoS the broker GRANTED, which can be lower than the one requested.
334
+ #
335
+ # A SUBACK carrying 0x80 is a REFUSAL, not a success — treating any SUBACK
336
+ # as success means sitting on a dead subscription receiving nothing, so it
337
+ # raises here.
338
+ def subscribe(topic_filter, qos: 1)
339
+ refuse_unsupported_qos(qos)
340
+ packet_id = next_packet_id
341
+ filter_field = mqtt_string(topic_filter)
342
+
343
+ body = String.new(capacity: filter_field.bytesize + 3, encoding: Encoding::BINARY)
344
+ body << [packet_id].pack("n")
345
+ body << filter_field
346
+ body << qos
347
+ write_packet(SUBSCRIBE, body)
348
+
349
+ payload = wait_for_acknowledgement(SUBACK, packet_id, "SUBACK")
350
+ raise MqttError, "malformed SUBACK: no return code for #{topic_filter.inspect}" if payload.bytesize < 3
351
+
352
+ granted = payload.getbyte(2)
353
+ if granted == SUBSCRIPTION_REFUSED
354
+ raise MqttError,
355
+ "broker refused the subscription to #{topic_filter.inspect} " \
356
+ "(SUBACK return code 0x80) — check the topic filter and the broker ACLs"
357
+ end
358
+ granted
359
+ end
360
+
361
+ # Read the next application message. Returns a Tina4::MqttMessage.
362
+ #
363
+ # ack: true (the default) acknowledges a QoS 1 delivery immediately, which
364
+ # is right for a synchronous read. Pass ack: false when the message must be
365
+ # stored before the broker is allowed to forget it — an unacknowledged QoS 1
366
+ # message is redelivered with DUP set. `consume` does exactly that.
367
+ def receive(timeout: nil, ack: true)
368
+ message = @inbox.shift || read_publish(deadline_in(timeout || @read_timeout))
369
+ message.acknowledge if ack
370
+ message
371
+ end
372
+
373
+ # Long-running consumer, mirroring Queue#consume.
374
+ #
375
+ # mqtt.consume("fleet/+/telemetry", qos: 1) { |message| store(message) }
376
+ # mqtt.consume.each { |message| store(message) } # already subscribed
377
+ #
378
+ # The message is acknowledged AFTER the block returns, so a handler that
379
+ # raises leaves the message unacknowledged and the broker redelivers it
380
+ # (with DUP set) — at-least-once, which is the point of QoS 1. The raise
381
+ # propagates: a consumer that swallows storage failures loses data quietly.
382
+ #
383
+ # iterations: > 0 stops after that many messages (bounded runs and tests).
384
+ def consume(topic_filter = nil, qos: 1, iterations: 0, timeout: nil, &block)
385
+ subscribe(topic_filter, qos: qos) if topic_filter
386
+ unless block
387
+ return Enumerator.new do |yielder|
388
+ consume_loop(iterations, timeout) { |message| yielder << message }
389
+ end
390
+ end
391
+
392
+ consume_loop(iterations, timeout, &block)
393
+ end
394
+
395
+ # PUBACK a QoS 1 delivery. Called by MqttMessage#acknowledge.
396
+ def acknowledge(packet_id)
397
+ write_packet(PUBACK, [packet_id].pack("n"))
398
+ true
399
+ end
400
+
401
+ # PINGREQ and wait for the PINGRESP. Use this when nothing else is reading
402
+ # the socket; under a `consume` loop use start_keepalive instead, because
403
+ # the reader there absorbs the PINGRESP.
404
+ def ping(timeout: nil)
405
+ send_keepalive
406
+ deadline = deadline_in(timeout || @timeout)
407
+ loop do
408
+ header, payload = read_packet(deadline)
409
+ return true if header == PINGRESP
410
+ next if stash_publish(header, payload)
411
+
412
+ raise MqttError, format("expected PINGRESP, got %#04x", header)
413
+ end
414
+ end
415
+
416
+ # Write a PINGREQ without waiting for the answer. The PINGRESP is absorbed
417
+ # by whatever is reading the socket (`receive` skips it), which is what
418
+ # makes the background keepalive safe alongside a consume loop.
419
+ def send_keepalive
420
+ write_packet(PINGREQ, "")
421
+ true
422
+ end
423
+
424
+ # Opt in to the cooperative keepalive. Registers a Tina4::Background task —
425
+ # the same mechanism the queue consumers use — that sends a PINGREQ only
426
+ # when the connection has actually gone quiet, so an actively publishing
427
+ # client costs no extra packets. Without a keepalive the broker drops a
428
+ # silent client past 1.5x keepalive (which is exactly what fires the Last
429
+ # Will).
430
+ def start_keepalive(interval: nil)
431
+ return @keepalive_task if @keepalive_task
432
+ raise MqttError, "keepalive is disabled (keepalive: 0) — nothing to schedule" unless @keepalive.positive?
433
+
434
+ seconds = interval || [@keepalive / 2.0, 1.0].max
435
+ @keepalive_task = Tina4::Background.register(interval: seconds) do
436
+ send_keepalive if connected? && idle_for?(seconds)
437
+ end
438
+ end
439
+
440
+ def stop_keepalive
441
+ return false unless @keepalive_task
442
+
443
+ Tina4::Background.stop_task(@keepalive_task)
444
+ @keepalive_task = nil
445
+ true
446
+ end
447
+
448
+ # Say goodbye properly: DISCONNECT then close. The broker discards the Last
449
+ # Will on a graceful disconnect, and a clean_session: false session survives
450
+ # for the next connect with the same client_id.
451
+ def disconnect
452
+ stop_keepalive
453
+ begin
454
+ write_packet(DISCONNECT, "") if connected?
455
+ rescue MqttError, SystemCallError, IOError
456
+ # Already gone — closing is still the right outcome.
457
+ ensure
458
+ close_socket
459
+ end
460
+ true
461
+ end
462
+
463
+ # Drop the socket WITHOUT a DISCONNECT — what a crashed or unplugged device
464
+ # looks like to the broker, and therefore what fires the Last Will.
465
+ def kill
466
+ stop_keepalive
467
+ close_socket
468
+ true
469
+ end
470
+
471
+ private
472
+
473
+ # The one delivery loop, shared by the block and Enumerator forms so the
474
+ # acknowledge-after-processing contract cannot drift between them.
475
+ def consume_loop(iterations, timeout)
476
+ consumed = 0
477
+ loop do
478
+ message = receive(timeout: timeout, ack: false)
479
+ yield message
480
+ message.acknowledge
481
+ consumed += 1
482
+ break if iterations.positive? && consumed >= iterations
483
+ end
484
+ consumed
485
+ end
486
+
487
+ def connect_flags
488
+ flags = @clean_session ? 0x02 : 0x00
489
+ if @will_topic
490
+ # Will flag 0x04, will QoS at bits 3-4, will retain 0x20.
491
+ flags |= 0x04 | (@will_qos << 3)
492
+ flags |= 0x20 if @will_retain
493
+ end
494
+ flags |= 0x80 if @username
495
+ flags |= 0x40 if @password
496
+ flags
497
+ end
498
+
499
+ # Wrap the TCP socket in TLS. `openssl` is Ruby stdlib, so mqtts:// costs no
500
+ # dependency — and it is required HERE rather than at the top of the file so a
501
+ # plain mqtt:// app never loads it.
502
+ def wrap_in_tls(raw_socket)
503
+ require "openssl"
504
+ context = OpenSSL::SSL::SSLContext.new
505
+
506
+ if @tls_verify
507
+ # Build our OWN trust store. Deliberately NOT context.ca_file= and NOT
508
+ # SSLContext#set_params: set_params installs the process-wide
509
+ # DEFAULT_CERT_STORE, and a later ca_file= writes our CA INTO that shared
510
+ # store, so every subsequent client in the process would trust it too.
511
+ # Verified 2026-07-23: with that ordering a second client accepted a
512
+ # self-signed broker certificate with no CA configured at all.
513
+ store = OpenSSL::X509::Store.new
514
+ store.set_default_paths # the system CAs, for a publicly-signed broker
515
+ if @ca_file
516
+ unless File.file?(@ca_file)
517
+ raise MqttError,
518
+ "MQTT CA file not found: #{@ca_file} — TINA4_MQTT_CA_FILE (or ca_file:) " \
519
+ "must point at the broker's CA certificate in PEM form"
520
+ end
521
+ store.add_file(@ca_file)
522
+ end
523
+ context.cert_store = store
524
+ context.verify_mode = OpenSSL::SSL::VERIFY_PEER
525
+ else
526
+ context.verify_mode = OpenSSL::SSL::VERIFY_NONE
527
+ Tina4::Log.warning(
528
+ "MQTT TLS certificate verification is DISABLED for mqtts://#{@host}:#{@port} — " \
529
+ "the connection is encrypted but the broker's identity is NOT verified, so a " \
530
+ "man in the middle can read and rewrite this traffic. Set TINA4_MQTT_CA_FILE " \
531
+ "(or ca_file:) to the broker's CA and drop TINA4_MQTT_TLS_VERIFY=false."
532
+ )
533
+ end
534
+
535
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(raw_socket, context)
536
+ # SNI, but only for a real hostname: an IP literal is not a valid SNI name.
537
+ ssl_socket.hostname = @host unless ip_literal?(@host)
538
+ ssl_socket.sync_close = true
539
+ ssl_socket.sync = true
540
+ ssl_socket.connect
541
+ # Chain verification does not check WHO the certificate is for. Ruby's
542
+ # post_connection_check handles both DNS and IP SANs.
543
+ ssl_socket.post_connection_check(@host) if @tls_verify
544
+ ssl_socket
545
+ rescue OpenSSL::SSL::SSLError, OpenSSL::X509::StoreError => e
546
+ raw_socket.close
547
+ raise MqttError, "MQTT TLS handshake with #{@host}:#{@port} failed: #{e.message}"
548
+ end
549
+
550
+ def ip_literal?(host)
551
+ host.match?(/\A\d{1,3}(\.\d{1,3}){3}\z/) || host.include?(":")
552
+ end
553
+
554
+ def presence(value)
555
+ self.class.presence(value)
556
+ end
557
+
558
+ def refuse_unsupported_qos(qos)
559
+ raise ArgumentError, QOS2_REFUSED_MESSAGE if qos == 2
560
+ raise ArgumentError, "qos must be 0 or 1 (got #{qos.inspect})" unless [0, 1].include?(qos)
561
+ end
562
+
563
+ # Every MQTT string is a 2-byte big-endian length followed by UTF-8 bytes.
564
+ def mqtt_string(value)
565
+ bytes = binary(value.to_s)
566
+ raise ArgumentError, "MQTT string is longer than 65535 bytes" if bytes.bytesize > 0xFFFF
567
+
568
+ [bytes.bytesize].pack("n") << bytes
569
+ end
570
+
571
+ def payload_bytes(payload)
572
+ return "".b if payload.nil?
573
+
574
+ binary(payload.to_s)
575
+ end
576
+
577
+ # The bytes of a String, without copying one that is already binary — a
578
+ # telemetry payload should not be duplicated on its way to the socket.
579
+ def binary(value)
580
+ value.encoding == Encoding::BINARY ? value : value.b
581
+ end
582
+
583
+ # Packet identifiers are 1..65535; 0 is invalid.
584
+ def next_packet_id
585
+ @write_mutex.synchronize { @packet_id = (@packet_id % 0xFFFF) + 1 }
586
+ end
587
+
588
+ # One buffer per packet. The fixed header (control byte + Remaining Length
589
+ # varint, 5 bytes at most) is written together with the body in a SINGLE
590
+ # write — IO#write with several arguments issues one writev, so the payload
591
+ # is never copied into a second buffer and a small telemetry frame still
592
+ # leaves as one segment.
593
+ def write_packet(header, body)
594
+ raise MqttError, "not connected to an MQTT broker" unless connected?
595
+
596
+ fixed_header = String.new(capacity: 5, encoding: Encoding::BINARY)
597
+ fixed_header << header << self.class.encode_remaining_length(body.bytesize)
598
+ @write_mutex.synchronize do
599
+ @socket.write(fixed_header, body)
600
+ @last_write_at = monotonic
601
+ end
602
+ true
603
+ rescue SystemCallError, IOError => e
604
+ raise MqttError, "MQTT write failed: #{e.message}"
605
+ rescue StandardError => e
606
+ raise MqttError, "MQTT TLS write failed: #{e.message}" if tls_error?(e)
607
+
608
+ raise
609
+ end
610
+
611
+ # An OpenSSL error must not leak out of the client as a raw OpenSSL
612
+ # exception. Guarded with defined? because `openssl` is only required when a
613
+ # mqtts:// connection is actually made.
614
+ def tls_error?(error)
615
+ defined?(OpenSSL::SSL::SSLError) && error.is_a?(OpenSSL::SSL::SSLError)
616
+ end
617
+
618
+ # Returns [control_byte, body]. The fixed header is read in exactly 1 + N
619
+ # bytes (N <= 4 for the varint) so the next packet's header is never
620
+ # consumed by a speculative over-read.
621
+ def read_packet(deadline)
622
+ header = read_exact(1, deadline).getbyte(0)
623
+ multiplier = 1
624
+ length = 0
625
+ loop do
626
+ byte = read_exact(1, deadline).getbyte(0)
627
+ length += (byte & 0x7F) * multiplier
628
+ break if (byte & 0x80).zero?
629
+
630
+ multiplier <<= 7
631
+ raise MqttError, "malformed Remaining Length (more than 4 varint bytes)" if multiplier > 0x200000
632
+ end
633
+ [header, length.zero? ? "".b : read_exact(length, deadline)]
634
+ end
635
+
636
+ # TCP is a stream: a single read returns SHORT. Loop, or the parse corrupts
637
+ # under load.
638
+ #
639
+ # Reads go through ONE buffer, filled a whole READ_CHUNK at a time. That is
640
+ # both fewer syscalls (a small packet costs one read rather than one per
641
+ # fixed-header byte) and what makes TLS correct: asking for a full chunk
642
+ # drains Ruby's OpenSSL::Buffering @rbuf and OpenSSL's SSL_pending, so a
643
+ # later IO.select can never block on an empty TCP socket while decrypted
644
+ # plaintext is already sitting in a buffer.
645
+ def read_exact(count, deadline)
646
+ raise MqttError, "not connected to an MQTT broker" unless connected?
647
+
648
+ fill_read_buffer(deadline) while buffered_bytes < count
649
+ take_buffered(count)
650
+ end
651
+
652
+ def buffered_bytes
653
+ @read_buffer.bytesize - @read_cursor
654
+ end
655
+
656
+ def take_buffered(count)
657
+ chunk = @read_buffer.byteslice(@read_cursor, count)
658
+ @read_cursor += count
659
+ # Fully consumed (the common case, once per packet): reset rather than grow.
660
+ if @read_cursor >= @read_buffer.bytesize
661
+ @read_buffer.clear
662
+ @read_cursor = 0
663
+ end
664
+ chunk
665
+ end
666
+
667
+ def fill_read_buffer(deadline)
668
+ wait_readable(deadline)
669
+ data =
670
+ begin
671
+ @socket.readpartial(READ_CHUNK)
672
+ rescue EOFError
673
+ raise MqttError, "broker closed the connection"
674
+ rescue SystemCallError, IOError => e
675
+ raise MqttError, "MQTT read failed: #{e.message}"
676
+ rescue StandardError => e
677
+ raise MqttError, "MQTT TLS read failed: #{e.message}" if tls_error?(e)
678
+
679
+ raise
680
+ end
681
+ if @read_buffer.empty?
682
+ @read_buffer.replace(data)
683
+ else
684
+ @read_buffer << data
685
+ end
686
+ end
687
+
688
+ def wait_readable(deadline)
689
+ # Bytes already decrypted inside OpenSSL are readable NOW; the underlying
690
+ # socket may have nothing, so selecting on it first would hang.
691
+ return if @socket.respond_to?(:pending) && @socket.pending.to_i.positive?
692
+
693
+ remaining = nil
694
+ if deadline
695
+ remaining = deadline - monotonic
696
+ raise MqttTimeoutError, "timed out waiting for the MQTT broker" if remaining <= 0
697
+ end
698
+ # An SSLSocket is not an IO; select on the socket underneath it.
699
+ io = @socket.respond_to?(:to_io) ? @socket.to_io : @socket
700
+ return if IO.select([io], nil, nil, remaining)
701
+
702
+ raise MqttTimeoutError, "timed out waiting for the MQTT broker"
703
+ end
704
+
705
+ # Read packets until the next PUBLISH, skipping the PINGRESPs the keepalive
706
+ # generates.
707
+ def read_publish(deadline)
708
+ loop do
709
+ header, payload = read_packet(deadline)
710
+ next if header == PINGRESP
711
+
712
+ message = parse_publish(header, payload)
713
+ return message if message
714
+
715
+ raise MqttError, format("expected PUBLISH, got %#04x", header)
716
+ end
717
+ end
718
+
719
+ # A PUBLISH that arrives while we are waiting for a PUBACK/SUBACK/PINGRESP
720
+ # (normal when the same connection both publishes and subscribes) is parked
721
+ # in the inbox so `receive` still delivers it, in order, instead of it being
722
+ # mistaken for the acknowledgement.
723
+ def stash_publish(header, payload)
724
+ message = parse_publish(header, payload)
725
+ return false unless message
726
+
727
+ @inbox << message
728
+ true
729
+ end
730
+
731
+ def parse_publish(header, payload)
732
+ return nil unless (header & 0xF0) == PUBLISH
733
+
734
+ qos = (header & 0x06) >> 1
735
+ topic_length = payload.unpack1("n")
736
+ offset = 2 + topic_length
737
+ topic = payload.byteslice(2, topic_length).force_encoding(Encoding::UTF_8)
738
+ packet_id = nil
739
+ # The packet identifier is present ONLY when QoS > 0.
740
+ if qos.positive?
741
+ packet_id = payload.byteslice(offset, 2).unpack1("n")
742
+ offset += 2
743
+ end
744
+
745
+ MqttMessage.new(
746
+ topic: topic,
747
+ payload: payload.byteslice(offset, payload.bytesize - offset) || "".b,
748
+ qos: qos,
749
+ retained: (header & 0x01) == 0x01,
750
+ duplicate: (header & 0x08) == 0x08,
751
+ packet_id: packet_id,
752
+ client: self
753
+ )
754
+ end
755
+
756
+ # Wait for a specific acknowledgement, tolerating interleaved PUBLISH and
757
+ # PINGRESP packets. A mismatched packet identifier is silent data loss if
758
+ # ignored, so it raises.
759
+ def wait_for_acknowledgement(expected_header, packet_id, name)
760
+ deadline = deadline_in(@timeout)
761
+ loop do
762
+ header, payload = read_packet(deadline)
763
+ next if header == PINGRESP
764
+ next if stash_publish(header, payload)
765
+
766
+ unless header == expected_header
767
+ raise MqttError, format("expected %s, got %#04x", name, header)
768
+ end
769
+
770
+ received_id = payload.unpack1("n")
771
+ unless received_id == packet_id
772
+ raise MqttError,
773
+ "#{name} packet identifier mismatch: broker acknowledged " \
774
+ "#{received_id} but we sent #{packet_id}"
775
+ end
776
+ return payload
777
+ end
778
+ end
779
+
780
+ def idle_for?(seconds)
781
+ (monotonic - @last_write_at) >= seconds
782
+ end
783
+
784
+ def deadline_in(seconds)
785
+ seconds ? monotonic + seconds : nil
786
+ end
787
+
788
+ def monotonic
789
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
790
+ end
791
+
792
+ def close_socket
793
+ @socket.close if @socket && !@socket.closed?
794
+ rescue IOError, SystemCallError
795
+ nil
796
+ ensure
797
+ @socket = nil
798
+ end
799
+ end
800
+ end