omq 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE +15 -0
- data/README.md +145 -0
- data/lib/omq/pair.rb +13 -0
- data/lib/omq/pub_sub.rb +77 -0
- data/lib/omq/push_pull.rb +21 -0
- data/lib/omq/req_rep.rb +23 -0
- data/lib/omq/router_dealer.rb +36 -0
- data/lib/omq/socket.rb +178 -0
- data/lib/omq/version.rb +5 -0
- data/lib/omq/zmtp/codec/command.rb +207 -0
- data/lib/omq/zmtp/codec/frame.rb +104 -0
- data/lib/omq/zmtp/codec/greeting.rb +96 -0
- data/lib/omq/zmtp/codec.rb +18 -0
- data/lib/omq/zmtp/connection.rb +233 -0
- data/lib/omq/zmtp/engine.rb +339 -0
- data/lib/omq/zmtp/mechanism/null.rb +70 -0
- data/lib/omq/zmtp/options.rb +57 -0
- data/lib/omq/zmtp/reactor.rb +142 -0
- data/lib/omq/zmtp/readable.rb +29 -0
- data/lib/omq/zmtp/routing/dealer.rb +57 -0
- data/lib/omq/zmtp/routing/fan_out.rb +89 -0
- data/lib/omq/zmtp/routing/pair.rb +68 -0
- data/lib/omq/zmtp/routing/pub.rb +62 -0
- data/lib/omq/zmtp/routing/pull.rb +48 -0
- data/lib/omq/zmtp/routing/push.rb +57 -0
- data/lib/omq/zmtp/routing/rep.rb +83 -0
- data/lib/omq/zmtp/routing/req.rb +70 -0
- data/lib/omq/zmtp/routing/round_robin.rb +69 -0
- data/lib/omq/zmtp/routing/router.rb +88 -0
- data/lib/omq/zmtp/routing/sub.rb +80 -0
- data/lib/omq/zmtp/routing/xpub.rb +74 -0
- data/lib/omq/zmtp/routing/xsub.rb +80 -0
- data/lib/omq/zmtp/routing.rb +38 -0
- data/lib/omq/zmtp/transport/inproc.rb +299 -0
- data/lib/omq/zmtp/transport/ipc.rb +114 -0
- data/lib/omq/zmtp/transport/tcp.rb +98 -0
- data/lib/omq/zmtp/valid_peers.rb +21 -0
- data/lib/omq/zmtp/writable.rb +44 -0
- data/lib/omq/zmtp.rb +47 -0
- data/lib/omq.rb +19 -0
- metadata +110 -0
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
buf = IO::Buffer.new(1 + name_bytes.bytesize + @data.bytesize)
|
|
43
|
+
buf.set_value(:U8, 0, name_bytes.bytesize)
|
|
44
|
+
buf.set_string(name_bytes, 1)
|
|
45
|
+
buf.set_string(@data, 1 + name_bytes.bytesize)
|
|
46
|
+
buf.get_string(0, buf.size, Encoding::BINARY)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Encodes as a complete command Frame.
|
|
50
|
+
#
|
|
51
|
+
# @return [Frame]
|
|
52
|
+
#
|
|
53
|
+
def to_frame
|
|
54
|
+
Frame.new(to_body, command: true)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Decodes a command from a frame body.
|
|
58
|
+
#
|
|
59
|
+
# @param body [String] binary frame body
|
|
60
|
+
# @return [Command]
|
|
61
|
+
# @raise [ProtocolError] on malformed command
|
|
62
|
+
#
|
|
63
|
+
def self.from_body(body)
|
|
64
|
+
body = body.b
|
|
65
|
+
raise ProtocolError, "command body too short" if body.bytesize < 1
|
|
66
|
+
|
|
67
|
+
buf = IO::Buffer.for(body)
|
|
68
|
+
name_len = buf.get_value(:U8, 0)
|
|
69
|
+
|
|
70
|
+
raise ProtocolError, "command name truncated" if body.bytesize < 1 + name_len
|
|
71
|
+
|
|
72
|
+
name = buf.get_string(1, name_len, Encoding::BINARY)
|
|
73
|
+
data = body.byteslice(1 + name_len..)
|
|
74
|
+
new(name, data)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Builds a READY command with Socket-Type and Identity properties.
|
|
78
|
+
#
|
|
79
|
+
# @param socket_type [String] e.g. "REQ", "REP", "PAIR"
|
|
80
|
+
# @param identity [String] peer identity (can be empty)
|
|
81
|
+
# @return [Command]
|
|
82
|
+
#
|
|
83
|
+
def self.ready(socket_type:, identity: "")
|
|
84
|
+
props = encode_properties(
|
|
85
|
+
"Socket-Type" => socket_type,
|
|
86
|
+
"Identity" => identity,
|
|
87
|
+
)
|
|
88
|
+
new("READY", props)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Builds a SUBSCRIBE command.
|
|
92
|
+
#
|
|
93
|
+
# @param prefix [String] subscription prefix
|
|
94
|
+
# @return [Command]
|
|
95
|
+
#
|
|
96
|
+
def self.subscribe(prefix)
|
|
97
|
+
new("SUBSCRIBE", prefix.b)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Builds a CANCEL command (unsubscribe).
|
|
101
|
+
#
|
|
102
|
+
# @param prefix [String] subscription prefix to cancel
|
|
103
|
+
# @return [Command]
|
|
104
|
+
#
|
|
105
|
+
def self.cancel(prefix)
|
|
106
|
+
new("CANCEL", prefix.b)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Builds a PING command.
|
|
110
|
+
#
|
|
111
|
+
# @param ttl [Numeric] time-to-live in seconds (sent as deciseconds)
|
|
112
|
+
# @param context [String] optional context bytes (up to 16 bytes)
|
|
113
|
+
# @return [Command]
|
|
114
|
+
#
|
|
115
|
+
def self.ping(ttl: 0, context: "".b)
|
|
116
|
+
# TTL is encoded as 2-byte big-endian value in tenths of a second
|
|
117
|
+
ttl_ds = (ttl * 10).to_i
|
|
118
|
+
buf = IO::Buffer.new(2 + context.bytesize)
|
|
119
|
+
buf.set_value(:U16, 0, ttl_ds)
|
|
120
|
+
buf.set_string(context.b, 2) if context.bytesize > 0
|
|
121
|
+
new("PING", buf.get_string(0, buf.size, Encoding::BINARY))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Builds a PONG command.
|
|
125
|
+
#
|
|
126
|
+
# @param context [String] context bytes from the PING
|
|
127
|
+
# @return [Command]
|
|
128
|
+
#
|
|
129
|
+
def self.pong(context: "".b)
|
|
130
|
+
new("PONG", context.b)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Extracts TTL (in seconds) and context from a PING command's data.
|
|
134
|
+
#
|
|
135
|
+
# @return [Array(Numeric, String)] [ttl_seconds, context_bytes]
|
|
136
|
+
#
|
|
137
|
+
def ping_ttl_and_context
|
|
138
|
+
buf = IO::Buffer.for(@data)
|
|
139
|
+
ttl_ds = buf.get_value(:U16, 0)
|
|
140
|
+
context = @data.bytesize > 2 ? @data.byteslice(2..) : "".b
|
|
141
|
+
[ttl_ds / 10.0, context]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Parses READY command data as a property list.
|
|
145
|
+
#
|
|
146
|
+
# @return [Hash<String, String>] property name => value
|
|
147
|
+
# @raise [ProtocolError] on malformed properties
|
|
148
|
+
#
|
|
149
|
+
def properties
|
|
150
|
+
self.class.decode_properties(@data)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Encodes a hash of properties into ZMTP property list format.
|
|
154
|
+
#
|
|
155
|
+
# @param props [Hash<String, String>]
|
|
156
|
+
# @return [String] binary property list
|
|
157
|
+
#
|
|
158
|
+
def self.encode_properties(props)
|
|
159
|
+
parts = props.map do |name, value|
|
|
160
|
+
name_bytes = name.b
|
|
161
|
+
value_bytes = value.b
|
|
162
|
+
buf = IO::Buffer.new(1 + name_bytes.bytesize + 4 + value_bytes.bytesize)
|
|
163
|
+
buf.set_value(:U8, 0, name_bytes.bytesize)
|
|
164
|
+
buf.set_string(name_bytes, 1)
|
|
165
|
+
buf.set_value(:U32, 1 + name_bytes.bytesize, value_bytes.bytesize) # big-endian
|
|
166
|
+
buf.set_string(value_bytes, 1 + name_bytes.bytesize + 4)
|
|
167
|
+
buf.get_string(0, buf.size, Encoding::BINARY)
|
|
168
|
+
end
|
|
169
|
+
parts.join
|
|
170
|
+
end
|
|
171
|
+
# Decodes a ZMTP property list from binary data.
|
|
172
|
+
#
|
|
173
|
+
# @param data [String] binary property list
|
|
174
|
+
# @return [Hash<String, String>] property name => value
|
|
175
|
+
# @raise [ProtocolError] on malformed properties
|
|
176
|
+
#
|
|
177
|
+
def self.decode_properties(data)
|
|
178
|
+
result = {}
|
|
179
|
+
buf = IO::Buffer.for(data)
|
|
180
|
+
offset = 0
|
|
181
|
+
|
|
182
|
+
while offset < data.bytesize
|
|
183
|
+
raise ProtocolError, "property name truncated" if offset + 1 > data.bytesize
|
|
184
|
+
name_len = buf.get_value(:U8, offset)
|
|
185
|
+
offset += 1
|
|
186
|
+
|
|
187
|
+
raise ProtocolError, "property name truncated" if offset + name_len > data.bytesize
|
|
188
|
+
name = buf.get_string(offset, name_len, Encoding::BINARY)
|
|
189
|
+
offset += name_len
|
|
190
|
+
|
|
191
|
+
raise ProtocolError, "property value length truncated" if offset + 4 > data.bytesize
|
|
192
|
+
value_len = buf.get_value(:U32, offset)
|
|
193
|
+
offset += 4
|
|
194
|
+
|
|
195
|
+
raise ProtocolError, "property value truncated" if offset + value_len > data.bytesize
|
|
196
|
+
value = buf.get_string(offset, value_len, Encoding::BINARY)
|
|
197
|
+
offset += value_len
|
|
198
|
+
|
|
199
|
+
result[name] = value
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
result
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
|
56
|
+
buf = IO::Buffer.new(9 + size)
|
|
57
|
+
buf.set_value(:U8, 0, flags)
|
|
58
|
+
buf.set_value(:U64, 1, size) # big-endian
|
|
59
|
+
buf.set_string(@body, 9)
|
|
60
|
+
buf.get_string(0, 9 + size, Encoding::BINARY)
|
|
61
|
+
else
|
|
62
|
+
buf = IO::Buffer.new(2 + size)
|
|
63
|
+
buf.set_value(:U8, 0, flags)
|
|
64
|
+
buf.set_value(:U8, 1, size)
|
|
65
|
+
buf.set_string(@body, 2)
|
|
66
|
+
buf.get_string(0, 2 + size, Encoding::BINARY)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Reads one frame from an IO-like object.
|
|
71
|
+
#
|
|
72
|
+
# @param io [#read] must support read(n) returning exactly n bytes
|
|
73
|
+
# @return [Frame]
|
|
74
|
+
# @raise [ProtocolError] on invalid frame
|
|
75
|
+
# @raise [EOFError] if the connection is closed
|
|
76
|
+
#
|
|
77
|
+
def self.read_from(io)
|
|
78
|
+
flags_byte = io.read_exactly(1)
|
|
79
|
+
flags_buf = IO::Buffer.for(flags_byte)
|
|
80
|
+
flags = flags_buf.get_value(:U8, 0)
|
|
81
|
+
|
|
82
|
+
more = (flags & FLAGS_MORE) != 0
|
|
83
|
+
long = (flags & FLAGS_LONG) != 0
|
|
84
|
+
command = (flags & FLAGS_COMMAND) != 0
|
|
85
|
+
|
|
86
|
+
if long
|
|
87
|
+
size_bytes = io.read_exactly(8)
|
|
88
|
+
size_buf = IO::Buffer.for(size_bytes)
|
|
89
|
+
size = size_buf.get_value(:U64, 0) # big-endian
|
|
90
|
+
else
|
|
91
|
+
size_byte = io.read_exactly(1)
|
|
92
|
+
size_buf = IO::Buffer.for(size_byte)
|
|
93
|
+
size = size_buf.get_value(:U8, 0)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
body = size > 0 ? io.read_exactly(size) : "".b
|
|
97
|
+
|
|
98
|
+
new(body, more: more, command: command)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
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 = IO::Buffer.new(SIZE)
|
|
37
|
+
buf.clear
|
|
38
|
+
|
|
39
|
+
# Signature
|
|
40
|
+
buf.set_value(:U8, 0, SIGNATURE_START)
|
|
41
|
+
# bytes 1-8 are already 0x00
|
|
42
|
+
buf.set_value(:U8, 9, SIGNATURE_END)
|
|
43
|
+
|
|
44
|
+
# Version
|
|
45
|
+
buf.set_value(:U8, 10, VERSION_MAJOR)
|
|
46
|
+
buf.set_value(:U8, 11, VERSION_MINOR)
|
|
47
|
+
|
|
48
|
+
# Mechanism (null-padded)
|
|
49
|
+
buf.set_string(mechanism.b, MECHANISM_OFFSET)
|
|
50
|
+
|
|
51
|
+
# As-server flag
|
|
52
|
+
buf.set_value(:U8, AS_SERVER_OFFSET, as_server ? 1 : 0)
|
|
53
|
+
|
|
54
|
+
# Filler bytes 33-63 are already 0x00
|
|
55
|
+
buf.get_string(0, SIZE, Encoding::BINARY)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Decodes a ZMTP greeting.
|
|
59
|
+
#
|
|
60
|
+
# @param data [String] 64-byte binary greeting
|
|
61
|
+
# @return [Hash] { major:, minor:, mechanism:, as_server: }
|
|
62
|
+
# @raise [ProtocolError] on invalid greeting
|
|
63
|
+
#
|
|
64
|
+
def self.decode(data)
|
|
65
|
+
raise ProtocolError, "greeting too short (#{data.bytesize} bytes)" if data.bytesize < SIZE
|
|
66
|
+
|
|
67
|
+
buf = IO::Buffer.for(data.b)
|
|
68
|
+
|
|
69
|
+
# Validate signature
|
|
70
|
+
unless buf.get_value(:U8, 0) == SIGNATURE_START &&
|
|
71
|
+
buf.get_value(:U8, 9) == SIGNATURE_END
|
|
72
|
+
raise ProtocolError, "invalid greeting signature"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
major = buf.get_value(:U8, 10)
|
|
76
|
+
minor = buf.get_value(:U8, 11)
|
|
77
|
+
|
|
78
|
+
unless major >= 3
|
|
79
|
+
raise ProtocolError, "unsupported ZMTP version #{major}.#{minor} (need >= 3.0)"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
mechanism = buf.get_string(MECHANISM_OFFSET, MECHANISM_LENGTH, Encoding::BINARY)
|
|
83
|
+
.delete("\x00")
|
|
84
|
+
as_server = buf.get_value(:U8, AS_SERVER_OFFSET) == 1
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
major: major,
|
|
88
|
+
minor: minor,
|
|
89
|
+
mechanism: mechanism,
|
|
90
|
+
as_server: as_server,
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
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"
|
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
# @return [String] peer's identity (from READY handshake)
|
|
17
|
+
#
|
|
18
|
+
attr_reader :peer_identity
|
|
19
|
+
|
|
20
|
+
# @return [Object] transport IO (#read, #write, #close)
|
|
21
|
+
#
|
|
22
|
+
attr_reader :io
|
|
23
|
+
|
|
24
|
+
# @param io [#read, #write, #close] transport IO
|
|
25
|
+
# @param socket_type [String] our socket type name (e.g. "REQ")
|
|
26
|
+
# @param identity [String] our identity
|
|
27
|
+
# @param as_server [Boolean] whether we are the server side
|
|
28
|
+
# @param mechanism [Mechanism::Null, Mechanism::Curve] security mechanism
|
|
29
|
+
# @param heartbeat_interval [Numeric, nil] heartbeat interval in seconds
|
|
30
|
+
# @param heartbeat_ttl [Numeric, nil] TTL to send in PING
|
|
31
|
+
# @param heartbeat_timeout [Numeric, nil] timeout for PONG
|
|
32
|
+
# @param max_message_size [Integer, nil] max frame size in bytes, nil = unlimited
|
|
33
|
+
#
|
|
34
|
+
def initialize(io, socket_type:, identity: "", as_server: false,
|
|
35
|
+
mechanism: nil,
|
|
36
|
+
heartbeat_interval: nil, heartbeat_ttl: nil, heartbeat_timeout: nil,
|
|
37
|
+
max_message_size: nil)
|
|
38
|
+
@io = io
|
|
39
|
+
@socket_type = socket_type
|
|
40
|
+
@identity = identity
|
|
41
|
+
@as_server = as_server
|
|
42
|
+
@mechanism = mechanism || Mechanism::Null.new
|
|
43
|
+
@peer_socket_type = nil
|
|
44
|
+
@peer_identity = nil
|
|
45
|
+
@mutex = Mutex.new
|
|
46
|
+
@heartbeat_interval = heartbeat_interval
|
|
47
|
+
@heartbeat_ttl = heartbeat_ttl || heartbeat_interval
|
|
48
|
+
@heartbeat_timeout = heartbeat_timeout || heartbeat_interval
|
|
49
|
+
@last_received_at = nil
|
|
50
|
+
@heartbeat_task = nil
|
|
51
|
+
@max_message_size = max_message_size
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Performs the full ZMTP handshake via the configured mechanism.
|
|
55
|
+
#
|
|
56
|
+
# @return [void]
|
|
57
|
+
# @raise [ProtocolError] on handshake failure
|
|
58
|
+
#
|
|
59
|
+
def handshake!
|
|
60
|
+
result = @mechanism.handshake!(
|
|
61
|
+
@io,
|
|
62
|
+
as_server: @as_server,
|
|
63
|
+
socket_type: @socket_type,
|
|
64
|
+
identity: @identity,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@peer_socket_type = result[:peer_socket_type]
|
|
68
|
+
@peer_identity = result[:peer_identity]
|
|
69
|
+
|
|
70
|
+
unless @peer_socket_type
|
|
71
|
+
raise ProtocolError, "peer READY missing Socket-Type"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
unless ZMTP::VALID_PEERS[@socket_type.to_sym]&.include?(@peer_socket_type.to_sym)
|
|
75
|
+
raise ProtocolError,
|
|
76
|
+
"incompatible socket types: #{@socket_type} cannot connect to #{@peer_socket_type}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Sends a multi-frame message.
|
|
81
|
+
#
|
|
82
|
+
# @param parts [Array<String>] message frames
|
|
83
|
+
# @return [void]
|
|
84
|
+
#
|
|
85
|
+
def send_message(parts)
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
parts.each_with_index do |part, i|
|
|
88
|
+
more = i < parts.size - 1
|
|
89
|
+
if @mechanism.encrypted?
|
|
90
|
+
@io.write(@mechanism.encrypt(part.b, more: more))
|
|
91
|
+
else
|
|
92
|
+
@io.write(Codec::Frame.new(part, more: more).to_wire)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Receives a multi-frame message.
|
|
99
|
+
# PING/PONG commands are handled automatically by #read_frame.
|
|
100
|
+
#
|
|
101
|
+
# @return [Array<String>] message frames
|
|
102
|
+
# @raise [EOFError] if connection is closed
|
|
103
|
+
#
|
|
104
|
+
def receive_message
|
|
105
|
+
frames = []
|
|
106
|
+
loop do
|
|
107
|
+
frame = read_frame
|
|
108
|
+
if frame.command?
|
|
109
|
+
yield frame if block_given?
|
|
110
|
+
next
|
|
111
|
+
end
|
|
112
|
+
frames << frame.body
|
|
113
|
+
break unless frame.more?
|
|
114
|
+
end
|
|
115
|
+
frames
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Starts the heartbeat sender task. Call after handshake.
|
|
119
|
+
#
|
|
120
|
+
# @return [#stop, nil] the heartbeat task, or nil if disabled
|
|
121
|
+
#
|
|
122
|
+
def start_heartbeat
|
|
123
|
+
return nil unless @heartbeat_interval
|
|
124
|
+
@last_received_at = monotonic_now
|
|
125
|
+
@heartbeat_task = Reactor.spawn_pump do
|
|
126
|
+
loop do
|
|
127
|
+
sleep @heartbeat_interval
|
|
128
|
+
# Send PING with TTL
|
|
129
|
+
send_command(Codec::Command.ping(
|
|
130
|
+
ttl: @heartbeat_ttl || 0,
|
|
131
|
+
context: "".b,
|
|
132
|
+
))
|
|
133
|
+
# Check if peer has gone silent
|
|
134
|
+
if @heartbeat_timeout && @last_received_at
|
|
135
|
+
elapsed = monotonic_now - @last_received_at
|
|
136
|
+
if elapsed > @heartbeat_timeout
|
|
137
|
+
close
|
|
138
|
+
break
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
rescue IOError, EOFError
|
|
143
|
+
# connection closed
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Sends a command.
|
|
148
|
+
#
|
|
149
|
+
# @param command [Codec::Command]
|
|
150
|
+
# @return [void]
|
|
151
|
+
#
|
|
152
|
+
def send_command(command)
|
|
153
|
+
@mutex.synchronize do
|
|
154
|
+
if @mechanism.encrypted?
|
|
155
|
+
@io.write(@mechanism.encrypt(command.to_body, command: true))
|
|
156
|
+
else
|
|
157
|
+
@io.write(command.to_frame.to_wire)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Reads one frame from the wire. Handles PING/PONG automatically.
|
|
163
|
+
# When using an encrypted mechanism, MESSAGE commands are decrypted
|
|
164
|
+
# back to ZMTP frames transparently.
|
|
165
|
+
#
|
|
166
|
+
# @return [Codec::Frame]
|
|
167
|
+
# @raise [EOFError] if connection is closed
|
|
168
|
+
#
|
|
169
|
+
def read_frame
|
|
170
|
+
loop do
|
|
171
|
+
frame = Codec::Frame.read_from(@io)
|
|
172
|
+
touch_heartbeat
|
|
173
|
+
|
|
174
|
+
# When CURVE is active, every wire frame is a MESSAGE envelope
|
|
175
|
+
# (data frame with "\x07MESSAGE" prefix). Decrypt to recover the
|
|
176
|
+
# inner ZMTP frame. This check only runs when encrypted? is true,
|
|
177
|
+
# so user data frames are never misdetected.
|
|
178
|
+
if @mechanism.encrypted? && frame.body.bytesize > 8 && frame.body.byteslice(0, 8) == "\x07MESSAGE".b
|
|
179
|
+
frame = @mechanism.decrypt(frame)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
if @max_message_size && !frame.command? && frame.body.bytesize > @max_message_size
|
|
183
|
+
close
|
|
184
|
+
raise ProtocolError, "frame size #{frame.body.bytesize} exceeds max_message_size #{@max_message_size}"
|
|
185
|
+
end
|
|
186
|
+
if frame.command?
|
|
187
|
+
cmd = Codec::Command.from_body(frame.body)
|
|
188
|
+
case cmd.name
|
|
189
|
+
when "PING"
|
|
190
|
+
_, context = cmd.ping_ttl_and_context
|
|
191
|
+
send_command(Codec::Command.pong(context: context))
|
|
192
|
+
next
|
|
193
|
+
when "PONG"
|
|
194
|
+
next
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
return frame
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Closes the connection.
|
|
202
|
+
#
|
|
203
|
+
# @return [void]
|
|
204
|
+
#
|
|
205
|
+
def close
|
|
206
|
+
@heartbeat_task&.stop rescue nil
|
|
207
|
+
@io.close
|
|
208
|
+
rescue IOError
|
|
209
|
+
# already closed
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def touch_heartbeat
|
|
215
|
+
@last_received_at = monotonic_now if @heartbeat_interval
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def monotonic_now
|
|
219
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Sends one frame to the wire.
|
|
223
|
+
#
|
|
224
|
+
# @param frame [Codec::Frame]
|
|
225
|
+
# @return [void]
|
|
226
|
+
#
|
|
227
|
+
def send_frame(frame)
|
|
228
|
+
@mutex.synchronize { @io.write(frame.to_wire) }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|