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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/lib/mqtt/core/client/acknowledgement.rb +31 -0
  3. data/lib/mqtt/core/client/client_id_generator.rb +19 -0
  4. data/lib/mqtt/core/client/connection.rb +193 -0
  5. data/lib/mqtt/core/client/enumerable_subscription.rb +224 -0
  6. data/lib/mqtt/core/client/filesystem_session_store.rb +197 -0
  7. data/lib/mqtt/core/client/memory_session_store.rb +84 -0
  8. data/lib/mqtt/core/client/qos0_session_store.rb +56 -0
  9. data/lib/mqtt/core/client/qos2_session_store.rb +45 -0
  10. data/lib/mqtt/core/client/qos_tracker.rb +119 -0
  11. data/lib/mqtt/core/client/retry_strategy.rb +63 -0
  12. data/lib/mqtt/core/client/session.rb +195 -0
  13. data/lib/mqtt/core/client/session_store.rb +128 -0
  14. data/lib/mqtt/core/client/socket_factory.rb +268 -0
  15. data/lib/mqtt/core/client/subscription.rb +109 -0
  16. data/lib/mqtt/core/client/uri.rb +77 -0
  17. data/lib/mqtt/core/client.rb +700 -0
  18. data/lib/mqtt/core/packet/connect.rb +39 -0
  19. data/lib/mqtt/core/packet/publish.rb +45 -0
  20. data/lib/mqtt/core/packet/subscribe.rb +190 -0
  21. data/lib/mqtt/core/packet/unsubscribe.rb +21 -0
  22. data/lib/mqtt/core/packet.rb +168 -0
  23. data/lib/mqtt/core/type/binary.rb +35 -0
  24. data/lib/mqtt/core/type/bit_flags.rb +89 -0
  25. data/lib/mqtt/core/type/boolean_byte.rb +48 -0
  26. data/lib/mqtt/core/type/fixed_int.rb +43 -0
  27. data/lib/mqtt/core/type/list.rb +41 -0
  28. data/lib/mqtt/core/type/password.rb +36 -0
  29. data/lib/mqtt/core/type/properties.rb +124 -0
  30. data/lib/mqtt/core/type/reason_codes.rb +60 -0
  31. data/lib/mqtt/core/type/remaining.rb +30 -0
  32. data/lib/mqtt/core/type/shape.rb +177 -0
  33. data/lib/mqtt/core/type/sub_type.rb +34 -0
  34. data/lib/mqtt/core/type/utf8_string.rb +39 -0
  35. data/lib/mqtt/core/type/utf8_string_pair.rb +29 -0
  36. data/lib/mqtt/core/type/var_int.rb +56 -0
  37. data/lib/mqtt/core/version.rb +10 -0
  38. data/lib/mqtt/core.rb +5 -0
  39. data/lib/mqtt/errors.rb +39 -0
  40. data/lib/mqtt/logger.rb +92 -0
  41. data/lib/mqtt/open.rb +239 -0
  42. data/lib/mqtt/options.rb +59 -0
  43. data/lib/mqtt/version.rb +6 -0
  44. data/lib/mqtt.rb +3 -0
  45. data/lib/patches/openssl.rb +19 -0
  46. 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