graphmind 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,448 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "io/wait"
5
+ require "uri"
6
+ require "base64"
7
+ require "digest"
8
+ require "securerandom"
9
+
10
+ module Graphmind
11
+ # A minimal RFC 6455 client, hand-rolled over TCPSocket.
12
+ #
13
+ # Why not a websocket gem? This gem is a *debugger* that gets added to
14
+ # someone else's Gemfile — often a Rails app with a carefully pinned
15
+ # dependency graph. A debug tool that forces a resolution on faraday,
16
+ # websocket-driver or eventmachine is a tool people cannot install. Every
17
+ # byte below is stdlib (socket/uri/base64/digest/securerandom/openssl), the
18
+ # gem has zero runtime dependencies, and the client half of RFC 6455 that a
19
+ # local debug link actually needs is small: one masked opcode, ping/pong,
20
+ # close, and fragment reassembly.
21
+ #
22
+ # Scope, stated honestly: text frames, client role, no permessage-deflate,
23
+ # no subprotocol negotiation, no HTTP proxies. That is exactly what
24
+ # `ws://127.0.0.1:4747/ingest` needs and nothing more.
25
+ module WebSocket
26
+ GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
27
+
28
+ # A frame bigger than this from the peer is treated as hostile and the
29
+ # connection is dropped (the viewer never sends anything near it).
30
+ MAX_FRAME_BYTES = 16 * 1024 * 1024
31
+
32
+ OPCODE_CONTINUATION = 0x0
33
+ OPCODE_TEXT = 0x1
34
+ OPCODE_BINARY = 0x2
35
+ OPCODE_CLOSE = 0x8
36
+ OPCODE_PING = 0x9
37
+ OPCODE_PONG = 0xA
38
+
39
+ class Error < StandardError; end
40
+ class ProtocolError < Error; end
41
+ class Closed < Error; end
42
+
43
+ # How often a blocking read wakes up to re-check `closed?`.
44
+ POLL_INTERVAL = 0.25
45
+
46
+ module Frame
47
+ module_function
48
+
49
+ # Encode one frame. `mask` must be true for client->server frames.
50
+ def encode(payload, opcode: OPCODE_TEXT, mask: true)
51
+ data = payload.to_s.dup.force_encoding(Encoding::BINARY)
52
+ length = data.bytesize
53
+ header = +"".b
54
+ header << (0x80 | opcode).chr
55
+ flag = mask ? 0x80 : 0x00
56
+ if length < 126
57
+ header << (flag | length).chr
58
+ elsif length < 65_536
59
+ header << (flag | 126).chr
60
+ header << [length].pack("n")
61
+ else
62
+ header << (flag | 127).chr
63
+ header << [length].pack("Q>")
64
+ end
65
+ return header + data unless mask
66
+
67
+ key = SecureRandom.bytes(4)
68
+ header + key + apply_mask(data, key)
69
+ end
70
+
71
+ def apply_mask(data, key)
72
+ k = key.bytes
73
+ out = data.bytes
74
+ i = 0
75
+ n = out.length
76
+ while i < n
77
+ out[i] ^= k[i & 3]
78
+ i += 1
79
+ end
80
+ out.pack("C*")
81
+ end
82
+ end
83
+
84
+ # One framed connection over an already-open IO.
85
+ #
86
+ # `mask: true` is the client role (this gem); `mask: false` is the server
87
+ # role (used by the test double so both halves of the codec are exercised).
88
+ class Connection
89
+ attr_reader :io
90
+
91
+ def initialize(io, mask:, prebuffer: nil)
92
+ @io = io
93
+ @mask = mask
94
+ @write_mutex = Mutex.new
95
+ @closed = false
96
+ @fragments = nil
97
+ @fragment_opcode = nil
98
+ # Bytes the opening handshake read past the end of the HTTP response
99
+ # (a server is allowed to pipeline its first frame into the same TCP
100
+ # segment). Dropping them would lose a message.
101
+ @prebuffer = prebuffer.nil? || prebuffer.empty? ? nil : prebuffer.dup.force_encoding(Encoding::BINARY)
102
+ # Set when a deadline expired part-way through a frame: the stream is
103
+ # no longer aligned, so the connection must be thrown away.
104
+ @torn = false
105
+ end
106
+
107
+ def closed? = @closed
108
+
109
+ def send_text(text)
110
+ write_frame(Frame.encode(text, opcode: OPCODE_TEXT, mask: @mask))
111
+ end
112
+
113
+ def send_ping(payload = "")
114
+ write_frame(Frame.encode(payload, opcode: OPCODE_PING, mask: @mask))
115
+ end
116
+
117
+ def send_pong(payload = "")
118
+ write_frame(Frame.encode(payload, opcode: OPCODE_PONG, mask: @mask))
119
+ end
120
+
121
+ def send_close(code = 1000, reason = "")
122
+ body = [code].pack("n") + reason.to_s.b
123
+ write_frame(Frame.encode(body, opcode: OPCODE_CLOSE, mask: @mask))
124
+ rescue StandardError
125
+ nil
126
+ end
127
+
128
+ # Read one application message.
129
+ #
130
+ # Returns a String for a text/binary message, `:closed` when the peer
131
+ # closed, or `nil` when `deadline` passed with nothing complete. Control
132
+ # frames (ping/pong) are answered internally and do not return.
133
+ #
134
+ # `deadline` is an absolute Process.clock_gettime(CLOCK_MONOTONIC) value,
135
+ # or nil to block until a message, a close, or #close from another
136
+ # thread.
137
+ def read_message(deadline: nil)
138
+ loop do
139
+ frame = read_frame(deadline)
140
+ return nil if frame == :timeout
141
+ return :closed if frame.nil?
142
+
143
+ fin, opcode, payload = frame
144
+ case opcode
145
+ when OPCODE_PING
146
+ send_pong(payload)
147
+ next
148
+ when OPCODE_PONG
149
+ next
150
+ when OPCODE_CLOSE
151
+ send_close(1000)
152
+ return :closed
153
+ when OPCODE_CONTINUATION
154
+ raise ProtocolError, "continuation frame with no start" if @fragments.nil?
155
+
156
+ @fragments << payload
157
+ when OPCODE_TEXT, OPCODE_BINARY
158
+ raise ProtocolError, "interleaved data frame" unless @fragments.nil?
159
+
160
+ if fin
161
+ return finish_message(opcode, payload)
162
+ end
163
+
164
+ @fragments = +"".b << payload
165
+ @fragment_opcode = opcode
166
+ next
167
+ else
168
+ raise ProtocolError, "unknown opcode #{opcode}"
169
+ end
170
+
171
+ next unless fin && !@fragments.nil?
172
+
173
+ payload = @fragments
174
+ opcode = @fragment_opcode
175
+ @fragments = nil
176
+ @fragment_opcode = nil
177
+ return finish_message(opcode, payload)
178
+ end
179
+ end
180
+
181
+ def close
182
+ return if @closed
183
+
184
+ @closed = true
185
+ send_close(1000)
186
+ begin
187
+ @io.close
188
+ rescue StandardError
189
+ nil
190
+ end
191
+ end
192
+
193
+ # Drop the socket without a close handshake (simulates a crash).
194
+ def abort!
195
+ @closed = true
196
+ begin
197
+ @io.close
198
+ rescue StandardError
199
+ nil
200
+ end
201
+ end
202
+
203
+ private
204
+
205
+ def finish_message(opcode, payload)
206
+ return payload.dup.force_encoding(Encoding::BINARY) if opcode == OPCODE_BINARY
207
+
208
+ text = payload.dup.force_encoding(Encoding::UTF_8)
209
+ text.valid_encoding? ? text : payload.dup.force_encoding(Encoding::BINARY)
210
+ end
211
+
212
+ def write_frame(bytes)
213
+ raise Closed, "connection closed" if @closed
214
+
215
+ @write_mutex.synchronize { @io.write(bytes) }
216
+ true
217
+ end
218
+
219
+ # Returns [fin, opcode, payload], nil on EOF/close, or :timeout.
220
+ def read_frame(deadline)
221
+ raise ProtocolError, "connection desynchronised by a read timeout" if @torn
222
+
223
+ head = read_exactly(2, deadline)
224
+ return head if head.nil? || head == :timeout
225
+
226
+ b0, b1 = head.bytes
227
+ fin = (b0 & 0x80) != 0
228
+ raise ProtocolError, "reserved bits set" if (b0 & 0x70) != 0
229
+
230
+ opcode = b0 & 0x0F
231
+ masked = (b1 & 0x80) != 0
232
+ length = b1 & 0x7F
233
+
234
+ if length == 126
235
+ ext = read_rest(2, deadline)
236
+ return ext if ext.nil? || ext == :timeout
237
+
238
+ length = ext.unpack1("n")
239
+ elsif length == 127
240
+ ext = read_rest(8, deadline)
241
+ return ext if ext.nil? || ext == :timeout
242
+
243
+ length = ext.unpack1("Q>")
244
+ end
245
+ raise ProtocolError, "frame of #{length} bytes exceeds the cap" if length > MAX_FRAME_BYTES
246
+
247
+ key = nil
248
+ if masked
249
+ key = read_rest(4, deadline)
250
+ return key if key.nil? || key == :timeout
251
+ end
252
+
253
+ payload = length.zero? ? +"".b : read_rest(length, deadline)
254
+ return payload if payload.nil? || payload == :timeout
255
+
256
+ payload = Frame.apply_mask(payload, key) if key
257
+ [fin, opcode, payload]
258
+ end
259
+
260
+ # Read part of a frame whose header has already been consumed. A timeout
261
+ # here leaves the stream mid-frame, so the connection is marked torn and
262
+ # the caller must drop it.
263
+ def read_rest(count, deadline)
264
+ result = read_exactly(count, deadline)
265
+ @torn = true if result == :timeout
266
+ result
267
+ end
268
+
269
+ # Read exactly n bytes. nil on EOF or after #close; :timeout on deadline.
270
+ def read_exactly(count, deadline)
271
+ buffer = +"".b
272
+ if @prebuffer
273
+ take = [count, @prebuffer.bytesize].min
274
+ buffer << @prebuffer.byteslice(0, take)
275
+ rest = @prebuffer.byteslice(take, @prebuffer.bytesize - take)
276
+ @prebuffer = rest.nil? || rest.empty? ? nil : rest
277
+ return buffer if buffer.bytesize == count
278
+ end
279
+ while buffer.bytesize < count
280
+ return nil if @closed
281
+
282
+ chunk =
283
+ begin
284
+ @io.read_nonblock(count - buffer.bytesize, exception: false)
285
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE
286
+ return nil
287
+ end
288
+ if chunk == :wait_readable
289
+ wait = POLL_INTERVAL
290
+ if deadline
291
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
292
+ return :timeout if remaining <= 0
293
+
294
+ wait = remaining < POLL_INTERVAL ? remaining : POLL_INTERVAL
295
+ end
296
+ ready =
297
+ begin
298
+ @io.wait_readable(wait)
299
+ rescue IOError
300
+ return nil
301
+ end
302
+ next if ready
303
+
304
+ return :timeout if deadline && (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)) <= 0
305
+
306
+ next
307
+ end
308
+ return nil if chunk.nil? || chunk == :wait_writable
309
+
310
+ buffer << chunk
311
+ end
312
+ buffer
313
+ end
314
+ end
315
+
316
+ module_function
317
+
318
+ # Dial `url` and complete the RFC 6455 opening handshake.
319
+ #
320
+ # Raises Graphmind::WebSocket::Error on any failure — the transport turns
321
+ # that into "stay detached" plus a rate-limited warning.
322
+ def connect(url, connect_timeout: 0.3, handshake_timeout: 1.0)
323
+ uri = parse_url(url)
324
+ socket =
325
+ begin
326
+ Socket.tcp(uri[:host], uri[:port], connect_timeout: connect_timeout)
327
+ rescue StandardError => e
328
+ raise Error, "could not connect to #{uri[:host]}:#{uri[:port]} (#{e.class}: #{e.message})"
329
+ end
330
+ begin
331
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
332
+ rescue StandardError
333
+ nil
334
+ end
335
+
336
+ io = uri[:tls] ? upgrade_tls(socket, uri[:host]) : socket
337
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + handshake_timeout
338
+ key = Base64.strict_encode64(SecureRandom.bytes(16))
339
+ leftover = nil
340
+ begin
341
+ io.write(handshake_request(uri, key))
342
+ response = read_http_response(io, deadline)
343
+ head, _, leftover = response.partition("\r\n\r\n")
344
+ verify_handshake(head, key)
345
+ rescue StandardError => e
346
+ begin
347
+ io.close
348
+ rescue StandardError
349
+ nil
350
+ end
351
+ raise e.is_a?(Error) ? e : Error.new("handshake failed (#{e.class}: #{e.message})")
352
+ end
353
+ Connection.new(io, mask: true, prebuffer: leftover)
354
+ end
355
+
356
+ def parse_url(url)
357
+ uri = URI.parse(url.to_s)
358
+ scheme = (uri.scheme || "ws").downcase
359
+ raise Error, "unsupported scheme #{scheme.inspect} (expected ws or wss)" unless %w[ws wss].include?(scheme)
360
+ raise Error, "missing host in #{url.inspect}" if uri.host.nil? || uri.host.empty?
361
+
362
+ tls = scheme == "wss"
363
+ path = uri.path.nil? || uri.path.empty? ? "/" : uri.path
364
+ path = "#{path}?#{uri.query}" if uri.query
365
+ {
366
+ host: uri.host,
367
+ port: uri.port || (tls ? 443 : 80),
368
+ path: path,
369
+ tls: tls
370
+ }
371
+ end
372
+
373
+ def handshake_request(uri, key)
374
+ host_header = uri[:port] == (uri[:tls] ? 443 : 80) ? uri[:host] : "#{uri[:host]}:#{uri[:port]}"
375
+ [
376
+ "GET #{uri[:path]} HTTP/1.1",
377
+ "Host: #{host_header}",
378
+ "Upgrade: websocket",
379
+ "Connection: Upgrade",
380
+ "Sec-WebSocket-Key: #{key}",
381
+ "Sec-WebSocket-Version: 13",
382
+ "User-Agent: graphmind-ruby/#{Graphmind::VERSION}",
383
+ "",
384
+ ""
385
+ ].join("\r\n")
386
+ end
387
+
388
+ def accept_token(key)
389
+ Base64.strict_encode64(Digest::SHA1.digest(key + GUID))
390
+ end
391
+
392
+ def read_http_response(io, deadline)
393
+ buffer = +"".b
394
+ until buffer.include?("\r\n\r\n")
395
+ raise Error, "handshake timed out" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
396
+
397
+ chunk = io.read_nonblock(4096, exception: false)
398
+ if chunk == :wait_readable
399
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
400
+ raise Error, "handshake timed out" if remaining <= 0
401
+
402
+ io.wait_readable(remaining) or raise Error, "handshake timed out"
403
+ next
404
+ end
405
+ raise Error, "connection closed during handshake" if chunk.nil?
406
+
407
+ buffer << chunk
408
+ raise Error, "handshake response too large" if buffer.bytesize > 64 * 1024
409
+ end
410
+ buffer
411
+ end
412
+
413
+ def verify_handshake(head, key)
414
+ lines = head.to_s.split("\r\n")
415
+ status = lines.first.to_s
416
+ unless status =~ %r{\AHTTP/1\.1 101}
417
+ raise Error, "viewer refused the upgrade (#{status.empty? ? 'no status line' : status})"
418
+ end
419
+
420
+ headers = {}
421
+ lines[1..].to_a.each do |line|
422
+ name, _, value = line.partition(":")
423
+ headers[name.strip.downcase] = value.strip
424
+ end
425
+ unless headers["upgrade"].to_s.downcase == "websocket"
426
+ raise Error, "missing Upgrade: websocket in the response"
427
+ end
428
+ unless headers["sec-websocket-accept"] == accept_token(key)
429
+ raise Error, "bad Sec-WebSocket-Accept"
430
+ end
431
+
432
+ true
433
+ end
434
+
435
+ def upgrade_tls(socket, host)
436
+ require "openssl"
437
+ context = OpenSSL::SSL::SSLContext.new
438
+ context.verify_mode = OpenSSL::SSL::VERIFY_PEER
439
+ ssl = OpenSSL::SSL::SSLSocket.new(socket, context)
440
+ ssl.hostname = host
441
+ ssl.sync_close = true
442
+ ssl.connect
443
+ ssl
444
+ rescue LoadError => e
445
+ raise Error, "wss:// requires openssl (#{e.message})"
446
+ end
447
+ end
448
+ end