protocol-zmtp 0.10.3 → 0.10.5
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 +4 -4
- data/CHANGELOG.md +311 -0
- data/README.md +12 -12
- data/lib/protocol/zmtp/codec/command.rb +39 -1
- data/lib/protocol/zmtp/codec/frame.rb +8 -0
- data/lib/protocol/zmtp/connection.rb +10 -7
- data/lib/protocol/zmtp/error.rb +5 -0
- data/lib/protocol/zmtp/mechanism/curve.rb +13 -9
- data/lib/protocol/zmtp/mechanism/null.rb +3 -3
- data/lib/protocol/zmtp/mechanism/plain.rb +5 -4
- data/lib/protocol/zmtp/peer_info.rb +1 -1
- data/lib/protocol/zmtp/version.rb +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c8d53545d14bf02a264d165e08adb9ab22b1f948a614856644dea0a093dc1da5
|
|
4
|
+
data.tar.gz: cf71224105abc69d8cd6df5d2ab5e45cdcbed0c88ff257a6325fdd3deeb8312c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 677c05b2057178c6c388805c53c9dd1b314c06a4c436cf448b1d0fa2f2cdc6515c7818e0f3995a398b336487bebf74f6c00f8380837f635fd9377e1c11d8b3b1
|
|
7
|
+
data.tar.gz: 912e32a6301493475a676952ad1ac1c9ddff7e1f68e0b5415b20fd344dfe32d3e887d51faa00a0dce1f8fe4fb219cd50f899517e21a5c0b47dfd33b1140b3cb9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## [0.10.5] - 2026-10-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Allow updating `Connection#max_message_size` after the handshake so
|
|
10
|
+
compressed transports can bound wire envelopes separately from messages.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Reject invalid frame flags, lengths above 2^63-1, and malformed PING commands.
|
|
15
|
+
- Match handshake metadata names case-insensitively for NULL, PLAIN, and CURVE.
|
|
16
|
+
- Raise `FatalHandshakeError` on handshake ERROR responses and send ERROR when
|
|
17
|
+
PLAIN authentication rejects credentials.
|
|
18
|
+
|
|
19
|
+
## 0.10.4 - 2026-07-23
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Release source is now the `zeromq/omq.rb` monorepo.
|
|
24
|
+
|
|
25
|
+
## 0.10.3 — 2026-05-30
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **CURVE: COMMAND flag in encrypted inner byte must be 0x02, not 0x04.**
|
|
30
|
+
CurveZMQ's inner plaintext flags byte uses a compact layout where
|
|
31
|
+
bit 1 (0x02) means COMMAND, but encrypt/decrypt used the wire-level
|
|
32
|
+
COMMAND bit (0x04). This broke interop with libzmq: a CURVE-encrypted
|
|
33
|
+
SUBSCRIBE from a libzmq SUB was treated as data and silently dropped,
|
|
34
|
+
so the PUB never registered the subscription.
|
|
35
|
+
|
|
36
|
+
## 0.10.2 — 2026-05-28
|
|
37
|
+
|
|
38
|
+
- Bump version (was missed in 0.10.1).
|
|
39
|
+
|
|
40
|
+
## 0.10.1 — 2026-05-28
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- **`Connection#write_wire_batch`** writes multiple pre-encoded wire
|
|
45
|
+
byte strings under a single mutex acquisition, amortizing lock
|
|
46
|
+
overhead for fan-out send pumps.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- **Single-write short frames.** Frames with bodies <= 255 bytes now
|
|
51
|
+
combine the 2-byte header and body into one `@io.write` call,
|
|
52
|
+
halving per-frame io-stream mutex overhead (+5-7% small-message
|
|
53
|
+
TCP throughput).
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- **NULL mechanism greeting: `as-server` field is now always 0.**
|
|
58
|
+
RFC 23 requires `as-server = 0` for the NULL mechanism, but the
|
|
59
|
+
server side was forwarding the caller's flag (`0x01`). This broke
|
|
60
|
+
interoperability with strict ZMTP 3.x peers that validate the field.
|
|
61
|
+
|
|
62
|
+
## 0.10.0 — 2026-04-20
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
|
|
66
|
+
- **`PeerInfo` extended with `identity`.** `PeerInfo = Data.define(:public_key, :identity)`
|
|
67
|
+
now bundles both post-handshake peer anchors. CURVE authenticators
|
|
68
|
+
still receive a `PeerInfo` during auth (with `identity: ""`, since
|
|
69
|
+
identity arrives post-auth).
|
|
70
|
+
- **`Connection#peer_info`.** After a successful handshake, returns a
|
|
71
|
+
frozen `PeerInfo` combining the peer's CURVE public key (if any) and
|
|
72
|
+
`ZMQ_IDENTITY`. Usable directly as a Hash key. Nil before handshake.
|
|
73
|
+
Upper layers (e.g. omq-qos levels 2/3) use the whole value as a
|
|
74
|
+
stable per-peer identifier across reconnects.
|
|
75
|
+
- **Mechanism return hashes carry `peer_public_key`.** Both
|
|
76
|
+
`Mechanism::Null#handshake!` and `Mechanism::Curve` handshake paths now
|
|
77
|
+
return `peer_public_key:` alongside `peer_identity:` etc. (nil for
|
|
78
|
+
NULL; the peer's long-term `crypto::PublicKey` for CURVE).
|
|
79
|
+
|
|
80
|
+
### Changed
|
|
81
|
+
|
|
82
|
+
- **Drop hot-path binary-encoding coercion and frame-body freezes.**
|
|
83
|
+
`Frame#initialize` and `Frame.encode_message` no longer copy non-binary
|
|
84
|
+
bodies via `String#b` — callers (OMQ's `Writable#send`, ZMTP command
|
|
85
|
+
builders) already hand in binary bytes, and for an outgoing message the
|
|
86
|
+
receiver (`String.new(capacity:) << body`) treats any encoding as raw
|
|
87
|
+
bytes once the buffer itself is binary. `Connection#receive_message`
|
|
88
|
+
also stops freezing each frame body and the returned parts array; the
|
|
89
|
+
caller decides what to freeze. Net effect: fewer allocations per
|
|
90
|
+
message and no per-part `.freeze` on the receive path.
|
|
91
|
+
|
|
92
|
+
## 0.9.0 — 2026-04-18
|
|
93
|
+
|
|
94
|
+
### Changed
|
|
95
|
+
|
|
96
|
+
- **`Frame.read_from` uses `io.peek` to collapse the long-frame read
|
|
97
|
+
path.** The previous implementation issued 2 `read_exactly` calls for
|
|
98
|
+
short frames (header + body) and 3 for long frames (2-byte header,
|
|
99
|
+
remaining 7 size bytes, body). It now peeks just enough header bytes
|
|
100
|
+
(2 for short, 9 for long), decodes the size from the peek buffer, and
|
|
101
|
+
drains header + body in a single `read_exactly(header_size + size)`.
|
|
102
|
+
Long frames drop to the same 2-call read path as short frames; the
|
|
103
|
+
`read_long_size` helper is gone. A speculative `read_exactly(9)` would
|
|
104
|
+
not be safe here — a <7-byte short frame at idle would hang waiting
|
|
105
|
+
for bytes that never arrive, or steal bytes from the next frame on a
|
|
106
|
+
mixed stream.
|
|
107
|
+
|
|
108
|
+
- **Transport IO must now also respond to `#peek`** in addition to
|
|
109
|
+
`#read_exactly`, `#write`, `#flush`, and `#close`. `io-stream`'s
|
|
110
|
+
`Async::IO::Stream` already provides this; custom transport wrappers
|
|
111
|
+
need a `#peek(&block)` that yields the current read buffer and fills
|
|
112
|
+
it until the block returns truthy.
|
|
113
|
+
|
|
114
|
+
## 0.8.1 — 2026-04-16
|
|
115
|
+
|
|
116
|
+
### Changed
|
|
117
|
+
|
|
118
|
+
- **`Frame#initialize` skips redundant `.b` copy.** Body is now kept
|
|
119
|
+
as-is when it is already `Encoding::BINARY`, avoiding a per-frame
|
|
120
|
+
String allocation on the read path (`read_exactly` and `byteslice`
|
|
121
|
+
already return binary strings).
|
|
122
|
+
|
|
123
|
+
- **`Frame#to_wire` uses `String.new(capacity:)` with `<<` appends**
|
|
124
|
+
instead of `+` concatenation, reducing from 3 intermediate String
|
|
125
|
+
allocations per frame to 1 pre-sized buffer.
|
|
126
|
+
|
|
127
|
+
- **`Frame.encode_message` pre-computes wire size** for the output
|
|
128
|
+
buffer `capacity:` hint, avoiding re-allocations during fan-out.
|
|
129
|
+
Single-part messages (the common case) skip the iteration and
|
|
130
|
+
inline the size calculation.
|
|
131
|
+
|
|
132
|
+
- **`Command.ping` / `.pong` default to `EMPTY_BINARY`** instead of
|
|
133
|
+
`"".b`, which allocated a mutable copy on every call despite
|
|
134
|
+
`frozen_string_literal: true`. `#ping_ttl_and_context` uses
|
|
135
|
+
`EMPTY_BINARY` for the empty-context fallback.
|
|
136
|
+
|
|
137
|
+
- **`Subscription.body` drops redundant outer `.b`** — both operands
|
|
138
|
+
are already binary, so the concatenation result is binary without
|
|
139
|
+
a second encoding conversion.
|
|
140
|
+
|
|
141
|
+
## 0.8.0 — 2026-04-15
|
|
142
|
+
|
|
143
|
+
### Added
|
|
144
|
+
|
|
145
|
+
- **`Codec::Subscription`** — helper module that unifies ZMTP 3.0
|
|
146
|
+
message-form (`\x01`/`\x00` + prefix data frame) and ZMTP 3.1
|
|
147
|
+
command-form (`SUBSCRIBE`/`CANCEL` command frame) subscription
|
|
148
|
+
encodings. `.body(prefix, cancel:)` builds the message-form body;
|
|
149
|
+
`.parse(frame)` returns `[:subscribe|:cancel, prefix]` for either
|
|
150
|
+
form, letting upper layers accept both without branching.
|
|
151
|
+
- **`Connection#peer_major` / `#peer_minor`** — ZMTP wire revision of
|
|
152
|
+
the peer, captured from the greeting. Lets upper layers pick the
|
|
153
|
+
subscription wire form (and other version-gated features) per peer.
|
|
154
|
+
Populated by `Mechanism::Null`, `Mechanism::Plain`, and
|
|
155
|
+
`Mechanism::Curve` via their handshake result hash.
|
|
156
|
+
- **`Codec::Greeting.read_from(io)`** — reads the 11-byte signature
|
|
157
|
+
phase first, validates the revision byte, then reads the rest of
|
|
158
|
+
the 64-byte greeting. A ZMTP/2.0 peer (revision `0x01`) is now
|
|
159
|
+
rejected loudly with `unsupported ZMTP revision 0x01 (ZMTP/2.x);
|
|
160
|
+
need revision >= 3`, instead of hanging forever in `read_exactly`
|
|
161
|
+
waiting for bytes that never arrive.
|
|
162
|
+
|
|
163
|
+
### Changed
|
|
164
|
+
|
|
165
|
+
- **Greeting error messages** refer to the ZMTP *revision byte*
|
|
166
|
+
(`0x03`) rather than a "version #{major}.#{minor}" — the byte at
|
|
167
|
+
offset 10 is the wire revision, which only accidentally matches the
|
|
168
|
+
spec major in ZMTP/3.x.
|
|
169
|
+
|
|
170
|
+
## 0.7.1 — 2026-04-14
|
|
171
|
+
|
|
172
|
+
### Changed
|
|
173
|
+
|
|
174
|
+
- **Short-frame read fast path.** `Frame.read_from` now fetches the
|
|
175
|
+
2-byte header (flags + first size byte) in a single `read_exactly`
|
|
176
|
+
call instead of two separate 1-byte reads. Short frames (≤255 bytes,
|
|
177
|
+
the vast majority of ZMTP traffic) now hit a 2-call read path
|
|
178
|
+
(header + body) instead of 3. Long frames read the remaining 7 size
|
|
179
|
+
bytes via the extracted `read_long_size` helper.
|
|
180
|
+
|
|
181
|
+
## 0.7.0 — 2026-04-13
|
|
182
|
+
|
|
183
|
+
### Added
|
|
184
|
+
|
|
185
|
+
- **Extension metadata hook on the handshake mechanisms.** `Mechanism::Null`,
|
|
186
|
+
`Mechanism::Plain`, and `Mechanism::Curve` expose a `metadata` accessor
|
|
187
|
+
(`Hash{String => String}`) that upper layers can populate before
|
|
188
|
+
`#handshake!`. Any entries are merged into the outgoing READY properties
|
|
189
|
+
(and INITIATE, for CURVE/PLAIN client side). `Codec::Command.ready` gained
|
|
190
|
+
a matching `metadata:` kwarg. Used by `omq-rfc-zstd` to advertise the
|
|
191
|
+
`X-Compression` property without forking the handshake code path.
|
|
192
|
+
|
|
193
|
+
- **`Connection#peer_properties`.** The full peer READY property hash is
|
|
194
|
+
now retained after a successful handshake (previously only Socket-Type,
|
|
195
|
+
Identity, and the X-QoS pair were extracted). Extensions can inspect
|
|
196
|
+
the peer's advertised properties to negotiate optional features.
|
|
197
|
+
Returned by all three mechanisms as `peer_properties:` in the
|
|
198
|
+
handshake result hash.
|
|
199
|
+
|
|
200
|
+
## 0.6.0 — 2026-04-12
|
|
201
|
+
|
|
202
|
+
### Changed
|
|
203
|
+
|
|
204
|
+
- **Consolidated empty-binary constant.** `Codec::Frame::EMPTY_BODY`
|
|
205
|
+
and `Codec::Command::EMPTY_DATA` are gone, replaced by a single
|
|
206
|
+
`Codec::EMPTY_BINARY` shared across the codec module. Anything
|
|
207
|
+
referencing the old constants needs to switch to
|
|
208
|
+
`Protocol::ZMTP::Codec::EMPTY_BINARY`.
|
|
209
|
+
|
|
210
|
+
## 0.5.1 — 2026-04-10
|
|
211
|
+
|
|
212
|
+
### Changed
|
|
213
|
+
|
|
214
|
+
- **Reduced allocations on hot paths.** Pre-computed `FLAG_BYTES` lookup
|
|
215
|
+
table eliminates `Integer#chr` + `String#b` per frame. `encode_message`
|
|
216
|
+
inlines wire encoding instead of creating throwaway Frame objects per
|
|
217
|
+
part. `write_frames` and `Command#to_body` skip redundant `.b` when
|
|
218
|
+
strings are already binary. `EMPTY_BODY` and `EMPTY_DATA` constants
|
|
219
|
+
replace per-call `"".b` allocations.
|
|
220
|
+
|
|
221
|
+
## 0.5.0 — 2026-04-10
|
|
222
|
+
|
|
223
|
+
### Fixed
|
|
224
|
+
|
|
225
|
+
- **Wire writes are now cancellation-safe.** `Connection#send_message`,
|
|
226
|
+
`#write_message`, `#write_messages`, `#write_wire`, and `#send_command`
|
|
227
|
+
each wrap their `@mutex.synchronize` block in
|
|
228
|
+
`Async::Task#defer_cancel`. A ZMTP frame is two `@io.write` calls
|
|
229
|
+
(header then body); under a socket-level barrier cascade (`barrier.stop`)
|
|
230
|
+
an `Async::Cancel` could previously land between those two writes,
|
|
231
|
+
leaving the peer with a header pointing at a body that never arrives
|
|
232
|
+
and an unrecoverable framer desync. `defer_cancel` holds the
|
|
233
|
+
cancellation until the write finishes, so cascading teardown only
|
|
234
|
+
unwinds at frame boundaries. Mutex protection is unchanged — it
|
|
235
|
+
guards against thread races; `defer_cancel` guards against fiber
|
|
236
|
+
cancellation. Non-Async callers (e.g. tests) fall through to the
|
|
237
|
+
unwrapped path.
|
|
238
|
+
|
|
239
|
+
## 0.4.0 — 2026-04-09
|
|
240
|
+
|
|
241
|
+
### Added
|
|
242
|
+
|
|
243
|
+
- **`Connection#write_messages`** — batched multipart send for work-stealing
|
|
244
|
+
send pumps. Dequeue a batch at once and write it under a single mutex
|
|
245
|
+
acquisition instead of one lock per message. Amortizes lock overhead on
|
|
246
|
+
high-throughput paths.
|
|
247
|
+
|
|
248
|
+
### Changed
|
|
249
|
+
|
|
250
|
+
- **Zero-alloc frame headers on the unencrypted hot send path.**
|
|
251
|
+
`#write_frames` used to allocate, per part, a `Codec::Frame` object, a
|
|
252
|
+
`.b` body copy, a 1-or-9-byte header String, and a concatenated wire
|
|
253
|
+
String (a copy of the entire body just to glue the header on). The body
|
|
254
|
+
copy was the dominant allocation — every 64 KB send produced a 64 KB
|
|
255
|
+
throwaway String, feeding the GC at ~1.2 GB/s under sustained load.
|
|
256
|
+
Headers are now packed into a reusable `@header_buf` via
|
|
257
|
+
`Array#pack(buffer:)`, header and body are written separately
|
|
258
|
+
(io-stream buffers them into one syscall per batch), the `Frame`
|
|
259
|
+
object is gone, and the body is no longer copied. `@mechanism.encrypted?`
|
|
260
|
+
is hoisted out of the per-frame loop. The encrypted path is unchanged —
|
|
261
|
+
CURVE/BLAKE3ZMQ still authenticate over the full encoded frame and need
|
|
262
|
+
a single wire String per part.
|
|
263
|
+
|
|
264
|
+
Bench impact downstream in omq (push_pull + router_dealer, 1 and 3 peers,
|
|
265
|
+
all transports): 48 improvements, 0 regressions across 72 measurements.
|
|
266
|
+
Peaks: `router_dealer tcp 3p 64B +40.7%`, `push_pull ipc 1p 64 KB +28.8%`
|
|
267
|
+
(crosses 1.3 GB/s).
|
|
268
|
+
|
|
269
|
+
## 0.3.0 — 2026-04-07
|
|
270
|
+
|
|
271
|
+
- Replace `[Async {}].each(&:wait)` with `Barrier` in tests.
|
|
272
|
+
- YARD documentation on all public methods and classes.
|
|
273
|
+
- Code style: two blank lines between methods and constants.
|
|
274
|
+
- Fix `#read_frame` decryption for non-CURVE encrypted mechanisms
|
|
275
|
+
(e.g. BLAKE3ZMQ). Previously only CURVE's `\x07MESSAGE`-wrapped command
|
|
276
|
+
frames were decrypted; inline-encrypted command frames (SUBSCRIBE, PING,
|
|
277
|
+
etc.) were silently dropped, breaking PUB/SUB over BLAKE3ZMQ.
|
|
278
|
+
|
|
279
|
+
- **Breaking:** `Mechanism::Curve` API is now kwargs-only:
|
|
280
|
+
`Curve.server(public_key:, secret_key:, crypto:)` and
|
|
281
|
+
`Curve.client(server_key:, crypto:)`. Client keys are optional — when
|
|
282
|
+
omitted, an ephemeral permanent keypair is auto-generated. INITIATE
|
|
283
|
+
always contains `C + vouch + metadata` per RFC 26.
|
|
284
|
+
- **Breaking:** Authenticator now receives a `Protocol::ZMTP::PeerInfo`
|
|
285
|
+
(with a `crypto::PublicKey`) via `#call`. The `#include?` duck-typing
|
|
286
|
+
is removed. Sends an ERROR command to the client on rejection.
|
|
287
|
+
- Add `Protocol::ZMTP::PeerInfo` shared across mechanisms.
|
|
288
|
+
- Add `#maintenance` to `Mechanism::Curve` for automatic cookie key rotation.
|
|
289
|
+
Returns `{ interval: 60, task: <Proc> }` on server-side mechanisms so the
|
|
290
|
+
host application can rotate the cookie key every 60 seconds, limiting the
|
|
291
|
+
forward secrecy exposure window.
|
|
292
|
+
|
|
293
|
+
## 0.2.0
|
|
294
|
+
|
|
295
|
+
- Add `Mechanism::Plain` — PLAIN authentication (RFC 24). Carries username and
|
|
296
|
+
password in a `HELLO` command during the handshake; no frame encryption.
|
|
297
|
+
Accepts an optional `authenticator:` callable on the server side for
|
|
298
|
+
credential validation.
|
|
299
|
+
|
|
300
|
+
## 0.1.2
|
|
301
|
+
|
|
302
|
+
- Check frame size against `max_message_size` before reading the body from the
|
|
303
|
+
wire. Previously, the entire frame was allocated into memory before the size
|
|
304
|
+
check, allowing a malicious peer to cause arbitrary memory allocation with a
|
|
305
|
+
single oversized frame header.
|
|
306
|
+
- Size limit applies to all frames including commands — an attacker cannot bypass
|
|
307
|
+
the check by setting the command flag.
|
|
308
|
+
|
|
309
|
+
## 0.1.1
|
|
310
|
+
|
|
311
|
+
- Initial public release.
|
data/README.md
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
# Protocol::ZMTP
|
|
2
2
|
|
|
3
3
|
[](https://rubygems.org/gems/protocol-zmtp)
|
|
4
|
-
[](https://github.com/zeromq/omq.rb/actions/workflows/ci.yml)
|
|
5
5
|
|
|
6
|
-
ZMTP 3.1 wire protocol
|
|
6
|
+
ZMTP 3.1 wire protocol: codec, connection, NULL and CURVE mechanisms.
|
|
7
7
|
No runtime dependencies.
|
|
8
8
|
|
|
9
9
|
## What's in the box
|
|
10
10
|
|
|
11
|
-
- **Codec::Frame
|
|
12
|
-
- **Codec::Greeting
|
|
13
|
-
- **Codec::Command
|
|
14
|
-
- **Connection
|
|
15
|
-
- **Mechanism::Null
|
|
16
|
-
- **Mechanism::Curve
|
|
17
|
-
- **Z85
|
|
11
|
+
- **Codec::Frame**: ZMTP frame encode/decode (flags, size, body)
|
|
12
|
+
- **Codec::Greeting**: 64-byte greeting exchange
|
|
13
|
+
- **Codec::Command**: READY, PING/PONG, SUBSCRIBE, etc.
|
|
14
|
+
- **Connection**: per-connection frame I/O, handshake, PING/PONG
|
|
15
|
+
- **Mechanism::Null**: NULL security (no encryption)
|
|
16
|
+
- **Mechanism::Curve**: CurveZMQ (RFC 26) with pluggable crypto backend
|
|
17
|
+
- **Z85**: ZeroMQ RFC 32 encoding
|
|
18
18
|
|
|
19
19
|
## Usage
|
|
20
20
|
|
|
21
21
|
```ruby
|
|
22
22
|
require "protocol/zmtp"
|
|
23
23
|
|
|
24
|
-
# NULL mechanism
|
|
24
|
+
# NULL mechanism, no encryption
|
|
25
25
|
conn = Protocol::ZMTP::Connection.new(
|
|
26
26
|
io,
|
|
27
27
|
socket_type: "REQ",
|
|
@@ -31,7 +31,7 @@ conn.handshake!
|
|
|
31
31
|
conn.send_message(["hello"])
|
|
32
32
|
msg = conn.receive_message
|
|
33
33
|
|
|
34
|
-
# CURVE mechanism
|
|
34
|
+
# CURVE mechanism, pass any NaCl-compatible backend
|
|
35
35
|
require "protocol/zmtp/mechanism/curve"
|
|
36
36
|
require "nuckle" # or: require "rbnacl"
|
|
37
37
|
|
|
@@ -42,7 +42,7 @@ server_mech = Protocol::ZMTP::Mechanism::Curve.server(
|
|
|
42
42
|
|
|
43
43
|
## CURVE crypto backend
|
|
44
44
|
|
|
45
|
-
`Mechanism::Curve` accepts a `crypto:` parameter
|
|
45
|
+
`Mechanism::Curve` accepts a `crypto:` parameter. Any module that
|
|
46
46
|
provides the NaCl API:
|
|
47
47
|
|
|
48
48
|
```ruby
|
|
@@ -72,7 +72,7 @@ module Protocol
|
|
|
72
72
|
|
|
73
73
|
# Builds a READY command with Socket-Type, Identity, and any
|
|
74
74
|
# extra properties supplied by upper layers (e.g. an extension
|
|
75
|
-
# that injects +X-
|
|
75
|
+
# that injects +X-Compression+).
|
|
76
76
|
#
|
|
77
77
|
# @param metadata [Hash{String => String}, nil] additional READY properties
|
|
78
78
|
# @return [Command]
|
|
@@ -83,6 +83,27 @@ module Protocol
|
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
# Builds a fatal handshake refusal.
|
|
87
|
+
def self.error(reason)
|
|
88
|
+
raise Error, "ERROR reason exceeds 255 bytes" if reason.bytesize > 255
|
|
89
|
+
|
|
90
|
+
new("ERROR", reason.bytesize.chr.b + reason.b)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ERROR is fatal even if its reason is malformed.
|
|
95
|
+
def raise_if_error!
|
|
96
|
+
return self unless @name == "ERROR"
|
|
97
|
+
|
|
98
|
+
length = @data.getbyte(0)
|
|
99
|
+
unless length && @data.bytesize == length + 1
|
|
100
|
+
raise FatalHandshakeError, "malformed ERROR command"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
raise FatalHandshakeError, "peer rejected handshake: #{@data.byteslice(1, length).inspect}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
|
|
86
107
|
# Builds a SUBSCRIBE command.
|
|
87
108
|
#
|
|
88
109
|
# @param prefix [String] subscription prefix to match
|
|
@@ -125,6 +146,9 @@ module Protocol
|
|
|
125
146
|
# @param context [String] optional context bytes (up to 16 bytes)
|
|
126
147
|
# @return [Command]
|
|
127
148
|
def self.ping(ttl: 0, context: EMPTY_BINARY)
|
|
149
|
+
raise Error, "PING TTL must be between 0 and 6553.5 seconds" unless (0..6553.5).cover?(ttl)
|
|
150
|
+
raise Error, "PING context exceeds 16 bytes" if context.bytesize > 16
|
|
151
|
+
|
|
128
152
|
ttl_ds = (ttl * 10).to_i
|
|
129
153
|
new("PING", [ttl_ds].pack("n") + (context.encoding == Encoding::BINARY ? context : context.b))
|
|
130
154
|
end
|
|
@@ -135,6 +159,8 @@ module Protocol
|
|
|
135
159
|
# @param context [String] context bytes echoed from the PING
|
|
136
160
|
# @return [Command]
|
|
137
161
|
def self.pong(context: EMPTY_BINARY)
|
|
162
|
+
raise Error, "PONG context exceeds 16 bytes" if context.bytesize > 16
|
|
163
|
+
|
|
138
164
|
new("PONG", context.encoding == Encoding::BINARY ? context : context.b)
|
|
139
165
|
end
|
|
140
166
|
|
|
@@ -143,6 +169,8 @@ module Protocol
|
|
|
143
169
|
#
|
|
144
170
|
# @return [Array(Numeric, String)] [ttl_seconds, context_bytes]
|
|
145
171
|
def ping_ttl_and_context
|
|
172
|
+
raise Error, "PING data must contain a TTL and at most 16 context bytes" unless (2..18).cover?(@data.bytesize)
|
|
173
|
+
|
|
146
174
|
ttl_ds = @data.unpack1("n")
|
|
147
175
|
context = @data.bytesize > 2 ? @data.byteslice(2..) : EMPTY_BINARY
|
|
148
176
|
[ttl_ds / 10.0, context]
|
|
@@ -157,6 +185,16 @@ module Protocol
|
|
|
157
185
|
end
|
|
158
186
|
|
|
159
187
|
|
|
188
|
+
# Property names are case-insensitive; retain their original spelling
|
|
189
|
+
# in the public metadata hash for callers that enumerate it.
|
|
190
|
+
def self.property(props, name)
|
|
191
|
+
props.each_pair do |key, value|
|
|
192
|
+
return value if key.casecmp?(name)
|
|
193
|
+
end
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
|
|
160
198
|
# Encodes a hash of properties into ZMTP property list format.
|
|
161
199
|
#
|
|
162
200
|
# @param props [Hash{String => String}] property name-value pairs
|
|
@@ -23,6 +23,7 @@ module Protocol
|
|
|
23
23
|
|
|
24
24
|
# Short frame: 1-byte size, max body 255 bytes.
|
|
25
25
|
SHORT_MAX = 255
|
|
26
|
+
LONG_MAX = (1 << 63) - 1
|
|
26
27
|
|
|
27
28
|
# Pre-computed single-byte flag strings (avoids Integer#chr + String#b per frame).
|
|
28
29
|
FLAG_BYTES = Array.new(256) { |i| i.chr.b.freeze }.freeze
|
|
@@ -102,6 +103,9 @@ module Protocol
|
|
|
102
103
|
long = (flags & FLAGS_LONG) != 0
|
|
103
104
|
command = (flags & FLAGS_COMMAND) != 0
|
|
104
105
|
|
|
106
|
+
raise Error, "reserved frame flags must be zero" unless (flags & 0xF8).zero?
|
|
107
|
+
raise Error, "command frames cannot have MORE set" if command && more
|
|
108
|
+
|
|
105
109
|
if long
|
|
106
110
|
raise EOFError, "Stream finished before reading long frame size" if buf.bytesize < 9
|
|
107
111
|
|
|
@@ -119,6 +123,8 @@ module Protocol
|
|
|
119
123
|
header_size = 2
|
|
120
124
|
end
|
|
121
125
|
|
|
126
|
+
raise Error, "frame size exceeds ZMTP's 63-bit limit" if size > LONG_MAX
|
|
127
|
+
|
|
122
128
|
if max_message_size && size > max_message_size
|
|
123
129
|
raise Error, "frame size #{size} exceeds max_message_size #{max_message_size}"
|
|
124
130
|
end
|
|
@@ -141,6 +147,8 @@ module Protocol
|
|
|
141
147
|
# @param more [Boolean] more frames follow
|
|
142
148
|
# @param command [Boolean] this is a command frame
|
|
143
149
|
def initialize(body, more: false, command: false)
|
|
150
|
+
raise Error, "command frames cannot have MORE set" if command && more
|
|
151
|
+
|
|
144
152
|
@body = body
|
|
145
153
|
@more = more
|
|
146
154
|
@command = command
|
|
@@ -47,6 +47,10 @@ module Protocol
|
|
|
47
47
|
# @return [Float, nil] monotonic timestamp of last received frame
|
|
48
48
|
attr_reader :last_received_at
|
|
49
49
|
|
|
50
|
+
# Transport wrappers may need room for bounded wire envelopes in
|
|
51
|
+
# addition to the application message limit they enforce after decoding.
|
|
52
|
+
attr_writer :max_message_size
|
|
53
|
+
|
|
50
54
|
|
|
51
55
|
# @param io [#peek, #read_exactly, #write, #flush, #close] transport IO
|
|
52
56
|
# @param socket_type [String] our socket type name (e.g. "REQ")
|
|
@@ -55,7 +59,7 @@ module Protocol
|
|
|
55
59
|
# @param mechanism [Mechanism::Null, Mechanism::Curve] security mechanism
|
|
56
60
|
# @param max_message_size [Integer, nil] max frame size in bytes, nil = unlimited
|
|
57
61
|
# @param opts [Hash{String => String}] extra READY properties to
|
|
58
|
-
# advertise (e.g. +"X-
|
|
62
|
+
# advertise (e.g. +"X-Compression" => "lz4"+). Upper-layer extensions use
|
|
59
63
|
# this to inject their own negotiated properties without the
|
|
60
64
|
# codec needing to know about them.
|
|
61
65
|
def initialize(io, socket_type:, identity: "", as_server: false,
|
|
@@ -278,12 +282,7 @@ module Protocol
|
|
|
278
282
|
# @raise [EOFError] if connection is closed
|
|
279
283
|
def read_frame
|
|
280
284
|
loop do
|
|
281
|
-
|
|
282
|
-
frame = Codec::Frame.read_from(@io, max_message_size: @max_message_size)
|
|
283
|
-
rescue Error
|
|
284
|
-
close
|
|
285
|
-
raise
|
|
286
|
-
end
|
|
285
|
+
frame = Codec::Frame.read_from(@io, max_message_size: @max_message_size)
|
|
287
286
|
|
|
288
287
|
touch_heartbeat
|
|
289
288
|
|
|
@@ -297,12 +296,16 @@ module Protocol
|
|
|
297
296
|
send_command(Codec::Command.pong(context: context))
|
|
298
297
|
next
|
|
299
298
|
when "PONG"
|
|
299
|
+
raise Error, "PONG context exceeds 16 bytes" if cmd.data.bytesize > 16
|
|
300
300
|
next
|
|
301
301
|
end
|
|
302
302
|
end
|
|
303
303
|
|
|
304
304
|
return frame
|
|
305
305
|
end
|
|
306
|
+
rescue Error
|
|
307
|
+
close
|
|
308
|
+
raise
|
|
306
309
|
end
|
|
307
310
|
|
|
308
311
|
|
data/lib/protocol/zmtp/error.rb
CHANGED
|
@@ -153,6 +153,8 @@ module Protocol
|
|
|
153
153
|
# @param command [Boolean] whether this is a command frame
|
|
154
154
|
# @return [String] binary wire bytes ready for writing
|
|
155
155
|
def encrypt(body, more: false, command: false)
|
|
156
|
+
raise Error, "command frames cannot have MORE set" if command && more
|
|
157
|
+
|
|
156
158
|
flags = 0
|
|
157
159
|
flags |= 0x01 if more
|
|
158
160
|
flags |= 0x02 if command
|
|
@@ -211,6 +213,8 @@ module Protocol
|
|
|
211
213
|
end
|
|
212
214
|
|
|
213
215
|
flags = plaintext.getbyte(0)
|
|
216
|
+
raise Error, "invalid MESSAGE flags" unless flags && (flags & 0xFC).zero?
|
|
217
|
+
|
|
214
218
|
body = plaintext.byteslice(1..) || "".b
|
|
215
219
|
Codec::Frame.new(body, more: (flags & 0x01) != 0, command: (flags & 0x02) != 0)
|
|
216
220
|
end
|
|
@@ -255,7 +259,7 @@ module Protocol
|
|
|
255
259
|
# --- Read WELCOME ---
|
|
256
260
|
welcome_frame = Codec::Frame.read_from(io)
|
|
257
261
|
raise Error, "expected command frame" unless welcome_frame.command?
|
|
258
|
-
welcome_cmd = Codec::Command.from_body(welcome_frame.body)
|
|
262
|
+
welcome_cmd = Codec::Command.from_body(welcome_frame.body).raise_if_error!
|
|
259
263
|
raise Error, "expected WELCOME, got #{welcome_cmd.name}" unless welcome_cmd.name == "WELCOME"
|
|
260
264
|
|
|
261
265
|
wdata = welcome_cmd.data
|
|
@@ -307,7 +311,7 @@ module Protocol
|
|
|
307
311
|
# --- Read READY ---
|
|
308
312
|
ready_frame = Codec::Frame.read_from(io)
|
|
309
313
|
raise Error, "expected command frame" unless ready_frame.command?
|
|
310
|
-
ready_cmd = Codec::Command.from_body(ready_frame.body)
|
|
314
|
+
ready_cmd = Codec::Command.from_body(ready_frame.body).raise_if_error!
|
|
311
315
|
raise Error, "expected READY, got #{ready_cmd.name}" unless ready_cmd.name == "READY"
|
|
312
316
|
|
|
313
317
|
rdata = ready_cmd.data
|
|
@@ -324,8 +328,8 @@ module Protocol
|
|
|
324
328
|
end
|
|
325
329
|
|
|
326
330
|
props = Codec::Command.decode_properties(r_plaintext)
|
|
327
|
-
peer_socket_type = props
|
|
328
|
-
peer_identity = props
|
|
331
|
+
peer_socket_type = Codec::Command.property(props, "Socket-Type")
|
|
332
|
+
peer_identity = Codec::Command.property(props, "Identity") || ""
|
|
329
333
|
|
|
330
334
|
@session_box = session
|
|
331
335
|
@send_nonce = 1
|
|
@@ -361,7 +365,7 @@ module Protocol
|
|
|
361
365
|
# --- Read HELLO ---
|
|
362
366
|
hello_frame = Codec::Frame.read_from(io)
|
|
363
367
|
raise Error, "expected command frame" unless hello_frame.command?
|
|
364
|
-
hello_cmd = Codec::Command.from_body(hello_frame.body)
|
|
368
|
+
hello_cmd = Codec::Command.from_body(hello_frame.body).raise_if_error!
|
|
365
369
|
raise Error, "expected HELLO, got #{hello_cmd.name}" unless hello_cmd.name == "HELLO"
|
|
366
370
|
|
|
367
371
|
hdata = hello_cmd.data
|
|
@@ -407,7 +411,7 @@ module Protocol
|
|
|
407
411
|
# --- Read INITIATE ---
|
|
408
412
|
init_frame = Codec::Frame.read_from(io)
|
|
409
413
|
raise Error, "expected command frame" unless init_frame.command?
|
|
410
|
-
init_cmd = Codec::Command.from_body(init_frame.body)
|
|
414
|
+
init_cmd = Codec::Command.from_body(init_frame.body).raise_if_error!
|
|
411
415
|
raise Error, "expected INITIATE, got #{init_cmd.name}" unless init_cmd.name == "INITIATE"
|
|
412
416
|
|
|
413
417
|
idata = init_cmd.data
|
|
@@ -468,7 +472,7 @@ module Protocol
|
|
|
468
472
|
peer = PeerInfo.new(public_key: client_permanent, identity: "")
|
|
469
473
|
unless @authenticator.call(peer)
|
|
470
474
|
send_error(io, "client key not authorized")
|
|
471
|
-
raise
|
|
475
|
+
raise FatalHandshakeError, "client key not authorized"
|
|
472
476
|
end
|
|
473
477
|
end
|
|
474
478
|
|
|
@@ -498,8 +502,8 @@ module Protocol
|
|
|
498
502
|
init_nonce_buffers!
|
|
499
503
|
|
|
500
504
|
{
|
|
501
|
-
peer_socket_type: props
|
|
502
|
-
peer_identity: props
|
|
505
|
+
peer_socket_type: Codec::Command.property(props, "Socket-Type"),
|
|
506
|
+
peer_identity: Codec::Command.property(props, "Identity") || "",
|
|
503
507
|
peer_public_key: client_permanent,
|
|
504
508
|
peer_properties: props,
|
|
505
509
|
peer_major: @peer_major,
|
|
@@ -48,14 +48,14 @@ module Protocol
|
|
|
48
48
|
raise Error, "expected command frame, got data frame"
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
peer_cmd = Codec::Command.from_body(frame.body)
|
|
51
|
+
peer_cmd = Codec::Command.from_body(frame.body).raise_if_error!
|
|
52
52
|
unless peer_cmd.name == "READY"
|
|
53
53
|
raise Error, "expected READY command, got #{peer_cmd.name}"
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
props = peer_cmd.properties
|
|
57
|
-
peer_socket_type = props
|
|
58
|
-
peer_identity = props
|
|
57
|
+
peer_socket_type = Codec::Command.property(props, "Socket-Type")
|
|
58
|
+
peer_identity = Codec::Command.property(props, "Identity") || ""
|
|
59
59
|
|
|
60
60
|
unless peer_socket_type
|
|
61
61
|
raise Error, "peer READY missing Socket-Type"
|
|
@@ -92,7 +92,8 @@ module Protocol
|
|
|
92
92
|
username, password = decode_credentials(cmd.data)
|
|
93
93
|
|
|
94
94
|
if @authenticator && !@authenticator.call(username, password)
|
|
95
|
-
|
|
95
|
+
send_command(io, Codec::Command.error("authentication failed"))
|
|
96
|
+
raise FatalHandshakeError, "authentication failed"
|
|
96
97
|
end
|
|
97
98
|
|
|
98
99
|
send_command(io, Codec::Command.new("WELCOME"))
|
|
@@ -143,13 +144,13 @@ module Protocol
|
|
|
143
144
|
|
|
144
145
|
def extract_peer_info(cmd)
|
|
145
146
|
props = cmd.properties
|
|
146
|
-
peer_socket_type = props
|
|
147
|
+
peer_socket_type = Codec::Command.property(props, "Socket-Type")
|
|
147
148
|
|
|
148
149
|
raise Error, "peer command missing Socket-Type" unless peer_socket_type
|
|
149
150
|
|
|
150
151
|
{
|
|
151
152
|
peer_socket_type: peer_socket_type,
|
|
152
|
-
peer_identity: props
|
|
153
|
+
peer_identity: Codec::Command.property(props, "Identity") || "",
|
|
153
154
|
peer_properties: props,
|
|
154
155
|
}
|
|
155
156
|
end
|
|
@@ -165,7 +166,7 @@ module Protocol
|
|
|
165
166
|
frame = Codec::Frame.read_from(io)
|
|
166
167
|
raise Error, "expected command frame, got data frame" unless frame.command?
|
|
167
168
|
|
|
168
|
-
Codec::Command.from_body(frame.body)
|
|
169
|
+
Codec::Command.from_body(frame.body).raise_if_error!
|
|
169
170
|
end
|
|
170
171
|
end
|
|
171
172
|
end
|
|
@@ -9,7 +9,7 @@ module Protocol
|
|
|
9
9
|
# - +identity+ is the peer's +ZMQ_IDENTITY+ string, or +""+ when no
|
|
10
10
|
# identity was advertised in the READY metadata.
|
|
11
11
|
#
|
|
12
|
-
# Used as a peer key by upper layers
|
|
12
|
+
# Used as a peer key by upper layers: the
|
|
13
13
|
# whole value object is frozen and equality-comparable, so it can be
|
|
14
14
|
# stored directly in a Hash without picking one anchor over the other.
|
|
15
15
|
#
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: protocol-zmtp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.10.
|
|
4
|
+
version: 0.10.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrik Wenger
|
|
@@ -18,6 +18,7 @@ executables: []
|
|
|
18
18
|
extensions: []
|
|
19
19
|
extra_rdoc_files: []
|
|
20
20
|
files:
|
|
21
|
+
- CHANGELOG.md
|
|
21
22
|
- LICENSE
|
|
22
23
|
- README.md
|
|
23
24
|
- lib/protocol/zmtp.rb
|
|
@@ -35,7 +36,7 @@ files:
|
|
|
35
36
|
- lib/protocol/zmtp/valid_peers.rb
|
|
36
37
|
- lib/protocol/zmtp/version.rb
|
|
37
38
|
- lib/protocol/zmtp/z85.rb
|
|
38
|
-
homepage: https://github.com/
|
|
39
|
+
homepage: https://github.com/zeromq/omq.rb/tree/main/gems/protocol-zmtp
|
|
39
40
|
licenses:
|
|
40
41
|
- ISC
|
|
41
42
|
metadata: {}
|
|
@@ -53,7 +54,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
53
54
|
- !ruby/object:Gem::Version
|
|
54
55
|
version: '0'
|
|
55
56
|
requirements: []
|
|
56
|
-
rubygems_version: 4.0.
|
|
57
|
+
rubygems_version: 4.0.20
|
|
57
58
|
specification_version: 4
|
|
58
59
|
summary: ZMTP 3.1 wire protocol codec and connection
|
|
59
60
|
test_files: []
|