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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE +21 -0
- data/README.md +464 -0
- data/lib/graphmind/api.rb +267 -0
- data/lib/graphmind/clock.rb +49 -0
- data/lib/graphmind/edit_guard.rb +87 -0
- data/lib/graphmind/env.rb +83 -0
- data/lib/graphmind/errors.rb +40 -0
- data/lib/graphmind/gate_engine.rb +282 -0
- data/lib/graphmind/held_ledger.rb +209 -0
- data/lib/graphmind/ids.rb +52 -0
- data/lib/graphmind/integrations/ruby_llm.rb +449 -0
- data/lib/graphmind/integrations/ruby_openai.rb +388 -0
- data/lib/graphmind/integrations/support.rb +459 -0
- data/lib/graphmind/jack.rb +201 -0
- data/lib/graphmind/loop_guard.rb +516 -0
- data/lib/graphmind/protocol.rb +123 -0
- data/lib/graphmind/railtie.rb +66 -0
- data/lib/graphmind/redaction.rb +781 -0
- data/lib/graphmind/ring_buffer.rb +75 -0
- data/lib/graphmind/runtime.rb +50 -0
- data/lib/graphmind/safe.rb +48 -0
- data/lib/graphmind/session.rb +1180 -0
- data/lib/graphmind/shrink.rb +787 -0
- data/lib/graphmind/token_batcher.rb +84 -0
- data/lib/graphmind/transport.rb +331 -0
- data/lib/graphmind/version.rb +5 -0
- data/lib/graphmind/websocket.rb +448 -0
- data/lib/graphmind/wrap.rb +295 -0
- data/lib/graphmind.rb +54 -0
- metadata +83 -0
|
@@ -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
|