mqtt-core 0.0.1.ci.release
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/lib/mqtt/core/client/acknowledgement.rb +31 -0
- data/lib/mqtt/core/client/client_id_generator.rb +19 -0
- data/lib/mqtt/core/client/connection.rb +193 -0
- data/lib/mqtt/core/client/enumerable_subscription.rb +224 -0
- data/lib/mqtt/core/client/filesystem_session_store.rb +197 -0
- data/lib/mqtt/core/client/memory_session_store.rb +84 -0
- data/lib/mqtt/core/client/qos0_session_store.rb +56 -0
- data/lib/mqtt/core/client/qos2_session_store.rb +45 -0
- data/lib/mqtt/core/client/qos_tracker.rb +119 -0
- data/lib/mqtt/core/client/retry_strategy.rb +63 -0
- data/lib/mqtt/core/client/session.rb +195 -0
- data/lib/mqtt/core/client/session_store.rb +128 -0
- data/lib/mqtt/core/client/socket_factory.rb +268 -0
- data/lib/mqtt/core/client/subscription.rb +109 -0
- data/lib/mqtt/core/client/uri.rb +77 -0
- data/lib/mqtt/core/client.rb +700 -0
- data/lib/mqtt/core/packet/connect.rb +39 -0
- data/lib/mqtt/core/packet/publish.rb +45 -0
- data/lib/mqtt/core/packet/subscribe.rb +190 -0
- data/lib/mqtt/core/packet/unsubscribe.rb +21 -0
- data/lib/mqtt/core/packet.rb +168 -0
- data/lib/mqtt/core/type/binary.rb +35 -0
- data/lib/mqtt/core/type/bit_flags.rb +89 -0
- data/lib/mqtt/core/type/boolean_byte.rb +48 -0
- data/lib/mqtt/core/type/fixed_int.rb +43 -0
- data/lib/mqtt/core/type/list.rb +41 -0
- data/lib/mqtt/core/type/password.rb +36 -0
- data/lib/mqtt/core/type/properties.rb +124 -0
- data/lib/mqtt/core/type/reason_codes.rb +60 -0
- data/lib/mqtt/core/type/remaining.rb +30 -0
- data/lib/mqtt/core/type/shape.rb +177 -0
- data/lib/mqtt/core/type/sub_type.rb +34 -0
- data/lib/mqtt/core/type/utf8_string.rb +39 -0
- data/lib/mqtt/core/type/utf8_string_pair.rb +29 -0
- data/lib/mqtt/core/type/var_int.rb +56 -0
- data/lib/mqtt/core/version.rb +10 -0
- data/lib/mqtt/core.rb +5 -0
- data/lib/mqtt/errors.rb +39 -0
- data/lib/mqtt/logger.rb +92 -0
- data/lib/mqtt/open.rb +239 -0
- data/lib/mqtt/options.rb +59 -0
- data/lib/mqtt/version.rb +6 -0
- data/lib/mqtt.rb +3 -0
- data/lib/patches/openssl.rb +19 -0
- metadata +98 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'uri'
|
|
4
|
+
require_relative '../../options'
|
|
5
|
+
|
|
6
|
+
module MQTT
|
|
7
|
+
module Core
|
|
8
|
+
class Client
|
|
9
|
+
# A Factory for creating the underlying connection to an MQTT server/broker from a URI
|
|
10
|
+
# @see https://github.com/mqtt/mqtt.github.io/wiki/URI-Scheme
|
|
11
|
+
# @api public
|
|
12
|
+
class SocketFactory
|
|
13
|
+
extend Options
|
|
14
|
+
include Options
|
|
15
|
+
|
|
16
|
+
# Option keys for constructing a URI
|
|
17
|
+
URI_OPTIONS = %i[uri host port local_addr local_port scheme password_file].freeze
|
|
18
|
+
|
|
19
|
+
# Option keys for constructing an IO object.
|
|
20
|
+
# @see new_io
|
|
21
|
+
IO_OPTIONS = (%i[connect_timeout resolv_timeout tcp_nodelay] + [:ssl_context, /ssl_(.*)/]).freeze
|
|
22
|
+
|
|
23
|
+
# All option keys owned by SocketFactory
|
|
24
|
+
OPTIONS = (%i[ignore_uri_params] + URI_OPTIONS + IO_OPTIONS).freeze
|
|
25
|
+
|
|
26
|
+
# Removes {OPTIONS} from an options Hash.
|
|
27
|
+
def self.extract_io_options(options)
|
|
28
|
+
slice_opts!(options, *OPTIONS)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Create a SocketFactory for establishing MQTT connections
|
|
32
|
+
#
|
|
33
|
+
# @overload create(uri = ENV['MQTT_SERVER'], **opts)
|
|
34
|
+
# Create from a URI
|
|
35
|
+
# @param [String, URI] uri an mqtt://, mqtts:// or unix:// URI
|
|
36
|
+
# @param [Hash<Symbol>] **opts
|
|
37
|
+
# @option opts [Boolean] ignore_uri_params (false)
|
|
38
|
+
# URI query parameters are merged into the opts Hash unless this is set. Use where uri input is untrusted.
|
|
39
|
+
# @option opts [String] password_file Provide authentication password from a file
|
|
40
|
+
# @option opts [Hash<Symbol>] **io_opts connection timeouts and socket options see {#new_io}
|
|
41
|
+
# @option opts [OpenSSL::SSL::SSLContext] ssl_context SSL context for `mqtts://` scheme
|
|
42
|
+
#
|
|
43
|
+
# - A default (secure) context is created if this option is absent and the mqtts:// scheme is used.
|
|
44
|
+
# @option opts [Hash<Symbol>] **ssl Additional `ssl_(.*)` options passed to
|
|
45
|
+
# OpenSSL::SSL::SSLContext#set_params
|
|
46
|
+
#
|
|
47
|
+
# - `min_version`, `ca_file`, `ciphers`, etc...
|
|
48
|
+
# @option opts [Hash<Symbol>] **client_certificate options for client authentication certificate
|
|
49
|
+
#
|
|
50
|
+
# - `ssl_cert`, `ssl_key` - can take native `OpenSSL` objects, or construct them from a `String`
|
|
51
|
+
# - `ssl_cert_file`, `ssl_key_file` - options to construct certificate objects from files
|
|
52
|
+
# - `ssl_passphrase`, `ssl_passphrase_file` - options to set the passphrase for the private key if required
|
|
53
|
+
# @return [SocketFactory]
|
|
54
|
+
# @example MQTT URI
|
|
55
|
+
# uri = URI('mqtt://localhost:1883')
|
|
56
|
+
# factory = MQTT::Core::Client::SocketFactory.create(uri)
|
|
57
|
+
# factory.new_io # => #<TCPSocket:0x00007f9920002000>
|
|
58
|
+
# @example MQTTS URI with minimum TLS version
|
|
59
|
+
# uri = URI('mqtts://localhost:8883')
|
|
60
|
+
# factory = MQTT::Core::Client::SocketFactory.create(uri, ssl_min_version: :TLSv1_2)
|
|
61
|
+
# factory.new_io # => #<OpenSSL::SSL::SSLSocket:0x00007f9920002000>
|
|
62
|
+
# @example MQTTS URI with client certificate files
|
|
63
|
+
# uri = 'mqtts://localhost:8883?ssl_cert_file=client.crt&ssl_key_file=client.key'
|
|
64
|
+
# factory = MQTT::Core::Client::SocketFactory.create(uri)
|
|
65
|
+
# @see URI::MQTT
|
|
66
|
+
# @see URI::MQTTS
|
|
67
|
+
#
|
|
68
|
+
# @overload create(host, port = nil, local_addr = nil, local_port = nil, scheme: nil, **opts)
|
|
69
|
+
# Build URI from TCP-style arguments
|
|
70
|
+
# @param [String] host Hostname or IP address
|
|
71
|
+
# @param [Integer, nil] port Port number
|
|
72
|
+
# @param [String, nil] local_addr Local address for the TCPSocket
|
|
73
|
+
# @param [Integer, nil] local_port Local port for the TCPSocket
|
|
74
|
+
# @param [String, nil] scheme 'mqtt', 'mqtts', or nil to auto-detect from ssl options
|
|
75
|
+
# @param [Hash<Symbol>] **opts Additional options (see first overload)
|
|
76
|
+
# @return [SocketFactory]
|
|
77
|
+
#
|
|
78
|
+
# @overload create(**opts)
|
|
79
|
+
# Create from keyword options only
|
|
80
|
+
# @param [Hash<Symbol>] opts Options including uri, host, port, local_addr, local_port (see first overload)
|
|
81
|
+
# @return [SocketFactory]
|
|
82
|
+
#
|
|
83
|
+
# @overload create(klass, *rest, **opts)
|
|
84
|
+
# Create a custom IO builder
|
|
85
|
+
# @param [Class] klass Class to instantiate
|
|
86
|
+
# @param [Array] *rest Arguments passed to klass.new
|
|
87
|
+
# @param [Hash<Symbol>] **opts Keyword arguments passed to klass.new
|
|
88
|
+
# @return [:new_io] New instance that implements `#new_io`
|
|
89
|
+
#
|
|
90
|
+
# @overload create(obj, options: {})
|
|
91
|
+
# Pass through an existing SocketFactory or compatible object
|
|
92
|
+
# @param [:new_io] obj Object that implements `#new_io`
|
|
93
|
+
# @param [Hash<Symbol>] options SocketFactory related options are extracted from this hash
|
|
94
|
+
# @return [:new_io] The obj parameter unchanged
|
|
95
|
+
# @see extract_io_options
|
|
96
|
+
def self.create(*io_args, options: {}, **opts)
|
|
97
|
+
opts.merge!(extract_io_options(options))
|
|
98
|
+
return io_args.first if io_args.first.respond_to?(:new_io)
|
|
99
|
+
|
|
100
|
+
(io_args.first.is_a?(Class) ? io_args.shift : self).new(*io_args, **opts)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @!visibility private
|
|
104
|
+
# rubocop:disable Metrics/AbcSize
|
|
105
|
+
def initialize(*io_args, ignore_uri_params: false, **opts)
|
|
106
|
+
extract_io_args(io_args, opts)
|
|
107
|
+
|
|
108
|
+
@uri, @io_args = parse_uri(*io_args, default_scheme: default_scheme(opts))
|
|
109
|
+
unless %w[mqtt mqtts unix].include?(@uri.scheme)
|
|
110
|
+
raise URI::InvalidURIError, "Invalid scheme for MQTT: #{@uri.scheme}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
@uri.require_deps
|
|
114
|
+
|
|
115
|
+
opts.merge!(URI.decode_www_form(@uri.query || '').to_h.transform_keys(&:to_sym)) unless ignore_uri_params
|
|
116
|
+
@uri.query = nil
|
|
117
|
+
|
|
118
|
+
@io_opts = slice_opts!(opts, :connect_timeout, :resolv_timeout, :tcp_nodelay) do |k, v|
|
|
119
|
+
k == :tcp_nodelay ? coerce_boolean(k, v) : coerce_float(k, v)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
@auth = build_auth(opts)
|
|
123
|
+
@ssl_context = build_ssl_context(**slice_ssl_opts!(opts))
|
|
124
|
+
|
|
125
|
+
# Remaining unused opts
|
|
126
|
+
@query_params = opts.freeze
|
|
127
|
+
end
|
|
128
|
+
# rubocop:enable Metrics/AbcSize
|
|
129
|
+
|
|
130
|
+
# @return [URI] The URI that will be used for the next connection.
|
|
131
|
+
attr_reader :uri
|
|
132
|
+
alias sanitized_uri uri
|
|
133
|
+
|
|
134
|
+
# @return [Hash<Symbol>] io_opts default options for #new_io
|
|
135
|
+
attr_reader :io_opts
|
|
136
|
+
|
|
137
|
+
# @return [Hash<Symbol>] username and password from URI
|
|
138
|
+
attr_reader :auth
|
|
139
|
+
|
|
140
|
+
# @return [Hash<Symbol>] Unused options and uri query parameters. This hash is frozen.
|
|
141
|
+
attr_reader :query_params
|
|
142
|
+
|
|
143
|
+
# @param [Hash<Symbol>] io_opts Options passed to the underlying IO object
|
|
144
|
+
#
|
|
145
|
+
# Available options depend on the URI scheme
|
|
146
|
+
# @option io_opts [Boolean] tcp_nodelay (true) (MQTT/S) Enable TCP_NODELAY to avoid trying to coalesce packets.
|
|
147
|
+
# @option io_opts [Numeric] connect_timeout (nil) (MQTT/S) Timeout in seconds to establish a connection
|
|
148
|
+
# @option io_opts [Numeric] resolv_timeout (connect_timeout) (MQTT/S) Timeout in seconds for name resolution
|
|
149
|
+
# @return [IO] -a connection to the URI
|
|
150
|
+
def new_io(**io_opts)
|
|
151
|
+
@uri.to_io(*@io_args, **@io_opts, **io_opts, **(@ssl_context && { ssl_context: @ssl_context }))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def default_scheme(io_params)
|
|
157
|
+
return io_params.delete(:scheme) if io_params.key?(:scheme)
|
|
158
|
+
|
|
159
|
+
io_params.keys.any? { |k| k.to_s.start_with?('ssl_') } ? 'mqtts' : 'mqtt'
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Pull non-SSL args out of the hash
|
|
163
|
+
# amazonq-ignore-next-line
|
|
164
|
+
def extract_io_args(io_args, io_params)
|
|
165
|
+
%i[uri host port local_addr local_port].each { |k| io_args << io_params.delete(k) }
|
|
166
|
+
io_args.compact!
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def parse_uri(host = nil, *io_args, default_scheme:)
|
|
170
|
+
host ||= ENV.fetch('MQTT_SERVER', nil)
|
|
171
|
+
return [host, io_args.freeze] if host.is_a?(URI)
|
|
172
|
+
return [URI.parse(host), io_args.freeze] if host.is_a?(String) && host =~ %r{^[a-z]+://}
|
|
173
|
+
|
|
174
|
+
port = io_args.shift if io_args.any?
|
|
175
|
+
[URI.parse(["#{default_scheme}://#{host}", port].compact.join(':')), io_args.freeze]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def build_auth(io_params)
|
|
179
|
+
# agents review - file path traversal
|
|
180
|
+
password = File.read(io_params.delete(:password_file)).chomp if io_params.key?(:password_file)
|
|
181
|
+
password ||= URI.decode_www_form_component(@uri.password) if @uri.password
|
|
182
|
+
username = URI.decode_www_form_component(@uri.user) if @uri.user
|
|
183
|
+
# amazonq-ignore-next-line
|
|
184
|
+
@uri.password = '********' if password
|
|
185
|
+
{ username: username, password: password }.compact
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def slice_ssl_opts!(opts, *_keys)
|
|
189
|
+
slice_opts!(opts, :ssl_context, /^ssl_(.*)$/) do |k, v|
|
|
190
|
+
case k
|
|
191
|
+
when :verify_mode
|
|
192
|
+
coerce_ssl_verify_mode(v)
|
|
193
|
+
when :verify_hostname
|
|
194
|
+
coerce_boolean(k, v)
|
|
195
|
+
when :verify_depth
|
|
196
|
+
coerce_integer(k, v)
|
|
197
|
+
else
|
|
198
|
+
v
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def build_ssl_context(ssl_context: nil, **ssl_params)
|
|
204
|
+
return unless @uri.scheme == 'mqtts'
|
|
205
|
+
|
|
206
|
+
(ssl_context || OpenSSL::SSL::SSLContext.new).tap do |ctx|
|
|
207
|
+
build_client_certificate_params(ssl_params)
|
|
208
|
+
ctx.set_params(ssl_params)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def coerce_ssl_verify_mode(value)
|
|
213
|
+
return value if value.is_a?(Integer)
|
|
214
|
+
return value unless value
|
|
215
|
+
|
|
216
|
+
case value.to_s.downcase
|
|
217
|
+
when 'none', '0' then OpenSSL::SSL::VERIFY_NONE
|
|
218
|
+
when 'peer', '1' then OpenSSL::SSL::VERIFY_PEER
|
|
219
|
+
when 'fail_if_no_peer_cert', '2' then OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
|
|
220
|
+
when 'client_once', '4' then OpenSSL::SSL::VERIFY_CLIENT_ONCE
|
|
221
|
+
else
|
|
222
|
+
raise ArgumentError, "Invalid ssl_verify_mode: #{value}. Use 'none', 'peer', (See OpenSSL::SSL::VERIFY_*)"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# rubocop:disable Metrics/AbcSize
|
|
227
|
+
def build_client_certificate_params(ssl_params)
|
|
228
|
+
passphrase = File.read(ssl_params.delete(:passphrase_file)).chomp if ssl_params.key?(:passphrase_file)
|
|
229
|
+
passphrase = ssl_params.delete(:passphrase) if ssl_params.key?(:passphrase)
|
|
230
|
+
|
|
231
|
+
ssl_params[:cert] = load_certificate(ssl_params[:cert]) if ssl_params[:cert].is_a?(String)
|
|
232
|
+
ssl_params[:cert] = load_certificate_file(ssl_params.delete(:cert_file)) if ssl_params.key?(:cert_file)
|
|
233
|
+
|
|
234
|
+
ssl_params[:key] = load_private_key(ssl_params[:key], passphrase) if ssl_params[:key].is_a?(String)
|
|
235
|
+
return unless ssl_params.key?(:key_file)
|
|
236
|
+
|
|
237
|
+
ssl_params[:key] =
|
|
238
|
+
load_private_key_file(ssl_params.delete(:key_file), passphrase)
|
|
239
|
+
end
|
|
240
|
+
# rubocop:enable Metrics/AbcSize
|
|
241
|
+
|
|
242
|
+
def load_certificate_file(cert_file)
|
|
243
|
+
return load_certificate(cert_file.binread) if cert_file.respond_to?(:binread)
|
|
244
|
+
|
|
245
|
+
load_certificate(File.binread(cert_file))
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def load_certificate(cert)
|
|
249
|
+
return cert if cert.is_a?(OpenSSL::X509::Certificate)
|
|
250
|
+
|
|
251
|
+
OpenSSL::X509::Certificate.new(cert)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def load_private_key_file(key_file, passphrase = nil)
|
|
255
|
+
return load_private_key(key_file.binread, passphrase) if key_file.respond_to?(:binread)
|
|
256
|
+
|
|
257
|
+
load_private_key(File.binread(key_file), passphrase)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def load_private_key(key, pwd = nil)
|
|
261
|
+
return key if key.is_a?(OpenSSL::PKey::PKey)
|
|
262
|
+
|
|
263
|
+
OpenSSL::PKey.read(key, pwd)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../errors'
|
|
4
|
+
module MQTT
|
|
5
|
+
module Core
|
|
6
|
+
# Base subscription error
|
|
7
|
+
class SubscriptionError < ResponseError
|
|
8
|
+
# @!attribute [r] errors
|
|
9
|
+
# @return [Hash<String,ReasonCode|ResultCode] Map of topic_filter to failure code
|
|
10
|
+
def initialize(failed_filters)
|
|
11
|
+
msg = ["#{self::NAME} failed for #{failed_filters.size} topics"] +
|
|
12
|
+
failed_filters.map { |topic_filter, status| "#{topic_filter}(#{status})" }
|
|
13
|
+
super(msg.join("\n\t"))
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class SubscribeError < SubscriptionError
|
|
18
|
+
NAME = 'Subscribe'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class UnsubscribeError < SubscriptionError
|
|
22
|
+
NAME = 'Unsubscribe'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class Client
|
|
26
|
+
Subscription = Data.define(:sub_packet, :ack_packet, :handler, :client)
|
|
27
|
+
|
|
28
|
+
# Base subscription for handling received messages
|
|
29
|
+
class Subscription < Data
|
|
30
|
+
# @!attribute [r] sub_packet
|
|
31
|
+
# @return [Packet] the `SUBSCRIBE` packet
|
|
32
|
+
|
|
33
|
+
# @!attribute [r] ack_packet
|
|
34
|
+
# @return [Packet] the `SUBACK` packet
|
|
35
|
+
|
|
36
|
+
# Classify `SUBACK` results
|
|
37
|
+
# @return [Hash<String,Symbol>]
|
|
38
|
+
# Map pf filter to acknowledged status. version-specific.
|
|
39
|
+
#
|
|
40
|
+
# @see Packet::Subscribe#filter_status
|
|
41
|
+
def filter_status
|
|
42
|
+
sub_packet.filter_status(ack_packet)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Deregister this Subscription from its client and unsubscribe its topics from the server.
|
|
46
|
+
# @param [Hash<Symbol>] unsubscribe additional properties for the `UNSUBSCRIBE` packet
|
|
47
|
+
# @note this will also terminate the current enumeration.
|
|
48
|
+
def unsubscribe(**unsubscribe)
|
|
49
|
+
client.delete_subscription(self, **unsubscribe, **unsubscribe_params)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Yield self, ensuring {#unsubscribe}
|
|
53
|
+
# @return [Object] the result of the block
|
|
54
|
+
def with!
|
|
55
|
+
yield self
|
|
56
|
+
ensure
|
|
57
|
+
unsubscribe
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Yield self, returning self, ensuring {#unsubscribe}
|
|
61
|
+
# @yieldreturn [void]
|
|
62
|
+
# @return [self]
|
|
63
|
+
def tap!(&)
|
|
64
|
+
tap { with!(&) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @!visibility private
|
|
68
|
+
# Match messages
|
|
69
|
+
def ===(other)
|
|
70
|
+
sub_packet === other # rubocop:disable Style/CaseEquality
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @!visibility private
|
|
74
|
+
def resubscribe_topic_filters
|
|
75
|
+
sub_packet.resubscribe_topic_filters(ack_packet)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Successfully subscribed topic filters
|
|
79
|
+
# @return [Array<String>]
|
|
80
|
+
def subscribed_topic_filters
|
|
81
|
+
sub_packet.subscribed_topic_filters(ack_packet)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @!visibility private
|
|
85
|
+
# called from a client when a message has arrived
|
|
86
|
+
def put(packet)
|
|
87
|
+
handle(packet, &handler) unless packet.is_a?(StandardError)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @!visibility private
|
|
91
|
+
def match?(publish_packet)
|
|
92
|
+
sub_packet.match?(publish_packet)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def unsubscribe_params
|
|
98
|
+
sub_packet.unsubscribe_params(ack_packet)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def handle(packet)
|
|
102
|
+
raise packet if packet.is_a?(StandardError)
|
|
103
|
+
|
|
104
|
+
(block_given? ? yield(packet) : packet).tap { client.handled!(packet) if packet&.qos&.positive? }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module URI
|
|
6
|
+
# MQTT over TCP
|
|
7
|
+
# @see https://github.com/mqtt/mqtt.github.io/wiki/URI-Scheme
|
|
8
|
+
class MQTT < ::URI::Generic
|
|
9
|
+
DEFAULT_PORT = 1883
|
|
10
|
+
|
|
11
|
+
# Options for MQTT URI new_io method
|
|
12
|
+
IO_OPTS = %i[connect_timeout resolv_timeout tcp_nodelay].freeze
|
|
13
|
+
|
|
14
|
+
def require_deps
|
|
15
|
+
require 'socket'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Create a TCP socket connection to the MQTT broker
|
|
19
|
+
# @param local_args [Array] Optional local_host and local_port for binding
|
|
20
|
+
# @param connect_timeout [Numeric, nil] Timeout in seconds for connection establishment
|
|
21
|
+
# @param resolv_timeout [Numeric, nil] Timeout in seconds for DNS resolution (defaults to connect_timeout)
|
|
22
|
+
# @param tcp_nodelay [Boolean] Enable TCP_NODELAY to avoid waiting to coalesce packets (default: true)
|
|
23
|
+
# @return [TCPSocket]
|
|
24
|
+
def to_io(*local_args, connect_timeout: nil, resolv_timeout: connect_timeout, tcp_nodelay: true)
|
|
25
|
+
TCPSocket.new(host, port, *local_args, connect_timeout:, resolv_timeout:).tap do |socket|
|
|
26
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if tcp_nodelay
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# MQTT over TLS
|
|
32
|
+
# @see https://github.com/mqtt/mqtt.github.io/wiki/URI-Scheme
|
|
33
|
+
class MQTTS < MQTT
|
|
34
|
+
DEFAULT_PORT = 8883
|
|
35
|
+
|
|
36
|
+
def require_deps
|
|
37
|
+
require_relative '../../../patches/openssl'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Create a TLS-encrypted socket connection to the MQTT broker
|
|
41
|
+
# @param local_args [Array] Optional local_host and local_port for binding
|
|
42
|
+
# @param ssl_context [OpenSSL::SSL::SSLContext] Pre-configured SSL context (required)
|
|
43
|
+
# @param connect_timeout [Numeric, nil] Timeout in seconds for connection establishment and SSL handshake
|
|
44
|
+
# @param tcp_args [Hash] Additional TCP options (see {MQTT#to_io})
|
|
45
|
+
# @return [OpenSSL::SSL::SSLSocket]
|
|
46
|
+
def to_io(*local_args, ssl_context:, connect_timeout: nil, **tcp_args)
|
|
47
|
+
tcp = super(*local_args, connect_timeout:, **tcp_args)
|
|
48
|
+
tcp.timeout = connect_timeout if connect_timeout
|
|
49
|
+
OpenSSL::SSL::SSLSocket.new(tcp, ssl_context).tap do |ssl_socket|
|
|
50
|
+
ssl_socket.sync_close = true
|
|
51
|
+
ssl_socket.hostname = host # For SNI validation if requested
|
|
52
|
+
ssl_socket.connect
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# MQTT over Unix domain socket
|
|
58
|
+
# @example
|
|
59
|
+
# URI('unix:///var/run/mosquitto.sock')
|
|
60
|
+
class Unix < ::URI::Generic
|
|
61
|
+
DEFAULT_PORT = nil
|
|
62
|
+
|
|
63
|
+
def require_deps
|
|
64
|
+
require 'socket'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Create a Unix domain socket connection
|
|
68
|
+
# @return [UNIXSocket]
|
|
69
|
+
def to_io(...)
|
|
70
|
+
UNIXSocket.new(path)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
URI.register_scheme 'mqtt', URI::MQTT
|
|
76
|
+
URI.register_scheme 'mqtts', URI::MQTTS
|
|
77
|
+
URI.register_scheme 'unix', URI::Unix
|