kontena-websocket-client 0.0.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/.gitignore +13 -0
- data/.rspec +3 -0
- data/.travis.yml +21 -0
- data/Gemfile +4 -0
- data/LICENSE +190 -0
- data/README.md +136 -0
- data/Rakefile +6 -0
- data/examples/websocket-echo-client.rb +80 -0
- data/kontena-websocket-client.gemspec +24 -0
- data/lib/kontena/websocket/client/connection.rb +119 -0
- data/lib/kontena/websocket/client/version.rb +13 -0
- data/lib/kontena/websocket/client.rb +848 -0
- data/lib/kontena/websocket/error.rb +81 -0
- data/lib/kontena/websocket/logging.rb +55 -0
- data/lib/kontena/websocket/openssl_patch.rb +10 -0
- data/lib/kontena-websocket-client.rb +15 -0
- metadata +116 -0
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
# Threadsafe: while the #run method is reading/parsing incoming websocket frames, the #send/#ping/#close methods
|
|
2
|
+
# can be called by other threads.
|
|
3
|
+
# The #run (on_open), #on_message and #on_pong blocks will be called from the #run thread.
|
|
4
|
+
#
|
|
5
|
+
#
|
|
6
|
+
=begin example
|
|
7
|
+
def websocket_connect
|
|
8
|
+
Kontena::Websocket::Client.connect(url, ...) do |client|
|
|
9
|
+
on_open(client)
|
|
10
|
+
|
|
11
|
+
client.read do |message|
|
|
12
|
+
on_message(message)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
on_close(client.close_code, client.close_reason) # client closed connection
|
|
16
|
+
end
|
|
17
|
+
rescue Kontena::Websocket::CloseError => exc
|
|
18
|
+
on_close(exc.code, exc.reason) # server closed connection
|
|
19
|
+
rescue Kontena::Websocket::Error => exc
|
|
20
|
+
on_error(exc)
|
|
21
|
+
ensure
|
|
22
|
+
# disconnected
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
=end
|
|
26
|
+
class Kontena::Websocket::Client
|
|
27
|
+
require_relative './client/connection'
|
|
28
|
+
|
|
29
|
+
include Kontena::Websocket::Logging
|
|
30
|
+
|
|
31
|
+
attr_reader :uri
|
|
32
|
+
|
|
33
|
+
FRAME_SIZE = 4 * 1024
|
|
34
|
+
CONNECT_TIMEOUT = 60.0
|
|
35
|
+
OPEN_TIMEOUT = 60.0
|
|
36
|
+
PING_INTERVAL = 60.0
|
|
37
|
+
PING_TIMEOUT = 10.0
|
|
38
|
+
PING_STRFTIME = '%FT%T.%NZ' # high-percision RFC 3339
|
|
39
|
+
CLOSE_TIMEOUT = 60.0
|
|
40
|
+
WRITE_TIMEOUT = 60.0
|
|
41
|
+
|
|
42
|
+
# Connect, send websocket handshake, and loop reading responses.
|
|
43
|
+
#
|
|
44
|
+
# Passed block is called once websocket is open.
|
|
45
|
+
# Raises on errors.
|
|
46
|
+
# Returns once websocket is closed by server.
|
|
47
|
+
#
|
|
48
|
+
# Intended to be called using a dedicated per-websocket thread.
|
|
49
|
+
# Other threads can then call the other threadsafe methods:
|
|
50
|
+
# * send
|
|
51
|
+
# * close
|
|
52
|
+
#
|
|
53
|
+
# @yield [client] websocket open
|
|
54
|
+
# @raise [Kontena::Websocket::ConnectError]
|
|
55
|
+
# @raise [Kontena::Websocket::ProtocolError]
|
|
56
|
+
# @raise [Kontena::Websocket::TimeoutError]
|
|
57
|
+
# @return websocket closed by server, @see #close_code #close_reason
|
|
58
|
+
def self.connect(url, **options, &block)
|
|
59
|
+
client = new(url, **options)
|
|
60
|
+
|
|
61
|
+
client.connect
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
yield client
|
|
65
|
+
ensure
|
|
66
|
+
# ensure socket is closed and client disconnected on any of:
|
|
67
|
+
# * connect error
|
|
68
|
+
# * open error
|
|
69
|
+
# * read error
|
|
70
|
+
# * close
|
|
71
|
+
client.disconnect
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @param [String] url
|
|
76
|
+
# @param headers [Hash{String => String}]
|
|
77
|
+
# @param ssl_params [Hash] @see OpenSSL::SSL::SSLContext
|
|
78
|
+
# The DEFAULT_PARAMS includes verify_mode: OpenSSL::SSL::VERIFY_PEER.
|
|
79
|
+
# Use { verify_mode: OpenSSL::SSL::VERIFY_NONE } to disable Kontena::Websocket::SSLVerifyError on connect.
|
|
80
|
+
# @param ssl_hostname [String] override hostname for SSL SNI, certificate identity matching
|
|
81
|
+
# @param connect_timeout [Float] timeout for TCP handshake; XXX: each phase of the SSL handshake
|
|
82
|
+
# @param open_timeout [Float] expect open frame after #start
|
|
83
|
+
# @param ping_interval [Float] send pings every interval seconds after previous ping
|
|
84
|
+
# @param ping_timeout [Float] expect pong frame after #ping
|
|
85
|
+
# @param close_timeout [Float] expect close frame after #close
|
|
86
|
+
# @param write_timeout [Float] block #send when sending faster than the server is able to receive, fail if no progress is made
|
|
87
|
+
# @raise [ArgumentError] Invalid websocket URI
|
|
88
|
+
def initialize(url,
|
|
89
|
+
headers: {},
|
|
90
|
+
ssl_params: {},
|
|
91
|
+
ssl_hostname: nil,
|
|
92
|
+
connect_timeout: CONNECT_TIMEOUT,
|
|
93
|
+
open_timeout: OPEN_TIMEOUT,
|
|
94
|
+
ping_interval: PING_INTERVAL,
|
|
95
|
+
ping_timeout: PING_TIMEOUT,
|
|
96
|
+
close_timeout: CLOSE_TIMEOUT,
|
|
97
|
+
write_timeout: WRITE_TIMEOUT
|
|
98
|
+
)
|
|
99
|
+
@uri = URI.parse(url)
|
|
100
|
+
@headers = headers
|
|
101
|
+
@ssl_params = ssl_params
|
|
102
|
+
@ssl_hostname = ssl_hostname
|
|
103
|
+
|
|
104
|
+
@connect_timeout = connect_timeout
|
|
105
|
+
@open_timeout = open_timeout
|
|
106
|
+
@ping_interval = ping_interval
|
|
107
|
+
@ping_timeout = ping_timeout
|
|
108
|
+
@close_timeout = close_timeout
|
|
109
|
+
@write_timeout = write_timeout
|
|
110
|
+
|
|
111
|
+
unless @uri.scheme == 'ws' || @uri.scheme == 'wss'
|
|
112
|
+
raise ArgumentError, "Invalid websocket URL: #{@uri}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@mutex = Mutex.new # for @driver
|
|
116
|
+
|
|
117
|
+
# written by #enqueue from @driver callbacks with the @mutex held
|
|
118
|
+
# drained by #read without the @mutex held
|
|
119
|
+
@message_queue = []
|
|
120
|
+
|
|
121
|
+
# sequential ping-pongs
|
|
122
|
+
@ping_at = Time.now # fake for first ping_interval
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @return [String]
|
|
126
|
+
def url
|
|
127
|
+
@uri.to_s
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @return [String] ws or wss
|
|
131
|
+
def scheme
|
|
132
|
+
@uri.scheme
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Boolean]
|
|
136
|
+
def ssl?
|
|
137
|
+
@uri.scheme == 'wss'
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Connecting with SSL cert/host verification?
|
|
141
|
+
#
|
|
142
|
+
# @return [Boolean]
|
|
143
|
+
def ssl_verify?
|
|
144
|
+
ssl? && ssl_context.verify_mode != OpenSSL::SSL::VERIFY_NONE
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [String]
|
|
148
|
+
def ssl_hostname
|
|
149
|
+
@ssl_hostname || @uri.host
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [String]
|
|
153
|
+
def host
|
|
154
|
+
@uri.host
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @return [Integer]
|
|
158
|
+
def port
|
|
159
|
+
@uri.port || (@uri.scheme == "ws" ? 80 : 443)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Connected to server. Not necessarily open yet.
|
|
163
|
+
#
|
|
164
|
+
# @return [Boolean]
|
|
165
|
+
def connected?
|
|
166
|
+
!!@socket && !!@connection && !!@driver
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Client has started websocket handshake, but is not yet open.
|
|
170
|
+
#
|
|
171
|
+
# @return [Boolean]
|
|
172
|
+
def opening?
|
|
173
|
+
# XXX: also true after disconnect
|
|
174
|
+
!!@opening_at && !@opened
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Server has accepted websocket connection.
|
|
178
|
+
#
|
|
179
|
+
# @return [Boolean]
|
|
180
|
+
def open?
|
|
181
|
+
!!@open
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Client has sent close frame, but socket is not yet closed.
|
|
185
|
+
#
|
|
186
|
+
# @return [Boolean]
|
|
187
|
+
def closing?
|
|
188
|
+
!!@closing && !@closed
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Server has closed websocket connection.
|
|
192
|
+
#
|
|
193
|
+
# @return [Boolean]
|
|
194
|
+
def closed?
|
|
195
|
+
!!@closed
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Valid once #run returns, when closed? or closing?
|
|
199
|
+
#
|
|
200
|
+
# @return [Integer]
|
|
201
|
+
def close_code
|
|
202
|
+
@closed_code || @closing_code
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Valid once #run returns, when closed?
|
|
206
|
+
#
|
|
207
|
+
# @return [String]
|
|
208
|
+
def close_reason
|
|
209
|
+
@closed_reason
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Create @socket, @connection and open @driver.
|
|
213
|
+
#
|
|
214
|
+
# Blocks until open. Disconnects if fails.
|
|
215
|
+
#
|
|
216
|
+
# @raise [Kontena::Websocket::ConnectError]
|
|
217
|
+
# @raise [Kontena::Websocket::TimeoutError]
|
|
218
|
+
# @return once websocket is open
|
|
219
|
+
def connect
|
|
220
|
+
@connection = self.socket_connect
|
|
221
|
+
|
|
222
|
+
@driver = self.websocket_open(@connection)
|
|
223
|
+
|
|
224
|
+
# blocks
|
|
225
|
+
self.websocket_read until @opened
|
|
226
|
+
|
|
227
|
+
rescue
|
|
228
|
+
disconnect
|
|
229
|
+
raise
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @raise [RuntimeError] not connected
|
|
233
|
+
# @return [nil] not an ssl connection, or no peer cert
|
|
234
|
+
# @return [OpenSSL::X509::Certificate]
|
|
235
|
+
def ssl_cert
|
|
236
|
+
fail "not connected" unless @socket
|
|
237
|
+
return nil unless ssl?
|
|
238
|
+
|
|
239
|
+
return @socket.peer_cert
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Verify and return SSL cert. Validates even if not ssl_verify.
|
|
243
|
+
#
|
|
244
|
+
# @raise [RuntimeError] not connected
|
|
245
|
+
# @raise [Kontena::Websocket::SSLVerifyError]
|
|
246
|
+
# @return [nil] not an ssl connection
|
|
247
|
+
# @return [OpenSSL::X509::Certificate]
|
|
248
|
+
def ssl_cert!
|
|
249
|
+
fail "not connected" unless @socket
|
|
250
|
+
return nil unless ssl?
|
|
251
|
+
|
|
252
|
+
# raises Kontena::Websocket::SSLVerifyError
|
|
253
|
+
self.ssl_verify_cert! @socket.peer_cert, @socket.peer_cert_chain
|
|
254
|
+
|
|
255
|
+
return @socket.peer_cert
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Valid once open
|
|
259
|
+
#
|
|
260
|
+
# @raise [RuntimeError] not connected
|
|
261
|
+
# @return [Integer]
|
|
262
|
+
def http_status
|
|
263
|
+
with_driver do |driver|
|
|
264
|
+
driver.status
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Valid once open
|
|
269
|
+
#
|
|
270
|
+
# @raise [RuntimeError] not connected
|
|
271
|
+
# @return [Websocket::Driver::Headers]
|
|
272
|
+
def http_headers
|
|
273
|
+
with_driver do |driver|
|
|
274
|
+
driver.headers
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Read messages from websocket.
|
|
279
|
+
#
|
|
280
|
+
# If a block is given, then this loops and yields messages until closed.
|
|
281
|
+
# Otherwise, returns the next message, or nil if closed.
|
|
282
|
+
#
|
|
283
|
+
# @raise [Kontena::Websocket::CloseError]
|
|
284
|
+
def read(&block)
|
|
285
|
+
if block
|
|
286
|
+
read_yield(&block)
|
|
287
|
+
else
|
|
288
|
+
read_return
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Send message frame, either text or binary.
|
|
293
|
+
#
|
|
294
|
+
# @param message [String, Array<Integer>]
|
|
295
|
+
# @raise [ArgumentError] invalid type
|
|
296
|
+
# @raise [RuntimeError] unable to send (socket closed?)
|
|
297
|
+
def send(message)
|
|
298
|
+
case message
|
|
299
|
+
when String
|
|
300
|
+
with_driver do |driver|
|
|
301
|
+
fail unless driver.text(message)
|
|
302
|
+
end
|
|
303
|
+
when Array
|
|
304
|
+
with_driver do |driver|
|
|
305
|
+
fail unless driver.binary(message)
|
|
306
|
+
end
|
|
307
|
+
else
|
|
308
|
+
raise ArgumentError, "Invalid type: #{message.class}"
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Register pong handler.
|
|
313
|
+
# Called from the #read thread every ping_interval after received pong.
|
|
314
|
+
# XXX: called with driver @mutex locked
|
|
315
|
+
#
|
|
316
|
+
# The ping interval should be longer than the ping timeout.
|
|
317
|
+
# If new pings are sent before old pings get any response, then the older pings do not yield on pong.
|
|
318
|
+
#
|
|
319
|
+
# @yield [delay] received pong
|
|
320
|
+
# @yieldparam delay [Float] ping-pong delay in seconds
|
|
321
|
+
def on_pong(&block)
|
|
322
|
+
@on_pong = block
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Send ping message.
|
|
326
|
+
#
|
|
327
|
+
# This is intended to be automatically called from #run per @ping_interval.
|
|
328
|
+
# Calling it from the #run callbacks also works, but calling it from a different thread
|
|
329
|
+
# while #run is blocked on read() will ignore the @ping_timeout, unless the server happens
|
|
330
|
+
# to send something else.
|
|
331
|
+
#
|
|
332
|
+
# @raise [RuntimeError] not connected
|
|
333
|
+
def ping
|
|
334
|
+
with_driver do |driver|
|
|
335
|
+
# start ping timeout for next read
|
|
336
|
+
ping_at = pinging!
|
|
337
|
+
|
|
338
|
+
debug "pinging at #{ping_at}"
|
|
339
|
+
|
|
340
|
+
fail unless driver.ping(ping_at.utc.strftime(PING_STRFTIME)) do
|
|
341
|
+
# called from read -> @driver.parse with @mutex held!
|
|
342
|
+
debug "pong for #{ping_at}"
|
|
343
|
+
|
|
344
|
+
# resolve ping timeout, unless this pong is late and we already sent a new one
|
|
345
|
+
if ping_at == @ping_at
|
|
346
|
+
ping_delay = pinged!
|
|
347
|
+
|
|
348
|
+
debug "ping-pong at #{ping_at} in #{ping_delay}s"
|
|
349
|
+
|
|
350
|
+
# XXX: defer call without mutex?
|
|
351
|
+
@on_pong.call(ping_delay) # TODO: also pass ping_at
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Waiting for pong from ping
|
|
358
|
+
#
|
|
359
|
+
# @return [Boolean]
|
|
360
|
+
def pinging?
|
|
361
|
+
!!@pinging
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Measured ping-pong delay from previous ping
|
|
365
|
+
#
|
|
366
|
+
# nil if not pinged yet
|
|
367
|
+
#
|
|
368
|
+
# @return [Float, nil]
|
|
369
|
+
def ping_delay
|
|
370
|
+
@ping_delay
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Send close frame. Does not disconnect, but allows #read to return once server completes close handshake.
|
|
374
|
+
#
|
|
375
|
+
# Imposes a close timeout when called from #run blocks (run/on_message/on_pong do ...). If called from
|
|
376
|
+
# a different thread, then #run should eventually return, once either:
|
|
377
|
+
# * server sends close frame
|
|
378
|
+
# * server sends any other frame after the close timeout expires
|
|
379
|
+
# * the ping interval expires
|
|
380
|
+
#
|
|
381
|
+
# XXX: prevent #send after close?
|
|
382
|
+
#
|
|
383
|
+
# @param close [Integer]
|
|
384
|
+
# @param reason [String]
|
|
385
|
+
def close(code = 1000, reason = nil)
|
|
386
|
+
debug "close code=#{code}: #{reason}"
|
|
387
|
+
|
|
388
|
+
with_driver do |driver|
|
|
389
|
+
fail unless driver.close(reason, code) # swapped argument order
|
|
390
|
+
|
|
391
|
+
closing! code
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Clear connection state, close socket.
|
|
396
|
+
# Does not send websocket close frame, or wait for server to close.
|
|
397
|
+
def disconnect
|
|
398
|
+
debug "disconnect"
|
|
399
|
+
|
|
400
|
+
@open = false
|
|
401
|
+
@driver = nil
|
|
402
|
+
@connection = nil
|
|
403
|
+
|
|
404
|
+
# TODO: errors and timeout? SSLSocket.close in particular is bidirectional?
|
|
405
|
+
@socket.close if @socket
|
|
406
|
+
@socket = nil
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
#protected XXX: called by specs TODO: refactor out to separate TCP/SSL client classes
|
|
410
|
+
|
|
411
|
+
# Call into driver with locked Mutex
|
|
412
|
+
#
|
|
413
|
+
# @raise [RuntimeError] not connected
|
|
414
|
+
# @yield [driver]
|
|
415
|
+
# @yieldparam driver [Websocket::Driver]
|
|
416
|
+
def with_driver
|
|
417
|
+
@mutex.synchronize {
|
|
418
|
+
fail "not connected" unless @driver
|
|
419
|
+
|
|
420
|
+
yield @driver
|
|
421
|
+
}
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Connect to TCP server.
|
|
425
|
+
#
|
|
426
|
+
# @raise [Kontena::Websocket::TimeoutError] Errno::ETIMEDOUT
|
|
427
|
+
# @raise [Kontena::Websocket::ConnectError] Errno::*
|
|
428
|
+
# @raise [Kontena::Websocket::ConnectError] SocketError
|
|
429
|
+
# @return [TCPSocket]
|
|
430
|
+
def connect_tcp
|
|
431
|
+
debug "connect_tcp: timeout=#{@connect_timeout}"
|
|
432
|
+
|
|
433
|
+
Socket.tcp(self.host, self.port, connect_timeout: @connect_timeout)
|
|
434
|
+
rescue Errno::ETIMEDOUT => exc
|
|
435
|
+
raise Kontena::Websocket::TimeoutError, "Connect timeout after #{@connect_timeout}s" # XXX: actual delay
|
|
436
|
+
rescue SocketError => exc
|
|
437
|
+
raise Kontena::Websocket::ConnectError, exc
|
|
438
|
+
rescue SystemCallError => exc
|
|
439
|
+
raise Kontena::Websocket::ConnectError, exc
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# @raise [ArgumentError] Failed adding cert store file/path: ...
|
|
443
|
+
# @return [OpenSSL::X509::Store]
|
|
444
|
+
def ssl_cert_store
|
|
445
|
+
@ssl_cert_store ||= OpenSSL::X509::Store.new.tap do |ssl_cert_store|
|
|
446
|
+
ca_file = @ssl_params[:ca_file] || ENV['SSL_CERT_FILE']
|
|
447
|
+
ca_path = @ssl_params[:ca_path] || ENV['SSL_CERT_PATH']
|
|
448
|
+
|
|
449
|
+
if ca_file || ca_path
|
|
450
|
+
if ca_file
|
|
451
|
+
debug "add cert store file: #{ca_file}"
|
|
452
|
+
|
|
453
|
+
begin
|
|
454
|
+
ssl_cert_store.add_file ca_file
|
|
455
|
+
rescue OpenSSL::X509::StoreError
|
|
456
|
+
raise ArgumentError, "Failed adding cert store file: #{ca_file}"
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
if ca_path
|
|
461
|
+
debug "add cert store path: #{ca_path}"
|
|
462
|
+
|
|
463
|
+
begin
|
|
464
|
+
# XXX: does not actually raise
|
|
465
|
+
ssl_cert_store.add_path ca_path
|
|
466
|
+
rescue OpenSSL::X509::StoreError
|
|
467
|
+
raise ArgumentError, "Failed adding cert store path: #{ca_path}"
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
else
|
|
471
|
+
debug "use default cert store paths"
|
|
472
|
+
|
|
473
|
+
ssl_cert_store.set_default_paths
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# @param ssl_cert [OpenSSL::X509::Certificate]
|
|
479
|
+
# @raise [Kontena::Websocket::SSLVerifyError]
|
|
480
|
+
def ssl_verify_cert!(ssl_cert, ssl_cert_chain)
|
|
481
|
+
unless ssl_cert
|
|
482
|
+
raise Kontena::Websocket::SSLVerifyError.new(OpenSSL::X509::V_OK, ssl_cert, ssl_cert_chain), "No certificate"
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
ssl_verify_context = OpenSSL::X509::StoreContext.new(ssl_cert_store, ssl_cert, ssl_cert_chain)
|
|
486
|
+
|
|
487
|
+
unless ssl_verify_context.verify
|
|
488
|
+
raise Kontena::Websocket::SSLVerifyError.new(ssl_verify_context.error, ssl_cert, ssl_cert_chain), ssl_verify_context.error_string
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
unless OpenSSL::SSL.verify_certificate_identity(ssl_cert, self.ssl_hostname)
|
|
492
|
+
raise Kontena::Websocket::SSLVerifyError.new(OpenSSL::X509::V_OK, ssl_cert, ssl_cert_chain), "Subject does not match hostname #{self.ssl_hostname}: #{ssl_cert.subject}"
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# @param verify_result [Integer] OpenSSL::SSL::SSLSocket#verify_result
|
|
497
|
+
# @return [Kontena::Websocket::SSLVerifyError]
|
|
498
|
+
def ssl_verify_error(verify_result, ssl_cert = nil, ssl_cert_chain = nil)
|
|
499
|
+
ssl_verify_context = OpenSSL::X509::StoreContext.new(ssl_cert_store)
|
|
500
|
+
ssl_verify_context.error = verify_result
|
|
501
|
+
|
|
502
|
+
Kontena::Websocket::SSLVerifyError.new(ssl_verify_context.error, ssl_cert, ssl_cert_chain, ssl_verify_context.error_string)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# @return [OpenSSL::SSL::SSLContext]
|
|
506
|
+
def ssl_context
|
|
507
|
+
@ssl_context ||= OpenSSL::SSL::SSLContext.new().tap do |ssl_context|
|
|
508
|
+
params = {
|
|
509
|
+
cert_store: self.ssl_cert_store,
|
|
510
|
+
**@ssl_params
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if Kontena::Websocket::Client.ruby_version? '2.4'
|
|
514
|
+
# prefer our own ssl_verify_cert! logic
|
|
515
|
+
params[:verify_hostname] = false
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
ssl_context.set_params(params)
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# TODO: connect_deadline to impose a single deadline on the entire process
|
|
523
|
+
# XXX: specs
|
|
524
|
+
#
|
|
525
|
+
# @param ssl_socket [OpenSSL::SSL::SSLSocket]
|
|
526
|
+
# @raise [OpenSSL::SSL::SSLError]
|
|
527
|
+
# @raise [Kontena::Websocket::TimeoutError]
|
|
528
|
+
def ssl_connect(ssl_socket)
|
|
529
|
+
debug "ssl_connect..."
|
|
530
|
+
ret = ssl_socket.connect_nonblock
|
|
531
|
+
rescue IO::WaitReadable
|
|
532
|
+
debug "ssl_connect wait read: timeout=#{@connect_timeout}"
|
|
533
|
+
ssl_socket.wait_readable(@connect_timeout) or raise Kontena::Websocket::TimeoutError, "SSL connect read timeout after #{@connect_timeout}s"
|
|
534
|
+
retry
|
|
535
|
+
rescue IO::WaitWritable
|
|
536
|
+
debug "ssl_connect wait write: timeout=#{@connect_timeout}"
|
|
537
|
+
ssl_socket.wait_writable(@connect_timeout) or raise Kontena::Websocket::TimeoutError, "SSL connect write timeout after #{@connect_timeout}s"
|
|
538
|
+
retry
|
|
539
|
+
else
|
|
540
|
+
debug "ssl_connect: #{ret}"
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Connect to TCP server, perform SSL handshake, verify if required.
|
|
544
|
+
#
|
|
545
|
+
# @raise [Kontena::Websocket::ConnectError] from connect_tcp
|
|
546
|
+
# @raise [Kontena::Websocket::SSLConnectError]
|
|
547
|
+
# @raise [Kontena::Websocket::SSLVerifyError] errors that only happen with ssl_verify: true
|
|
548
|
+
# @return [OpenSSL::SSL::SSLSocket]
|
|
549
|
+
def connect_ssl
|
|
550
|
+
tcp_socket = self.connect_tcp
|
|
551
|
+
ssl_context = self.ssl_context
|
|
552
|
+
|
|
553
|
+
debug "connect_ssl: #{ssl_context.inspect} hostname=#{self.ssl_hostname}"
|
|
554
|
+
|
|
555
|
+
ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
|
|
556
|
+
ssl_socket.sync_close = true # close TCPSocket after SSL shutdown
|
|
557
|
+
ssl_socket.hostname = self.ssl_hostname # SNI
|
|
558
|
+
|
|
559
|
+
begin
|
|
560
|
+
self.ssl_connect(ssl_socket)
|
|
561
|
+
rescue OpenSSL::SSL::SSLError => exc
|
|
562
|
+
# SSL_connect returned=1 errno=0 state=error: certificate verify failed
|
|
563
|
+
if exc.message.end_with? 'certificate verify failed'
|
|
564
|
+
# ssl_socket.peer_cert is not set on errors :(
|
|
565
|
+
raise ssl_verify_error(ssl_socket.verify_result)
|
|
566
|
+
else
|
|
567
|
+
raise Kontena::Websocket::SSLConnectError, exc
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# raises Kontena::Websocket::SSLVerifyError
|
|
572
|
+
self.ssl_verify_cert!(ssl_socket.peer_cert, ssl_socket.peer_cert_chain) if ssl_verify?
|
|
573
|
+
|
|
574
|
+
ssl_socket
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# @return [Connection]
|
|
578
|
+
def socket_connect
|
|
579
|
+
if ssl?
|
|
580
|
+
@socket = self.connect_ssl
|
|
581
|
+
else
|
|
582
|
+
@socket = self.connect_tcp
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
return Connection.new(@uri, @socket,
|
|
586
|
+
write_timeout: @write_timeout,
|
|
587
|
+
)
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# Create websocket driver using connection, and send websocket handshake.
|
|
591
|
+
# Registers driver handlers to set @open, @closed states, enqueue messages, or raise errors.
|
|
592
|
+
#
|
|
593
|
+
# @param connection [Kontena::Websocket::Client::Connection]
|
|
594
|
+
# @raise [RuntimeError] already started?
|
|
595
|
+
# @return [WebSocket::Driver::Client]
|
|
596
|
+
def websocket_open(connection)
|
|
597
|
+
debug "websocket open..."
|
|
598
|
+
|
|
599
|
+
driver = ::WebSocket::Driver.client(connection)
|
|
600
|
+
|
|
601
|
+
@headers.each do |k, v|
|
|
602
|
+
driver.set_header(k, v)
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# these are called from read_loop -> with_driver { driver.parse } with the @mutex held
|
|
606
|
+
# do not recurse back into with_driver!
|
|
607
|
+
driver.on :error do |event|
|
|
608
|
+
self.on_driver_error(event)
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
driver.on :open do |event|
|
|
612
|
+
self.on_driver_open(event)
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
driver.on :message do |event|
|
|
616
|
+
self.on_driver_message(event)
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
driver.on :close do |event|
|
|
620
|
+
self.on_driver_close(event)
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# not expected to emit anything, not even :error
|
|
624
|
+
fail unless driver.start
|
|
625
|
+
|
|
626
|
+
debug "opening"
|
|
627
|
+
|
|
628
|
+
opening!
|
|
629
|
+
|
|
630
|
+
return driver
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# @param exc [WebSocket::Driver::ProtocolError]
|
|
634
|
+
def on_driver_error(exc)
|
|
635
|
+
# this will presumably propagate up out of #recv_loop, not this function
|
|
636
|
+
raise exc
|
|
637
|
+
|
|
638
|
+
rescue WebSocket::Driver::ProtocolError => exc
|
|
639
|
+
raise Kontena::Websocket::ProtocolError, exc
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# Mark client as opened, calling the block passed to #run.
|
|
643
|
+
#
|
|
644
|
+
# @param event [WebSocket::Driver::OpenEvent] no attrs
|
|
645
|
+
def on_driver_open(event)
|
|
646
|
+
debug "opened"
|
|
647
|
+
|
|
648
|
+
opened!
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
# Queue up received messages
|
|
652
|
+
# Causes #read to return/yield once websocket_read returns with the driver mutex unlocked.
|
|
653
|
+
#
|
|
654
|
+
# @param event [WebSocket::Driver::MessageEvent] data
|
|
655
|
+
def on_driver_message(event)
|
|
656
|
+
@message_queue << event.data
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
# Mark client as closed, allowing #run to return (and disconnect from the server).
|
|
660
|
+
#
|
|
661
|
+
# @param event [WebSocket::Driver::CloseEvent] code, reason
|
|
662
|
+
def on_driver_close(event)
|
|
663
|
+
debug "closed code=#{event.code}: #{event.reason}"
|
|
664
|
+
|
|
665
|
+
closed! event.code, event.reason
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Start read deadline for @open_timeout
|
|
669
|
+
def opening!
|
|
670
|
+
@opening_at = Time.now
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# Server completed open handshake.
|
|
674
|
+
def opened!
|
|
675
|
+
@opened = true
|
|
676
|
+
@open = true
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Start read deadline for @ping_timeout
|
|
680
|
+
#
|
|
681
|
+
# @return [Time] ping at
|
|
682
|
+
def pinging!
|
|
683
|
+
@pinging = true
|
|
684
|
+
@ping_at = Time.now
|
|
685
|
+
@pong_at = nil
|
|
686
|
+
|
|
687
|
+
@ping_at
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
# Stop read deadline for @ping_timeout
|
|
691
|
+
# @return [Float] ping delay
|
|
692
|
+
def pinged!
|
|
693
|
+
@pinging = false
|
|
694
|
+
@pong_at = Time.now
|
|
695
|
+
@ping_delay = @pong_at - @ping_at
|
|
696
|
+
|
|
697
|
+
@ping_delay
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# Client closing connection with code.
|
|
701
|
+
#
|
|
702
|
+
# Start read deadline for @open_timeout
|
|
703
|
+
#
|
|
704
|
+
# @param code [Integer] expecting close frame from server with matching code
|
|
705
|
+
def closing!(code)
|
|
706
|
+
@open = false # fail any further sends after close
|
|
707
|
+
@closing = true
|
|
708
|
+
@closing_at = Time.now
|
|
709
|
+
@closing_code = code # cheked by #read_closed
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# Server closed, completing close handshake if closing.
|
|
713
|
+
#
|
|
714
|
+
# @param code [Integer]
|
|
715
|
+
# @param reason [String]
|
|
716
|
+
def closed!(code, reason)
|
|
717
|
+
@open = false
|
|
718
|
+
@closed = true
|
|
719
|
+
@closed_code = code
|
|
720
|
+
@closed_reason = reason
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# @raise [Kontena::Websocket::CloseError] server closed connection without client closing
|
|
724
|
+
# @return [Boolean] connection was closed
|
|
725
|
+
def read_closed
|
|
726
|
+
if !@closed
|
|
727
|
+
return false
|
|
728
|
+
elsif @closing && @closing_code == @closed_code
|
|
729
|
+
# client sent close, server responded with close
|
|
730
|
+
return true
|
|
731
|
+
else
|
|
732
|
+
raise Kontena::Websocket::CloseError.new(@closed_code, @closed_reason)
|
|
733
|
+
end
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# @raise [Kontena::Websocket::CloseError]
|
|
737
|
+
# @return [String, Array<Integer>] nil when websocket closed
|
|
738
|
+
def read_return
|
|
739
|
+
# important to first drain message queue before failing on read_closed!
|
|
740
|
+
while @message_queue.empty? && !self.read_closed
|
|
741
|
+
self.websocket_read
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
# returns nil if @closed, once the message queue is empty
|
|
745
|
+
return @message_queue.shift
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# @raise [Kontena::Websocket::CloseError]
|
|
749
|
+
# @yield [message] received websocket message payload
|
|
750
|
+
# @yieldparam message [String, Array<integer>] text or binary
|
|
751
|
+
# @return websocket closed
|
|
752
|
+
def read_yield
|
|
753
|
+
loop do
|
|
754
|
+
while msg = @message_queue.shift
|
|
755
|
+
yield msg
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
# drain queue before returning on close
|
|
759
|
+
return if self.read_closed
|
|
760
|
+
|
|
761
|
+
self.websocket_read
|
|
762
|
+
end
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# Return read deadline for current read state
|
|
766
|
+
#
|
|
767
|
+
# @return [Symbol, Float, Float] state, at, timeout
|
|
768
|
+
def read_state_timeout
|
|
769
|
+
case
|
|
770
|
+
when opening? && @open_timeout
|
|
771
|
+
[:open, @opening_at, @open_timeout]
|
|
772
|
+
when pinging? && @ping_timeout
|
|
773
|
+
[:pong, @ping_at, @ping_timeout]
|
|
774
|
+
when closing? && @close_timeout
|
|
775
|
+
[:close, @closing_at, @close_timeout]
|
|
776
|
+
when @ping_interval
|
|
777
|
+
[:ping, @ping_at, @ping_interval]
|
|
778
|
+
else
|
|
779
|
+
[nil, nil, nil]
|
|
780
|
+
end
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
# Read socket with timeout, parse websocket frames via @driver, emitting on-driver_* to enqueue messages.
|
|
784
|
+
# Sends ping on interval timeout.
|
|
785
|
+
def websocket_read
|
|
786
|
+
read_state, state_start, state_timeout = self.read_state_timeout
|
|
787
|
+
|
|
788
|
+
if state_timeout
|
|
789
|
+
read_deadline = state_start + state_timeout
|
|
790
|
+
read_timeout = read_deadline - Time.now
|
|
791
|
+
else
|
|
792
|
+
read_timeout = nil
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
debug "read (#{read_state})"
|
|
796
|
+
|
|
797
|
+
begin
|
|
798
|
+
# read from socket
|
|
799
|
+
data = self.socket_read(read_timeout)
|
|
800
|
+
rescue Kontena::Websocket::TimeoutError => exc
|
|
801
|
+
if read_state == :ping
|
|
802
|
+
debug "ping on #{exc}"
|
|
803
|
+
self.ping
|
|
804
|
+
elsif read_state
|
|
805
|
+
raise exc.class.new("#{exc} while waiting #{state_timeout}s for #{read_state}")
|
|
806
|
+
else
|
|
807
|
+
raise
|
|
808
|
+
end
|
|
809
|
+
else
|
|
810
|
+
# parse data with @driver @mutex held
|
|
811
|
+
with_driver do |driver|
|
|
812
|
+
# call into the driver, causing it to emit the events registered in #start
|
|
813
|
+
driver.parse(data)
|
|
814
|
+
end
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
# Read from socket with timeout, parse websocket frames.
|
|
819
|
+
# Invokes on_driver_*, which enqueues messages.
|
|
820
|
+
# The websocket must be connected.
|
|
821
|
+
#
|
|
822
|
+
# @param timeout [Flaot] seconds
|
|
823
|
+
# @raise [Kontena::Websocket::TimeoutError] read deadline expired
|
|
824
|
+
# @raise [Kontena::Websocket::TimeoutError] read timeout after X.Ys
|
|
825
|
+
# @raise [Kontena::Websocket::SocketError]
|
|
826
|
+
def socket_read(timeout = nil)
|
|
827
|
+
if timeout && timeout <= 0.0
|
|
828
|
+
raise Kontena::Websocket::TimeoutError, "read deadline expired"
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
begin
|
|
832
|
+
data = @connection.read(FRAME_SIZE, timeout: timeout)
|
|
833
|
+
|
|
834
|
+
rescue EOFError => exc
|
|
835
|
+
debug "read EOF"
|
|
836
|
+
|
|
837
|
+
raise Kontena::Websocket::EOFError, 'Server closed connection without sending close frame'
|
|
838
|
+
|
|
839
|
+
rescue IOError => exc
|
|
840
|
+
# socket was closed => IOError: closed stream
|
|
841
|
+
debug "read IOError: #{exc}"
|
|
842
|
+
|
|
843
|
+
raise Kontena::Websocket::SocketError, exc
|
|
844
|
+
|
|
845
|
+
# TODO: Errno::ECONNRESET etc
|
|
846
|
+
end
|
|
847
|
+
end
|
|
848
|
+
end
|