omq 0.7.0 → 0.8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2e17e1a12c0f44bec5af914b8df2f810029e75c7da17e79e862428a7b0aa99d
4
- data.tar.gz: 4a8d06116bad67adc4411cb3281302fd6190ecce46ceb47f21cc15a32c3e9006
3
+ metadata.gz: 852e5a45a7b9f61b32004aa2079e1289291c3ac2ecc5efeb78911d88d3b63b2e
4
+ data.tar.gz: b475a15807b8e98d4a852ec78b07ecaa4a0b15e68d3aec2fd9b2702533cb5d5f
5
5
  SHA512:
6
- metadata.gz: 1dad86f1ad0648690bc5a01d3b7258b89f301d4768bac0831495d27f0a0e3c84f5a30af049732f8cc6885bcd6105654d41708d519da69c30a62739ca58a27418
7
- data.tar.gz: a71f54aa26dd3e7dea3b249d027c03aeff896c7ded7b0581067fbda609d114c976c8d35a5f7f7e630cbbc34fbb839c7d43f6d445e8f2bb50f1348cb46f8d0e51
6
+ metadata.gz: 0afb8f440937ae11bebe4ce3ecc1df599103f27300ed721b14437d708bd1cba1cd5b0aeb6873bd7281d054c42f6353abc29b1c8c542f4708152c2ad6b047b5f0
7
+ data.tar.gz: b3477d54c520f7ed09dc4647b84841c9937d863036c4301afc3110bd959fd794bbd91db56d0204f317cd66ef62bf63ad8b1772521dc48f96912ae01e9bea8f81
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 — 2026-03-31
4
+
5
+ ### Breaking
6
+
7
+ - **CURVE mechanism moved to protocol-zmtp** — `OMQ::ZMTP::Mechanism::Curve`
8
+ is now `Protocol::ZMTP::Mechanism::Curve` with a required `crypto:` parameter.
9
+ Pass `crypto: RbNaCl` (libsodium) or `crypto: Nuckle` (pure Ruby). The
10
+ omq-curve and omq-kurve gems are superseded.
11
+
12
+ ```ruby
13
+ # Before (omq-curve)
14
+ require "omq/curve"
15
+ rep.mechanism = OMQ::Curve.server(pub, sec)
16
+
17
+ # After (protocol-zmtp + any NaCl backend)
18
+ require "protocol/zmtp/mechanism/curve"
19
+ require "nuckle" # or: require "rbnacl"
20
+ rep.mechanism = Protocol::ZMTP::Mechanism::Curve.server(pub, sec, crypto: Nuckle)
21
+ ```
22
+
23
+ ### Changed
24
+
25
+ - **Protocol layer extracted into protocol-zmtp gem** — Codec (Frame,
26
+ Greeting, Command), Connection, Mechanism::Null, Mechanism::Curve,
27
+ ValidPeers, and Z85 now live in the
28
+ [protocol-zmtp](https://github.com/paddor/protocol-zmtp) gem. OMQ
29
+ re-exports them under `OMQ::ZMTP::` for backwards compatibility.
30
+ protocol-zmtp has zero runtime dependencies.
31
+ - **Unified CURVE mechanism** — one implementation with a pluggable
32
+ `crypto:` backend replaces the two near-identical copies in omq-curve
33
+ (RbNaCl) and omq-kurve (Nuckle). 1,088 → 467 lines (57% reduction).
34
+ - **Heartbeat ownership** — `Connection#start_heartbeat` removed.
35
+ Connection tracks timestamps only; the engine drives the PING/PONG loop.
36
+ - **CI no longer needs libsodium** — CURVE tests use
37
+ [nuckle](https://github.com/paddor/nuckle) (pure Ruby) by default.
38
+ Cross-backend interop tests run when rbnacl is available.
39
+
3
40
  ## 0.7.0 — 2026-03-30
4
41
 
5
42
  ### Breaking
@@ -502,4 +539,4 @@ Initial release. Pure Ruby implementation of ZMTP 3.1 (ZeroMQ) using Async.
502
539
  - Linger on close (drain send queue before closing)
503
540
  - `max_message_size` enforcement
504
541
  - Works inside Async reactors or standalone (shared IO thread)
505
- - Optional CURVE encryption via the [omq-curve](https://github.com/zeromq/omq-curve) gem
542
+ - Optional CURVE encryption via the [protocol-zmtp](https://github.com/paddor/protocol-zmtp) gem
data/lib/omq/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OMQ
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -432,17 +432,14 @@ module OMQ
432
432
  def setup_connection(io, as_server:, endpoint: nil, done: nil)
433
433
  conn = Connection.new(
434
434
  io,
435
- socket_type: @socket_type.to_s,
436
- identity: @options.identity,
437
- as_server: as_server,
438
- mechanism: @options.mechanism,
439
- heartbeat_interval: @options.heartbeat_interval,
440
- heartbeat_ttl: @options.heartbeat_ttl,
441
- heartbeat_timeout: @options.heartbeat_timeout,
442
- max_message_size: @options.max_message_size,
435
+ socket_type: @socket_type.to_s,
436
+ identity: @options.identity,
437
+ as_server: as_server,
438
+ mechanism: @options.mechanism&.dup,
439
+ max_message_size: @options.max_message_size,
443
440
  )
444
441
  conn.handshake!
445
- conn.start_heartbeat
442
+ start_heartbeat(conn)
446
443
  @connections << conn
447
444
  @connection_endpoints[conn] = endpoint if endpoint
448
445
  @connection_promises[conn] = done if done
@@ -454,6 +451,35 @@ module OMQ
454
451
  end
455
452
 
456
453
 
454
+ # Spawns a heartbeat task for the connection.
455
+ # The connection only tracks timestamps — the engine drives the loop.
456
+ #
457
+ # @param conn [Connection]
458
+ # @return [void]
459
+ #
460
+ def start_heartbeat(conn)
461
+ interval = @options.heartbeat_interval
462
+ return unless interval
463
+
464
+ ttl = @options.heartbeat_ttl || interval
465
+ timeout = @options.heartbeat_timeout || interval
466
+ conn.touch_heartbeat
467
+
468
+ @tasks << Reactor.spawn_pump(annotation: "heartbeat") do
469
+ loop do
470
+ sleep interval
471
+ conn.send_command(Codec::Command.ping(ttl: ttl, context: "".b))
472
+ if conn.heartbeat_expired?(timeout)
473
+ conn.close
474
+ break
475
+ end
476
+ end
477
+ rescue *CONNECTION_LOST
478
+ # connection closed
479
+ end
480
+ end
481
+
482
+
457
483
  # Spawns a background task that reconnects to the given endpoint
458
484
  # with exponential back-off based on the reconnect_interval option.
459
485
  #
data/lib/omq/zmtp.rb CHANGED
@@ -1,13 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "protocol/zmtp"
4
+ require "io/stream"
5
+
3
6
  module OMQ
4
7
  # ZMTP 3.1 protocol internals.
5
8
  #
6
- # These classes implement the wire protocol, transports, and routing
7
- # strategies. They are not part of the public API.
9
+ # The wire protocol (codec, connection, mechanisms) lives in the
10
+ # protocol-zmtp gem. This module re-exports those classes under the
11
+ # OMQ::ZMTP namespace and adds the transport/routing/engine layers.
8
12
  #
9
13
  module ZMTP
10
- require "io/stream"
14
+ # Re-export protocol-zmtp classes
15
+ Codec = Protocol::ZMTP::Codec
16
+ Connection = Protocol::ZMTP::Connection
17
+ ProtocolError = Protocol::ZMTP::Error
18
+ VALID_PEERS = Protocol::ZMTP::VALID_PEERS
19
+
20
+ module Mechanism
21
+ Null = Protocol::ZMTP::Mechanism::Null
22
+ Curve = Protocol::ZMTP::Mechanism::Curve if defined?(Protocol::ZMTP::Mechanism::Curve)
23
+ end
11
24
 
12
25
  # Errors raised when a peer disconnects or resets the connection.
13
26
  CONNECTION_LOST = [
@@ -32,24 +45,14 @@ module OMQ
32
45
  end
33
46
  end
34
47
 
35
- # Constants
36
- require_relative "zmtp/valid_peers"
37
-
38
- # Codec
39
- require_relative "zmtp/codec"
40
-
41
48
  # Transport
42
49
  require_relative "zmtp/transport/inproc"
43
50
  require_relative "zmtp/transport/tcp"
44
51
  require_relative "zmtp/transport/ipc"
45
52
 
46
- # Mechanisms
47
- require_relative "zmtp/mechanism/null"
48
-
49
53
  # Core
50
54
  require_relative "zmtp/reactor"
51
55
  require_relative "zmtp/options"
52
- require_relative "zmtp/connection"
53
56
  require_relative "zmtp/routing"
54
57
  require_relative "zmtp/routing/round_robin"
55
58
  require_relative "zmtp/routing/fan_out"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: protocol-zmtp
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: async
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -83,13 +97,7 @@ files:
83
97
  - lib/omq/socket.rb
84
98
  - lib/omq/version.rb
85
99
  - lib/omq/zmtp.rb
86
- - lib/omq/zmtp/codec.rb
87
- - lib/omq/zmtp/codec/command.rb
88
- - lib/omq/zmtp/codec/frame.rb
89
- - lib/omq/zmtp/codec/greeting.rb
90
- - lib/omq/zmtp/connection.rb
91
100
  - lib/omq/zmtp/engine.rb
92
- - lib/omq/zmtp/mechanism/null.rb
93
101
  - lib/omq/zmtp/options.rb
94
102
  - lib/omq/zmtp/reactor.rb
95
103
  - lib/omq/zmtp/readable.rb
@@ -119,7 +127,6 @@ files:
119
127
  - lib/omq/zmtp/transport/inproc.rb
120
128
  - lib/omq/zmtp/transport/ipc.rb
121
129
  - lib/omq/zmtp/transport/tcp.rb
122
- - lib/omq/zmtp/valid_peers.rb
123
130
  - lib/omq/zmtp/writable.rb
124
131
  homepage: https://github.com/zeromq/omq
125
132
  licenses:
@@ -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
@@ -1,72 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module OMQ
4
- module ZMTP
5
- module Mechanism
6
- # NULL security mechanism — no encryption, no authentication.
7
- #
8
- # Performs the ZMTP 3.1 greeting exchange and READY command handshake.
9
- #
10
- class Null
11
- MECHANISM_NAME = "NULL"
12
-
13
- # Performs the full NULL handshake over +io+.
14
- #
15
- # 1. Exchange 64-byte greetings
16
- # 2. Validate peer greeting (version, mechanism)
17
- # 3. Exchange READY commands (socket type + identity)
18
- #
19
- # @param io [#read, #write] transport IO
20
- # @param as_server [Boolean]
21
- # @param socket_type [String]
22
- # @param identity [String]
23
- # @return [Hash] { peer_socket_type:, peer_identity: }
24
- # @raise [ProtocolError]
25
- #
26
- def handshake!(io, as_server:, socket_type:, identity:)
27
- # Send our greeting
28
- io.write(Codec::Greeting.encode(mechanism: MECHANISM_NAME, as_server: as_server))
29
- io.flush
30
-
31
- # Read peer greeting
32
- greeting_data = io.read_exactly(Codec::Greeting::SIZE)
33
- peer_greeting = Codec::Greeting.decode(greeting_data)
34
-
35
- unless peer_greeting[:mechanism] == MECHANISM_NAME
36
- raise ProtocolError, "unsupported mechanism: #{peer_greeting[:mechanism]}"
37
- end
38
-
39
- # Send our READY command
40
- ready_cmd = Codec::Command.ready(socket_type: socket_type, identity: identity)
41
- io.write(ready_cmd.to_frame.to_wire)
42
- io.flush
43
-
44
- # Read peer READY command
45
- frame = Codec::Frame.read_from(io)
46
- unless frame.command?
47
- raise ProtocolError, "expected command frame, got data frame"
48
- end
49
-
50
- peer_cmd = Codec::Command.from_body(frame.body)
51
- unless peer_cmd.name == "READY"
52
- raise ProtocolError, "expected READY command, got #{peer_cmd.name}"
53
- end
54
-
55
- props = peer_cmd.properties
56
- peer_socket_type = props["Socket-Type"]
57
- peer_identity = props["Identity"] || ""
58
-
59
- unless peer_socket_type
60
- raise ProtocolError, "peer READY missing Socket-Type"
61
- end
62
-
63
- { peer_socket_type: peer_socket_type, peer_identity: peer_identity }
64
- end
65
-
66
- # @return [Boolean] false — NULL does not encrypt frames
67
- #
68
- def encrypted? = false
69
- end
70
- end
71
- end
72
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module OMQ
4
- module ZMTP
5
- # Valid socket type peer combinations per ZMTP spec.
6
- #
7
- VALID_PEERS = {
8
- PAIR: %i[PAIR].freeze,
9
- REQ: %i[REP ROUTER].freeze,
10
- REP: %i[REQ DEALER].freeze,
11
- DEALER: %i[REP DEALER ROUTER].freeze,
12
- ROUTER: %i[REQ DEALER ROUTER].freeze,
13
- PUB: %i[SUB XSUB].freeze,
14
- SUB: %i[PUB XPUB].freeze,
15
- XPUB: %i[SUB XSUB].freeze,
16
- XSUB: %i[PUB XPUB].freeze,
17
- PUSH: %i[PULL].freeze,
18
- PULL: %i[PUSH].freeze,
19
- CLIENT: %i[SERVER].freeze,
20
- SERVER: %i[CLIENT].freeze,
21
- RADIO: %i[DISH].freeze,
22
- DISH: %i[RADIO].freeze,
23
- SCATTER: %i[GATHER].freeze,
24
- GATHER: %i[SCATTER].freeze,
25
- PEER: %i[PEER].freeze,
26
- CHANNEL: %i[CHANNEL].freeze,
27
- }.freeze
28
- end
29
- end