omq 0.7.0 → 0.9.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.
@@ -1,210 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module OMQ
4
- module ZMTP
5
- module Codec
6
- # ZMTP command encode/decode.
7
- #
8
- # Command frame body format:
9
- # 1 byte: command name length
10
- # N bytes: command name
11
- # remaining: command data
12
- #
13
- # READY command data = property list:
14
- # 1 byte: property name length
15
- # N bytes: property name
16
- # 4 bytes: property value length (big-endian)
17
- # N bytes: property value
18
- #
19
- class Command
20
- # @return [String] command name (e.g. "READY", "SUBSCRIBE")
21
- #
22
- attr_reader :name
23
-
24
- # @return [String] command data (binary)
25
- #
26
- attr_reader :data
27
-
28
- # @param name [String] command name
29
- # @param data [String] command data
30
- #
31
- def initialize(name, data = "".b)
32
- @name = name
33
- @data = data.b
34
- end
35
-
36
- # Encodes as a command frame body.
37
- #
38
- # @return [String] binary body (name-length + name + data)
39
- #
40
- def to_body
41
- name_bytes = @name.b
42
- name_bytes.bytesize.chr.b + name_bytes + @data
43
- end
44
-
45
- # Encodes as a complete command Frame.
46
- #
47
- # @return [Frame]
48
- #
49
- def to_frame
50
- Frame.new(to_body, command: true)
51
- end
52
-
53
- # Decodes a command from a frame body.
54
- #
55
- # @param body [String] binary frame body
56
- # @return [Command]
57
- # @raise [ProtocolError] on malformed command
58
- #
59
- def self.from_body(body)
60
- body = body.b
61
- raise ProtocolError, "command body too short" if body.bytesize < 1
62
-
63
- name_len = body.getbyte(0)
64
-
65
- raise ProtocolError, "command name truncated" if body.bytesize < 1 + name_len
66
-
67
- name = body.byteslice(1, name_len)
68
- data = body.byteslice(1 + name_len..)
69
- new(name, data)
70
- end
71
-
72
- # Builds a READY command with Socket-Type and Identity properties.
73
- #
74
- # @param socket_type [String] e.g. "REQ", "REP", "PAIR"
75
- # @param identity [String] peer identity (can be empty)
76
- # @return [Command]
77
- #
78
- def self.ready(socket_type:, identity: "")
79
- props = encode_properties(
80
- "Socket-Type" => socket_type,
81
- "Identity" => identity,
82
- )
83
- new("READY", props)
84
- end
85
-
86
- # Builds a SUBSCRIBE command.
87
- #
88
- # @param prefix [String] subscription prefix
89
- # @return [Command]
90
- #
91
- def self.subscribe(prefix)
92
- new("SUBSCRIBE", prefix.b)
93
- end
94
-
95
- # Builds a CANCEL command (unsubscribe).
96
- #
97
- # @param prefix [String] subscription prefix to cancel
98
- # @return [Command]
99
- #
100
- def self.cancel(prefix)
101
- new("CANCEL", prefix.b)
102
- end
103
-
104
- # Builds a JOIN command (RADIO/DISH group subscription).
105
- #
106
- # @param group [String] group name
107
- # @return [Command]
108
- #
109
- def self.join(group)
110
- new("JOIN", group.b)
111
- end
112
-
113
- # Builds a LEAVE command (RADIO/DISH group unsubscription).
114
- #
115
- # @param group [String] group name
116
- # @return [Command]
117
- #
118
- def self.leave(group)
119
- new("LEAVE", group.b)
120
- end
121
-
122
- # Builds a PING command.
123
- #
124
- # @param ttl [Numeric] time-to-live in seconds (sent as deciseconds)
125
- # @param context [String] optional context bytes (up to 16 bytes)
126
- # @return [Command]
127
- #
128
- def self.ping(ttl: 0, context: "".b)
129
- ttl_ds = (ttl * 10).to_i
130
- new("PING", [ttl_ds].pack("n") + context.b)
131
- end
132
-
133
- # Builds a PONG command.
134
- #
135
- # @param context [String] context bytes from the PING
136
- # @return [Command]
137
- #
138
- def self.pong(context: "".b)
139
- new("PONG", context.b)
140
- end
141
-
142
- # Extracts TTL (in seconds) and context from a PING command's data.
143
- #
144
- # @return [Array(Numeric, String)] [ttl_seconds, context_bytes]
145
- #
146
- def ping_ttl_and_context
147
- ttl_ds = @data.unpack1("n")
148
- context = @data.bytesize > 2 ? @data.byteslice(2..) : "".b
149
- [ttl_ds / 10.0, context]
150
- end
151
-
152
- # Parses READY command data as a property list.
153
- #
154
- # @return [Hash<String, String>] property name => value
155
- # @raise [ProtocolError] on malformed properties
156
- #
157
- def properties
158
- self.class.decode_properties(@data)
159
- end
160
-
161
- # Encodes a hash of properties into ZMTP property list format.
162
- #
163
- # @param props [Hash<String, String>]
164
- # @return [String] binary property list
165
- #
166
- def self.encode_properties(props)
167
- parts = props.map do |name, value|
168
- name_bytes = name.b
169
- value_bytes = value.b
170
- name_bytes.bytesize.chr.b + name_bytes + [value_bytes.bytesize].pack("N") + value_bytes
171
- end
172
- parts.join
173
- end
174
-
175
- # Decodes a ZMTP property list from binary data.
176
- #
177
- # @param data [String] binary property list
178
- # @return [Hash<String, String>] property name => value
179
- # @raise [ProtocolError] on malformed properties
180
- #
181
- def self.decode_properties(data)
182
- result = {}
183
- offset = 0
184
-
185
- while offset < data.bytesize
186
- raise ProtocolError, "property name truncated" if offset + 1 > data.bytesize
187
- name_len = data.getbyte(offset)
188
- offset += 1
189
-
190
- raise ProtocolError, "property name truncated" if offset + name_len > data.bytesize
191
- name = data.byteslice(offset, name_len)
192
- offset += name_len
193
-
194
- raise ProtocolError, "property value length truncated" if offset + 4 > data.bytesize
195
- value_len = data.byteslice(offset, 4).unpack1("N")
196
- offset += 4
197
-
198
- raise ProtocolError, "property value truncated" if offset + value_len > data.bytesize
199
- value = data.byteslice(offset, value_len)
200
- offset += value_len
201
-
202
- result[name] = value
203
- end
204
-
205
- result
206
- end
207
- end
208
- end
209
- end
210
- end
@@ -1,89 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module OMQ
4
- module ZMTP
5
- module Codec
6
- # ZMTP frame encode/decode.
7
- #
8
- # Wire format:
9
- # Byte 0: flags (bit 0=MORE, bit 1=LONG, bit 2=COMMAND)
10
- # Next 1-8: size (1-byte if short, 8-byte big-endian if LONG)
11
- # Next N: body
12
- #
13
- class Frame
14
- FLAGS_MORE = 0x01
15
- FLAGS_LONG = 0x02
16
- FLAGS_COMMAND = 0x04
17
-
18
- # Short frame: 1-byte size, max body 255 bytes.
19
- #
20
- SHORT_MAX = 255
21
-
22
- # @return [String] frame body (binary)
23
- #
24
- attr_reader :body
25
-
26
- # @param body [String] frame body
27
- # @param more [Boolean] more frames follow
28
- # @param command [Boolean] this is a command frame
29
- #
30
- def initialize(body, more: false, command: false)
31
- @body = body.b
32
- @more = more
33
- @command = command
34
- end
35
-
36
- # @return [Boolean] true if more frames follow in this message
37
- #
38
- def more? = @more
39
-
40
- # @return [Boolean] true if this is a command frame
41
- #
42
- def command? = @command
43
-
44
- # Encodes to wire bytes.
45
- #
46
- # @return [String] binary wire representation (flags + size + body)
47
- #
48
- def to_wire
49
- size = @body.bytesize
50
- flags = 0
51
- flags |= FLAGS_MORE if @more
52
- flags |= FLAGS_COMMAND if @command
53
-
54
- if size > SHORT_MAX
55
- (flags | FLAGS_LONG).chr.b + [size].pack("Q>") + @body
56
- else
57
- flags.chr.b + size.chr.b + @body
58
- end
59
- end
60
-
61
- # Reads one frame from an IO-like object.
62
- #
63
- # @param io [#read_exactly] must support read_exactly(n)
64
- # @return [Frame]
65
- # @raise [ProtocolError] on invalid frame
66
- # @raise [EOFError] if the connection is closed
67
- #
68
- def self.read_from(io)
69
- flags = io.read_exactly(1).getbyte(0)
70
-
71
- more = (flags & FLAGS_MORE) != 0
72
- long = (flags & FLAGS_LONG) != 0
73
- command = (flags & FLAGS_COMMAND) != 0
74
-
75
- size = if long
76
- io.read_exactly(8).unpack1("Q>")
77
- else
78
- io.read_exactly(1).getbyte(0)
79
- end
80
-
81
- body = size > 0 ? io.read_exactly(size) : "".b
82
-
83
- new(body, more: more, command: command)
84
- end
85
-
86
- end
87
- end
88
- end
89
- end
@@ -1,78 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module OMQ
4
- module ZMTP
5
- module Codec
6
- # ZMTP 3.1 greeting encode/decode.
7
- #
8
- # The greeting is always exactly 64 bytes:
9
- # Offset Bytes Field
10
- # 0 1 0xFF (signature start)
11
- # 1-8 8 0x00 padding
12
- # 9 1 0x7F (signature end)
13
- # 10 1 major version
14
- # 11 1 minor version
15
- # 12-31 20 mechanism (null-padded ASCII)
16
- # 32 1 as-server flag (0x00 or 0x01)
17
- # 33-63 31 filler (0x00)
18
- #
19
- module Greeting
20
- SIZE = 64
21
- SIGNATURE_START = 0xFF
22
- SIGNATURE_END = 0x7F
23
- VERSION_MAJOR = 3
24
- VERSION_MINOR = 1
25
- MECHANISM_OFFSET = 12
26
- MECHANISM_LENGTH = 20
27
- AS_SERVER_OFFSET = 32
28
-
29
- # Encodes a ZMTP 3.1 greeting.
30
- #
31
- # @param mechanism [String] security mechanism name (e.g. "NULL")
32
- # @param as_server [Boolean] whether this peer is the server
33
- # @return [String] 64-byte binary greeting
34
- #
35
- def self.encode(mechanism: "NULL", as_server: false)
36
- buf = "\xFF".b + ("\x00" * 8) + "\x7F".b
37
- buf << [VERSION_MAJOR, VERSION_MINOR].pack("CC")
38
- buf << mechanism.b.ljust(MECHANISM_LENGTH, "\x00")
39
- buf << (as_server ? "\x01" : "\x00")
40
- buf << ("\x00" * 31)
41
- end
42
-
43
- # Decodes a ZMTP greeting.
44
- #
45
- # @param data [String] 64-byte binary greeting
46
- # @return [Hash] { major:, minor:, mechanism:, as_server: }
47
- # @raise [ProtocolError] on invalid greeting
48
- #
49
- def self.decode(data)
50
- raise ProtocolError, "greeting too short (#{data.bytesize} bytes)" if data.bytesize < SIZE
51
-
52
- data = data.b
53
-
54
- unless data.getbyte(0) == SIGNATURE_START && data.getbyte(9) == SIGNATURE_END
55
- raise ProtocolError, "invalid greeting signature"
56
- end
57
-
58
- major = data.getbyte(10)
59
- minor = data.getbyte(11)
60
-
61
- unless major >= 3
62
- raise ProtocolError, "unsupported ZMTP version #{major}.#{minor} (need >= 3.0)"
63
- end
64
-
65
- mechanism = data.byteslice(MECHANISM_OFFSET, MECHANISM_LENGTH).delete("\x00")
66
- as_server = data.getbyte(AS_SERVER_OFFSET) == 1
67
-
68
- {
69
- major: major,
70
- minor: minor,
71
- mechanism: mechanism,
72
- as_server: as_server,
73
- }
74
- end
75
- end
76
- end
77
- end
78
- end
@@ -1,18 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module OMQ
4
- module ZMTP
5
- # ZMTP 3.1 wire protocol codec.
6
- #
7
- module Codec
8
- end
9
-
10
- # Raised on ZMTP protocol violations.
11
- #
12
- class ProtocolError < RuntimeError; end
13
- end
14
- end
15
-
16
- require_relative "codec/greeting"
17
- require_relative "codec/frame"
18
- require_relative "codec/command"
@@ -1,282 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module OMQ
4
- module ZMTP
5
- # Manages one ZMTP peer connection over any transport IO.
6
- #
7
- # Delegates the security handshake to a Mechanism object (Null, Curve, etc.),
8
- # then provides message send/receive and command send/receive on top of the
9
- # framing codec.
10
- #
11
- class Connection
12
- # @return [String] peer's socket type (from READY handshake)
13
- #
14
- attr_reader :peer_socket_type
15
-
16
-
17
- # @return [String] peer's identity (from READY handshake)
18
- #
19
- attr_reader :peer_identity
20
-
21
-
22
- # @return [Object] transport IO (#read, #write, #close)
23
- #
24
- attr_reader :io
25
-
26
-
27
- # @param io [#read, #write, #close] transport IO
28
- # @param socket_type [String] our socket type name (e.g. "REQ")
29
- # @param identity [String] our identity
30
- # @param as_server [Boolean] whether we are the server side
31
- # @param mechanism [Mechanism::Null, Mechanism::Curve] security mechanism
32
- # @param heartbeat_interval [Numeric, nil] heartbeat interval in seconds
33
- # @param heartbeat_ttl [Numeric, nil] TTL to send in PING
34
- # @param heartbeat_timeout [Numeric, nil] timeout for PONG
35
- # @param max_message_size [Integer, nil] max frame size in bytes, nil = unlimited
36
- #
37
- def initialize(io, socket_type:, identity: "", as_server: false,
38
- mechanism: nil,
39
- heartbeat_interval: nil, heartbeat_ttl: nil, heartbeat_timeout: nil,
40
- max_message_size: nil)
41
- @io = io
42
- @socket_type = socket_type
43
- @identity = identity
44
- @as_server = as_server
45
- @mechanism = mechanism || Mechanism::Null.new
46
- @peer_socket_type = nil
47
- @peer_identity = nil
48
- @mutex = Mutex.new
49
- @heartbeat_interval = heartbeat_interval
50
- @heartbeat_ttl = heartbeat_ttl || heartbeat_interval
51
- @heartbeat_timeout = heartbeat_timeout || heartbeat_interval
52
- @last_received_at = nil
53
- @heartbeat_task = nil
54
- @max_message_size = max_message_size
55
- end
56
-
57
-
58
- # Performs the full ZMTP handshake via the configured mechanism.
59
- #
60
- # @return [void]
61
- # @raise [ProtocolError] on handshake failure
62
- #
63
- def handshake!
64
- result = @mechanism.handshake!(
65
- @io,
66
- as_server: @as_server,
67
- socket_type: @socket_type,
68
- identity: @identity,
69
- )
70
-
71
- @peer_socket_type = result[:peer_socket_type]
72
- @peer_identity = result[:peer_identity]
73
-
74
- unless @peer_socket_type
75
- raise ProtocolError, "peer READY missing Socket-Type"
76
- end
77
-
78
- unless ZMTP::VALID_PEERS[@socket_type.to_sym]&.include?(@peer_socket_type.to_sym)
79
- raise ProtocolError,
80
- "incompatible socket types: #{@socket_type} cannot connect to #{@peer_socket_type}"
81
- end
82
- end
83
-
84
-
85
- # Sends a multi-frame message (write + flush).
86
- #
87
- # @param parts [Array<String>] message frames
88
- # @return [void]
89
- #
90
- def send_message(parts)
91
- @mutex.synchronize do
92
- write_frames(parts)
93
- @io.flush
94
- end
95
- end
96
-
97
-
98
- # Writes a multi-frame message to the buffer without flushing.
99
- # Call {#flush} after batching writes.
100
- #
101
- # @param parts [Array<String>] message frames
102
- # @return [void]
103
- #
104
- def write_message(parts)
105
- @mutex.synchronize do
106
- write_frames(parts)
107
- end
108
- end
109
-
110
-
111
- # Flushes the write buffer to the underlying IO.
112
- #
113
- # @return [void]
114
- #
115
- def flush
116
- @mutex.synchronize do
117
- @io.flush
118
- end
119
- end
120
-
121
-
122
- # Receives a multi-frame message.
123
- # PING/PONG commands are handled automatically by #read_frame.
124
- #
125
- # @return [Array<String>] message frames
126
- # @raise [EOFError] if connection is closed
127
- #
128
- def receive_message
129
- frames = []
130
- loop do
131
- frame = read_frame
132
- if frame.command?
133
- yield frame if block_given?
134
- next
135
- end
136
- frames << frame.body.freeze
137
- break unless frame.more?
138
- end
139
- frames.freeze
140
- end
141
-
142
-
143
- # Starts the heartbeat sender task. Call after handshake.
144
- #
145
- # @return [#stop, nil] the heartbeat task, or nil if disabled
146
- #
147
- def start_heartbeat
148
- return nil unless @heartbeat_interval
149
- @last_received_at = monotonic_now
150
- @heartbeat_task = Reactor.spawn_pump(annotation: "heartbeat") do
151
- loop do
152
- sleep @heartbeat_interval
153
- # Send PING with TTL
154
- send_command(Codec::Command.ping(
155
- ttl: @heartbeat_ttl || 0,
156
- context: "".b,
157
- ))
158
- # Check if peer has gone silent
159
- if @heartbeat_timeout && @last_received_at
160
- elapsed = monotonic_now - @last_received_at
161
- if elapsed > @heartbeat_timeout
162
- close
163
- break
164
- end
165
- end
166
- end
167
- rescue *ZMTP::CONNECTION_LOST
168
- # connection closed
169
- end
170
- end
171
-
172
-
173
- # Sends a command.
174
- #
175
- # @param command [Codec::Command]
176
- # @return [void]
177
- #
178
- def send_command(command)
179
- @mutex.synchronize do
180
- if @mechanism.encrypted?
181
- @io.write(@mechanism.encrypt(command.to_body, command: true))
182
- else
183
- @io.write(command.to_frame.to_wire)
184
- end
185
- @io.flush
186
- end
187
- end
188
-
189
-
190
- # Reads one frame from the wire. Handles PING/PONG automatically.
191
- # When using an encrypted mechanism, MESSAGE commands are decrypted
192
- # back to ZMTP frames transparently.
193
- #
194
- # @return [Codec::Frame]
195
- # @raise [EOFError] if connection is closed
196
- #
197
- def read_frame
198
- loop do
199
- frame = Codec::Frame.read_from(@io)
200
- touch_heartbeat
201
-
202
- # When CURVE is active, every wire frame is a MESSAGE envelope
203
- # (data frame with "\x07MESSAGE" prefix). Decrypt to recover the
204
- # inner ZMTP frame. This check only runs when encrypted? is true,
205
- # so user data frames are never misdetected.
206
- if @mechanism.encrypted? && frame.body.bytesize > 8 && frame.body.byteslice(0, 8) == "\x07MESSAGE".b
207
- frame = @mechanism.decrypt(frame)
208
- end
209
-
210
- if @max_message_size && !frame.command? && frame.body.bytesize > @max_message_size
211
- close
212
- raise ProtocolError, "frame size #{frame.body.bytesize} exceeds max_message_size #{@max_message_size}"
213
- end
214
- if frame.command?
215
- cmd = Codec::Command.from_body(frame.body)
216
- case cmd.name
217
- when "PING"
218
- _, context = cmd.ping_ttl_and_context
219
- send_command(Codec::Command.pong(context: context))
220
- next
221
- when "PONG"
222
- next
223
- end
224
- end
225
- return frame
226
- end
227
- end
228
-
229
-
230
- # Closes the connection.
231
- #
232
- # @return [void]
233
- #
234
- def close
235
- @heartbeat_task&.stop rescue nil
236
- @io.close
237
- rescue IOError
238
- # already closed
239
- end
240
-
241
-
242
- private
243
-
244
-
245
- # Writes message parts as ZMTP frames, encrypting if needed.
246
- #
247
- # @param parts [Array<String>] message frames
248
- #
249
- def write_frames(parts)
250
- parts.each_with_index do |part, i|
251
- more = i < parts.size - 1
252
- if @mechanism.encrypted?
253
- @io.write(@mechanism.encrypt(part.b, more: more))
254
- else
255
- @io.write(Codec::Frame.new(part, more: more).to_wire)
256
- end
257
- end
258
- end
259
-
260
-
261
- def touch_heartbeat
262
- @last_received_at = monotonic_now if @heartbeat_interval
263
- end
264
-
265
-
266
- def monotonic_now
267
- Async::Clock.now
268
- end
269
-
270
-
271
- # Sends one frame to the wire.
272
- #
273
- # @param frame [Codec::Frame]
274
- # @return [void]
275
- #
276
- def send_frame(frame)
277
- @mutex.synchronize { @io.write(frame.to_wire) }
278
- end
279
-
280
- end
281
- end
282
- end