zzq 0.1.0

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.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/queue"
4
+ require "async/limited_queue"
5
+
6
+ module ZZQ
7
+ # Handle returned by {Client#subscribe} and {Broker#subscribe}. A
8
+ # thin wrapper around an +Async::LimitedQueue+ that also owns the
9
+ # UNSUBSCRIBE lifecycle.
10
+ #
11
+ # Callers consume with {#pop} or {#each}; {#close} sends UNSUBSCRIBE
12
+ # and wakes any blocked consumer with +nil+.
13
+ #
14
+ # Created internally — users should never call +.new+ directly.
15
+ class Subscription
16
+ # @return [Array<String>] topic filters this subscription matches.
17
+ attr_reader :filters
18
+
19
+ # @return [Integer] subscription identifier (v5) or nil (v3).
20
+ attr_reader :subscription_id
21
+
22
+
23
+ def initialize(filters:, hwm:, subscription_id: nil, on_close: nil)
24
+ @filters = Array(filters).freeze
25
+ @subscription_id = subscription_id
26
+ @queue = Async::LimitedQueue.new(hwm)
27
+ @on_close = on_close
28
+ @closed = false
29
+ end
30
+
31
+
32
+ # Blocks the fiber until a message arrives. Returns +nil+ when
33
+ # the subscription is closed.
34
+ def pop
35
+ return nil if @closed && @queue.empty?
36
+ @queue.dequeue
37
+ end
38
+
39
+
40
+ # Yields each message until the subscription is closed.
41
+ def each
42
+ return enum_for(:each) unless block_given?
43
+ while (msg = pop)
44
+ yield msg
45
+ end
46
+ end
47
+
48
+
49
+ # Sends UNSUBSCRIBE and wakes the consumer. Idempotent.
50
+ def close
51
+ return if @closed
52
+ @closed = true
53
+ @on_close&.call(self)
54
+ @queue.enqueue(nil)
55
+ end
56
+
57
+
58
+ def closed?
59
+ @closed
60
+ end
61
+
62
+
63
+ # Internal: deliver a message to the subscriber's queue. Blocks
64
+ # the caller if the queue is at high-water-mark.
65
+ def deliver(message)
66
+ return if @closed
67
+ @queue.enqueue(message)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "io/stream"
5
+ require_relative "../engine"
6
+
7
+ module ZZQ
8
+ module Transport
9
+ # IPC transport using Unix domain sockets.
10
+ #
11
+ # Endpoint forms:
12
+ # * `ipc:///var/run/zzq.sock` — filesystem path (portable).
13
+ # * `ipc://@zzq-local` — Linux abstract namespace (the
14
+ # leading `@` becomes a NUL byte in sockaddr.sun_path).
15
+ module IPC
16
+ Engine.transports["ipc"] = self
17
+
18
+
19
+ class << self
20
+ def bind(endpoint, engine, **)
21
+ path = parse_path(endpoint)
22
+ sock_path = to_socket_path(path)
23
+
24
+ File.delete(sock_path) if !abstract?(path) && File.exist?(sock_path)
25
+
26
+ server = UNIXServer.new(sock_path)
27
+ Listener.new(endpoint, server, path, engine)
28
+ end
29
+
30
+
31
+ def connect(endpoint, engine, **)
32
+ path = parse_path(endpoint)
33
+ sock_path = to_socket_path(path)
34
+ sock = UNIXSocket.new(sock_path)
35
+ engine.handle_connected(IO::Stream::Buffered.wrap(sock), endpoint: endpoint)
36
+ end
37
+
38
+
39
+ def parse_path(endpoint)
40
+ endpoint.sub(%r{\Aipc://}, "")
41
+ end
42
+
43
+
44
+ # Linux abstract-namespace Unix sockets: leading `@` becomes a
45
+ # NUL byte in sun_path. Only supported on Linux.
46
+ def to_socket_path(path)
47
+ abstract?(path) ? "\0#{path[1..]}" : path
48
+ end
49
+
50
+
51
+ def abstract?(path)
52
+ path.start_with?("@")
53
+ end
54
+ end
55
+
56
+
57
+ class Listener
58
+ attr_reader :endpoint
59
+
60
+
61
+ def initialize(endpoint, server, path, engine)
62
+ @endpoint = endpoint
63
+ @server = server
64
+ @path = path
65
+ @engine = engine
66
+ @task = nil
67
+ end
68
+
69
+
70
+ def start_accept_loop(parent_task, &on_accepted)
71
+ @task = parent_task.async(annotation: "zzq ipc accept #{@endpoint}") do
72
+ loop do
73
+ client = @server.accept
74
+ on_accepted.call(IO::Stream::Buffered.wrap(client))
75
+ rescue Async::Stop, IOError
76
+ break
77
+ end
78
+ ensure
79
+ @server.close rescue nil
80
+ end
81
+ end
82
+
83
+
84
+ def stop
85
+ @task&.stop
86
+ @server.close rescue nil
87
+ File.delete(@path) rescue nil unless IPC.abstract?(@path)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "uri"
5
+ require "io/stream"
6
+ require_relative "../engine"
7
+
8
+ module ZZQ
9
+ module Transport
10
+ # Plain MQTT-over-TCP transport. Endpoint scheme: `mqtt://host:port`
11
+ # (default port 1883). One server per bind, blocking accept inside
12
+ # an Async fiber.
13
+ module MQTT
14
+ Engine.transports["mqtt"] = self
15
+
16
+
17
+ class << self
18
+ def bind(endpoint, engine, **)
19
+ host, port = parse_endpoint(endpoint)
20
+ host = "0.0.0.0" if host == "*"
21
+ server = TCPServer.new(host, port)
22
+ actual = server.local_address.ip_port
23
+ host_part = host.include?(":") ? "[#{host}]" : host
24
+ Listener.new("mqtt://#{host_part}:#{actual}", server, actual, engine)
25
+ end
26
+
27
+
28
+ def connect(endpoint, engine, **)
29
+ host, port = parse_endpoint(endpoint)
30
+ sock = ::Socket.tcp(host, port, connect_timeout: connect_timeout(engine.options))
31
+ engine.handle_connected(IO::Stream::Buffered.wrap(sock), endpoint: endpoint)
32
+ end
33
+
34
+
35
+ def connect_timeout(options)
36
+ ri = options.reconnect_interval
37
+ ri = ri.end if ri.is_a?(Range)
38
+ [ri, 0.5].max
39
+ end
40
+
41
+
42
+ def parse_endpoint(endpoint)
43
+ uri = URI.parse(endpoint.sub("mqtt://", "tcp://"))
44
+ [uri.hostname, uri.port]
45
+ end
46
+ end
47
+
48
+
49
+ class Listener
50
+ attr_reader :endpoint, :port
51
+
52
+
53
+ def initialize(endpoint, server, port, engine)
54
+ @endpoint = endpoint
55
+ @server = server
56
+ @port = port
57
+ @engine = engine
58
+ @task = nil
59
+ end
60
+
61
+
62
+ def start_accept_loop(parent_task, &on_accepted)
63
+ @task = parent_task.async(annotation: "zzq mqtt accept #{@endpoint}") do
64
+ loop do
65
+ client = @server.accept
66
+ on_accepted.call(IO::Stream::Buffered.wrap(client))
67
+ rescue Async::Stop, IOError
68
+ break
69
+ end
70
+ ensure
71
+ @server.close rescue nil
72
+ end
73
+ end
74
+
75
+
76
+ def stop
77
+ @task&.stop
78
+ @server.close rescue nil
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "openssl"
5
+ require "uri"
6
+ require "io/stream"
7
+ require_relative "../engine"
8
+
9
+ module ZZQ
10
+ module Transport
11
+ # MQTT-over-TLS transport — endpoint scheme `mqtts://host:port`
12
+ # (default port 8883).
13
+ #
14
+ # Both bind and connect accept a `tls_context:` option — any
15
+ # +OpenSSL::SSL::SSLContext+. On bind the context must carry a
16
+ # cert + key; on connect a default client context is used if
17
+ # none is given. The transport does not constrain the context;
18
+ # see the README for a recommended TLS 1.3 +
19
+ # `TLS_CHACHA20_POLY1305_SHA256` configuration.
20
+ #
21
+ # Per-connection application data is capped at
22
+ # {DATA_LIMIT} bytes, after which the socket is torn down and
23
+ # the peer must reconnect. This keeps AEAD nonce counters well
24
+ # below their unsafe bound (e.g. ChaCha20-Poly1305's ~256 GiB
25
+ # per-key integrity bound) without relying on a specific cipher
26
+ # choice.
27
+ module MQTTS
28
+ Engine.transports["mqtts"] = self
29
+
30
+ # 128 GiB — conservative for every AEAD in TLS 1.3 (ChaCha20-
31
+ # Poly1305 at ~256 GiB, AES-GCM limits are higher under TLS 1.3
32
+ # record sizes). Reconnection negotiates fresh keys, so this
33
+ # caps nonce reuse exposure regardless of cipher choice.
34
+ DATA_LIMIT = 128 * 1024 * 1024 * 1024
35
+
36
+
37
+ class << self
38
+ def bind(endpoint, engine, tls_context: nil, **)
39
+ raise Error, "mqtts:// bind requires tls_context:" unless tls_context
40
+
41
+ host, port = parse_endpoint(endpoint)
42
+ host = "0.0.0.0" if host == "*"
43
+ server = TCPServer.new(host, port)
44
+ actual = server.local_address.ip_port
45
+ host_part = host.include?(":") ? "[#{host}]" : host
46
+ Listener.new("mqtts://#{host_part}:#{actual}", server, tls_context, engine)
47
+ end
48
+
49
+
50
+ def connect(endpoint, engine, tls_context: nil, **)
51
+ host, port = parse_endpoint(endpoint)
52
+ ctx = tls_context || OpenSSL::SSL::SSLContext.new
53
+ tcp = ::Socket.tcp(host, port, connect_timeout: connect_timeout(engine.options))
54
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
55
+ ssl.hostname = host # SNI
56
+ ssl.sync_close = true
57
+ ssl.connect
58
+ engine.handle_connected(LimitedStream.wrap(ssl), endpoint: endpoint)
59
+ end
60
+
61
+
62
+ def connect_timeout(options)
63
+ ri = options.reconnect_interval
64
+ ri = ri.end if ri.is_a?(Range)
65
+ [ri, 0.5].max
66
+ end
67
+
68
+
69
+ def parse_endpoint(endpoint)
70
+ uri = URI.parse(endpoint.sub("mqtts://", "tcp://"))
71
+ [uri.hostname, uri.port]
72
+ end
73
+ end
74
+
75
+
76
+ class Listener
77
+ attr_reader :endpoint
78
+
79
+
80
+ def initialize(endpoint, server, tls_context, engine)
81
+ @endpoint = endpoint
82
+ @server = server
83
+ @ctx = tls_context
84
+ @engine = engine
85
+ @task = nil
86
+ end
87
+
88
+
89
+ def start_accept_loop(parent_task, &on_accepted)
90
+ @task = parent_task.async(annotation: "zzq mqtts accept #{@endpoint}") do |task|
91
+ loop do
92
+ client = @server.accept
93
+ # TLS handshake per-connection, off the accept loop, so
94
+ # one slow handshake can't block the next client.
95
+ task.async(annotation: "zzq mqtts handshake") do
96
+ ssl = OpenSSL::SSL::SSLSocket.new(client, @ctx)
97
+ ssl.sync_close = true
98
+ ssl.accept
99
+ on_accepted.call(LimitedStream.wrap(ssl))
100
+ rescue Async::Stop, IOError, OpenSSL::SSL::SSLError
101
+ ssl&.close rescue nil
102
+ client.close rescue nil
103
+ end
104
+ rescue Async::Stop, IOError
105
+ break
106
+ end
107
+ ensure
108
+ @server.close rescue nil
109
+ end
110
+ end
111
+
112
+
113
+ def stop
114
+ @task&.stop
115
+ @server.close rescue nil
116
+ end
117
+ end
118
+
119
+
120
+ # Buffered stream with a per-connection byte cap. When the cap
121
+ # is reached on either direction the underlying socket is
122
+ # closed and the next I/O raises — forcing the peer to
123
+ # reconnect and negotiate fresh keys before ChaCha20-Poly1305's
124
+ # nonce counter approaches its unsafe range.
125
+ class LimitedStream < IO::Stream::Buffered
126
+ def self.wrap(io, limit: DATA_LIMIT)
127
+ if io.respond_to?(:buffered=)
128
+ io.buffered = false
129
+ elsif io.respond_to?(:sync=)
130
+ io.sync = true
131
+ end
132
+ new(io, limit)
133
+ end
134
+
135
+
136
+ def initialize(io, limit = DATA_LIMIT)
137
+ super(io)
138
+ @limit = limit
139
+ @bytes = 0
140
+ end
141
+
142
+
143
+ protected
144
+
145
+
146
+ def syswrite(buffer)
147
+ enforce!(buffer.bytesize)
148
+ super
149
+ end
150
+
151
+
152
+ def sysread(size, buffer)
153
+ result = super
154
+ enforce!(result.bytesize) if result.is_a?(String)
155
+ result
156
+ end
157
+
158
+
159
+ private
160
+
161
+
162
+ def enforce!(n)
163
+ @bytes += n
164
+ return if @bytes < @limit
165
+
166
+ close rescue nil
167
+ raise ZZQ::Error,
168
+ "mqtts:// data limit reached (#{@limit} B): dropping connection to force fresh keys"
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZZQ
4
+ VERSION = "0.1.0"
5
+ end
data/lib/zzq.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ZZQ — async-native MQTT client and broker for Ruby.
4
+ #
5
+ # Built on `async`, `io-stream`, and `protocol-mqtt`. Applies the
6
+ # omq/nnq architecture (Socket base, Engine, pluggable transports,
7
+ # barrier-scoped lifecycle) to MQTT 3.1.1 and 5.0.
8
+ module ZZQ
9
+ end
10
+
11
+ require_relative "zzq/version"
12
+ require_relative "zzq/error"
13
+ require_relative "zzq/monitor_event"
14
+ require_relative "zzq/options"
15
+ require_relative "zzq/message"
16
+ require_relative "zzq/subscription"
17
+ require_relative "zzq/engine"
18
+ require_relative "zzq/socket"
19
+ require_relative "zzq/transport/mqtt"
20
+ require_relative "zzq/transport/ipc"
21
+ require_relative "zzq/transport/mqtts"
22
+ require_relative "zzq/persistence/interface"
23
+ require_relative "zzq/persistence/memory"
24
+ require_relative "zzq/persistence/pstore"
25
+ require_relative "zzq/client"
26
+ require_relative "zzq/broker"
metadata ADDED
@@ -0,0 +1,128 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: zzq
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Patrik Wenger
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: async
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.38'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.38'
26
+ - !ruby/object:Gem::Dependency
27
+ name: io-stream
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.11'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.11'
40
+ - !ruby/object:Gem::Dependency
41
+ name: protocol-mqtt
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 0.1.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 0.1.0
54
+ - !ruby/object:Gem::Dependency
55
+ name: pstore
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.2'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.2'
68
+ description: Pure Ruby implementation of MQTT — client and broker in one gem — built
69
+ on async, io-stream, and protocol-mqtt. Fiber-first, pluggable transports (tcp,
70
+ tls, ws, ipc), pluggable persistence (in-memory, PStore).
71
+ email:
72
+ - paddor@gmail.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - CHANGELOG.md
78
+ - LICENSE
79
+ - README.md
80
+ - lib/zzq.rb
81
+ - lib/zzq/broker.rb
82
+ - lib/zzq/client.rb
83
+ - lib/zzq/engine.rb
84
+ - lib/zzq/engine/connection_lifecycle.rb
85
+ - lib/zzq/engine/keepalive.rb
86
+ - lib/zzq/engine/socket_lifecycle.rb
87
+ - lib/zzq/error.rb
88
+ - lib/zzq/message.rb
89
+ - lib/zzq/monitor_event.rb
90
+ - lib/zzq/options.rb
91
+ - lib/zzq/persistence/interface.rb
92
+ - lib/zzq/persistence/memory.rb
93
+ - lib/zzq/persistence/pstore.rb
94
+ - lib/zzq/routing/broker.rb
95
+ - lib/zzq/routing/client.rb
96
+ - lib/zzq/routing/packet_id_allocator.rb
97
+ - lib/zzq/routing/qos_tracker.rb
98
+ - lib/zzq/routing/retained_store.rb
99
+ - lib/zzq/routing/topic_match.rb
100
+ - lib/zzq/routing/topic_trie.rb
101
+ - lib/zzq/socket.rb
102
+ - lib/zzq/subscription.rb
103
+ - lib/zzq/transport/ipc.rb
104
+ - lib/zzq/transport/mqtt.rb
105
+ - lib/zzq/transport/mqtts.rb
106
+ - lib/zzq/version.rb
107
+ homepage: https://github.com/paddor/zzq
108
+ licenses:
109
+ - ISC
110
+ metadata: {}
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '4.0'
119
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ requirements: []
125
+ rubygems_version: 4.0.6
126
+ specification_version: 4
127
+ summary: Pure Ruby MQTT 3.1.1 + 5.0 client and broker on Async
128
+ test_files: []