webtube 1.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/GPL-3 +674 -0
- data/Manifest.txt +9 -0
- data/README +779 -0
- data/bin/wsc +239 -0
- data/lib/webtube.rb +757 -0
- data/lib/webtube/vital-statistics.rb +74 -0
- data/lib/webtube/webrick.rb +187 -0
- data/sample-server.rb +47 -0
- data/webtube.gemspec +17 -0
- metadata +55 -0
data/bin/wsc
ADDED
@@ -0,0 +1,239 @@
|
|
1
|
+
#! /usr/bin/ruby
|
2
|
+
|
3
|
+
# WebSocketCat, a primitive CLI tool for manually talking to WebSocket servers
|
4
|
+
|
5
|
+
require 'base64'
|
6
|
+
require 'getoptlong'
|
7
|
+
require 'webtube'
|
8
|
+
|
9
|
+
VERSION_DATA = "WebSocketCat 1.0.0 (Webtube 1.0.0)
|
10
|
+
Copyright (C) 2014 Andres Soolo
|
11
|
+
Copyright (C) 2014 Knitten Development Ltd.
|
12
|
+
|
13
|
+
Licensed under GPLv3+: GNU GPL version 3 or later
|
14
|
+
<http://gnu.org/licenses/gpl.html>
|
15
|
+
|
16
|
+
This is free software: you are free to change and
|
17
|
+
redistribute it.
|
18
|
+
|
19
|
+
There is NO WARRANTY, to the extent permitted by law.
|
20
|
+
|
21
|
+
"
|
22
|
+
|
23
|
+
USAGE = "Usage: wsc [options] ws[s]://host[:port][/path]
|
24
|
+
|
25
|
+
Interact with a WebSocket server, telnet-style.
|
26
|
+
|
27
|
+
--header, -H=name:value
|
28
|
+
Use the given HTTP header field in the request.
|
29
|
+
|
30
|
+
--insecure, -k
|
31
|
+
Allow connecting to an SSL server with invalid certificate.
|
32
|
+
|
33
|
+
--help
|
34
|
+
Print this usage.
|
35
|
+
|
36
|
+
--version
|
37
|
+
Show version data.
|
38
|
+
|
39
|
+
Report bugs to: <dig@mirky.net>
|
40
|
+
|
41
|
+
"
|
42
|
+
|
43
|
+
ONLINE_HELP = "WebSocketCat's commands are slash-prefixed.
|
44
|
+
|
45
|
+
/ping [message]
|
46
|
+
Send a ping frame to the server.
|
47
|
+
|
48
|
+
/close [status [explanation]]
|
49
|
+
Send a close frame to the server. The status code is specified as an
|
50
|
+
unsigned decimal number.
|
51
|
+
|
52
|
+
/N [payload]
|
53
|
+
Send a message or control frame of opcode [[N]], given as a single hex digit,
|
54
|
+
to the server. Per protocol specification, [[/1]] is text message, [[/2]] is
|
55
|
+
binary message, [[/8]] is close, [[/9]] is ping, [[/A]] is pong. Other
|
56
|
+
opcodes can have application-specific meaning. Note that the specification
|
57
|
+
requires kicking clients (or servers) who send messages so cryptic that the
|
58
|
+
server (or client) can't understand them.
|
59
|
+
|
60
|
+
/help
|
61
|
+
Show this online help.
|
62
|
+
|
63
|
+
If you need to start a text message with a slash, you can double it for escape,
|
64
|
+
or you can use the explicit [[/1]] command. EOF from stdin is equivalent to
|
65
|
+
[[/close 1000]].
|
66
|
+
|
67
|
+
"
|
68
|
+
|
69
|
+
$header = {} # lowercased field name => value
|
70
|
+
$insecure = false
|
71
|
+
|
72
|
+
$0 = 'wsc' # for [[GetoptLong]] error reporting
|
73
|
+
begin
|
74
|
+
GetoptLong.new(
|
75
|
+
['--header', '-H', GetoptLong::REQUIRED_ARGUMENT],
|
76
|
+
['--insecure', '-k', GetoptLong::NO_ARGUMENT],
|
77
|
+
['--help', GetoptLong::NO_ARGUMENT],
|
78
|
+
['--version', GetoptLong::NO_ARGUMENT],
|
79
|
+
).each do |opt, arg|
|
80
|
+
case opt
|
81
|
+
when '--header' then
|
82
|
+
name, value = arg.split /\s*:\s*/, 2
|
83
|
+
if value.nil? then
|
84
|
+
$stderr.puts "wsc: colon missing in argument to --header"
|
85
|
+
exit 1
|
86
|
+
end
|
87
|
+
name.downcase!
|
88
|
+
if $header[name] then
|
89
|
+
# The value was specified multiple times.
|
90
|
+
$header[name] += ", " + value
|
91
|
+
else
|
92
|
+
$header[name] = value
|
93
|
+
end
|
94
|
+
when '--insecure' then
|
95
|
+
$insecure = true
|
96
|
+
when '--help' then
|
97
|
+
puts USAGE
|
98
|
+
exit 0
|
99
|
+
when '--version' then
|
100
|
+
puts VERSION_DATA
|
101
|
+
exit 0
|
102
|
+
else
|
103
|
+
raise 'assertion failed'
|
104
|
+
end
|
105
|
+
end
|
106
|
+
rescue GetoptLong::Error => e
|
107
|
+
# no need to display; it has already been reported
|
108
|
+
exit 1
|
109
|
+
end
|
110
|
+
|
111
|
+
unless ARGV.length == 1 then
|
112
|
+
$stderr.puts "wsc: argument mismatch (exactly one needed)"
|
113
|
+
exit 1
|
114
|
+
end
|
115
|
+
|
116
|
+
# The events incoming over the WebSocket will be listened to by this object,
|
117
|
+
# and promptly shown to the user.
|
118
|
+
|
119
|
+
class << $listener = Object.new
|
120
|
+
def onopen webtube
|
121
|
+
puts "*** open"
|
122
|
+
return
|
123
|
+
end
|
124
|
+
|
125
|
+
def onmessage webtube, content, opcode
|
126
|
+
if opcode == 1 then
|
127
|
+
puts "<<< #{content}"
|
128
|
+
else
|
129
|
+
puts "<#{opcode}< #{content.inspect}"
|
130
|
+
end
|
131
|
+
return
|
132
|
+
end
|
133
|
+
|
134
|
+
def oncontrolframe webtube, frame
|
135
|
+
# We'll ignore 9 (ping) and 10 (pong) here, as they are already processed
|
136
|
+
# by handlers of their own.
|
137
|
+
unless [9, 10].include? frame.opcode then
|
138
|
+
puts "*#{'%X' % opcode}* #{frame.payload.inspect}"
|
139
|
+
end
|
140
|
+
return
|
141
|
+
end
|
142
|
+
|
143
|
+
def onping webtube, frame
|
144
|
+
puts "*** ping #{frame.payload.inspect}"
|
145
|
+
return
|
146
|
+
end
|
147
|
+
|
148
|
+
def onpong webtube, frame
|
149
|
+
puts "*** pong #{frame.payload.inspect}"
|
150
|
+
return
|
151
|
+
end
|
152
|
+
|
153
|
+
def onannoyedclose webtube, frame
|
154
|
+
if frame.body.bytesize >= 2 then
|
155
|
+
status_code, = frame.body.unpack 'n'
|
156
|
+
message = frame.body.byteslice 2 .. -1
|
157
|
+
message.force_encoding 'UTF-8'
|
158
|
+
message.force_encoding 'ASCII-8BIT' unless message.valid_encoding?
|
159
|
+
message = nil if message.empty?
|
160
|
+
else
|
161
|
+
status_code = nil
|
162
|
+
message = nil
|
163
|
+
end
|
164
|
+
puts "*** annoyedclose #{status_code.inspect}" +
|
165
|
+
(message ? " #{message.inspect}" : '')
|
166
|
+
return
|
167
|
+
end
|
168
|
+
|
169
|
+
def onclose webtube
|
170
|
+
puts "*** close"
|
171
|
+
$send_thread.raise StopSendThread
|
172
|
+
return
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
class StopSendThread < Exception
|
177
|
+
end
|
178
|
+
|
179
|
+
puts "Connecting to #{ARGV.first} ..."
|
180
|
+
|
181
|
+
$webtube = Webtube.connect ARGV.first,
|
182
|
+
header_fields: $header,
|
183
|
+
allow_opcodes: 1 .. 15,
|
184
|
+
ssl_verify_mode: $insecure ? OpenSSL::SSL::VERIFY_NONE : nil,
|
185
|
+
on_http_response: proc{ |response|
|
186
|
+
# Show the HTTP response to the user
|
187
|
+
puts "| #{response.code} #{response.message}"
|
188
|
+
response.each_key do |key|
|
189
|
+
response.get_fields(key).each do |value|
|
190
|
+
puts "| #{key}: #{value}"
|
191
|
+
end
|
192
|
+
end
|
193
|
+
puts
|
194
|
+
}
|
195
|
+
|
196
|
+
# [[$listener]] will send us, via [[$send_thread]], the [[StopSendThread]]
|
197
|
+
# exception when the other side goes away.
|
198
|
+
$send_thread = Thread.current
|
199
|
+
|
200
|
+
# [[Webtube#run]] will hog the whole thread it runs on, so we'll give it a
|
201
|
+
# thread of its own.
|
202
|
+
$recv_thread = Thread.new do
|
203
|
+
begin
|
204
|
+
$webtube.run $listener
|
205
|
+
rescue Exception => e
|
206
|
+
$stderr.puts "Exception in receive thread: #{$!}", $@
|
207
|
+
$send_thread.exit 1 # terminate the main thread
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
# Now, read user input and interpret commands.
|
212
|
+
|
213
|
+
begin
|
214
|
+
until $stdin.eof? do
|
215
|
+
line = $stdin.readline.chomp!
|
216
|
+
case line
|
217
|
+
when /\A\/(\/)/ then
|
218
|
+
$webtube.send_message $1 + $'
|
219
|
+
when /\A\/([0-9a-f])\b\s*/i then
|
220
|
+
$webtube.send_message $', $1.hex
|
221
|
+
when /\A\/ping\b\s*/ then
|
222
|
+
$webtube.send_message $', Webtube::OPCODE_PING
|
223
|
+
puts "(Ping sent.)"
|
224
|
+
when /\A\/close\b\s*\Z/ then
|
225
|
+
$webtube.close
|
226
|
+
puts "(Close sent.)"
|
227
|
+
when /\A\/close\b\s+(\d+)\s*/ then
|
228
|
+
$webtube.close $1.to_i, $'
|
229
|
+
puts "(Close sent.)"
|
230
|
+
when /\A\/help\s*\Z/ then
|
231
|
+
puts ONLINE_HELP
|
232
|
+
else
|
233
|
+
$webtube.send_message line
|
234
|
+
end
|
235
|
+
end
|
236
|
+
$webtube.close
|
237
|
+
$recv_thread.join
|
238
|
+
rescue StopSendThread
|
239
|
+
end
|
data/lib/webtube.rb
ADDED
@@ -0,0 +1,757 @@
|
|
1
|
+
# webtube.rb -- an implementation of the WebSocket extension of HTTP
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
require 'digest/sha1'
|
5
|
+
require 'net/http'
|
6
|
+
require 'securerandom'
|
7
|
+
require 'thread'
|
8
|
+
require 'uri'
|
9
|
+
require 'webrick/httprequest'
|
10
|
+
|
11
|
+
class Webtube
|
12
|
+
# Not all the possible 16 values are defined by the standard.
|
13
|
+
OPCODE_CONTINUATION = 0x0
|
14
|
+
OPCODE_TEXT = 0x1
|
15
|
+
OPCODE_BINARY = 0x2
|
16
|
+
OPCODE_CLOSE = 0x8
|
17
|
+
OPCODE_PING = 0x9
|
18
|
+
OPCODE_PONG = 0xA
|
19
|
+
|
20
|
+
attr_accessor :allow_rsv_bits
|
21
|
+
attr_accessor :allow_opcodes
|
22
|
+
|
23
|
+
# The following three slots are not used by the [[Webtube]] infrastructrue.
|
24
|
+
# They have been defined purely so that application code could easily
|
25
|
+
# associate data it finds significant to [[Webtube]] instances.
|
26
|
+
|
27
|
+
attr_accessor :header # [[accept_webtube]] saves the request object here
|
28
|
+
attr_accessor :session
|
29
|
+
attr_accessor :context
|
30
|
+
|
31
|
+
def initialize socket,
|
32
|
+
serverp,
|
33
|
+
# If true, we will expect incoming data masked and will not mask
|
34
|
+
# outgoing data. If false, we will expect incoming data unmasked and
|
35
|
+
# will mask outgoing data.
|
36
|
+
allow_rsv_bits: 0,
|
37
|
+
allow_opcodes: [Webtube::OPCODE_TEXT],
|
38
|
+
close_socket: true
|
39
|
+
super()
|
40
|
+
@socket = socket
|
41
|
+
@serverp = serverp
|
42
|
+
@allow_rsv_bits = allow_rsv_bits
|
43
|
+
@allow_opcodes = allow_opcodes
|
44
|
+
@close_socket = close_socket
|
45
|
+
@defrag_buffer = []
|
46
|
+
@alive = true
|
47
|
+
@send_mutex = Mutex.new
|
48
|
+
# Guards message sending, so that fragmented messages won't get
|
49
|
+
# interleaved, and the [[@alive]] flag.
|
50
|
+
@run_mutex = Mutex.new
|
51
|
+
# Guards the main read loop.
|
52
|
+
@receiving_frame = false
|
53
|
+
# Are we currently receiving a frame for the [[Webtube#run]] main loop?
|
54
|
+
@reception_interrupt_mutex = Mutex.new
|
55
|
+
# guards [[@receiving_frame]]
|
56
|
+
return
|
57
|
+
end
|
58
|
+
|
59
|
+
# Run a loop to read all the messages and control frames coming in via this
|
60
|
+
# WebSocket, and hand events to the given [[listener]]. The listener can
|
61
|
+
# implement the following methods:
|
62
|
+
#
|
63
|
+
# - onopen(webtube) will be called as soon as the channel is set up.
|
64
|
+
#
|
65
|
+
# - onmessage(webtube, message_body, opcode) will be called with each
|
66
|
+
# arriving data message once it has been defragmented. The data will be
|
67
|
+
# passed to it as a [[String]], encoded in [[UTF-8]] for [[OPCODE_TEXT]]
|
68
|
+
# messages and in [[ASCII-8BIT]] for all the other message opcodes.
|
69
|
+
#
|
70
|
+
# - oncontrolframe(webtube, frame) will be called upon receipt of a control
|
71
|
+
# frame whose opcode is listed in the [[allow_opcodes]] parameter of this
|
72
|
+
# [[Webtube]] instance. The frame is repreented by an instance of
|
73
|
+
# [[Webtube::Frame]]. Note that [[Webtube]] handles connection closures
|
74
|
+
# ([[OPCODE_CLOSE]]) and ponging all the pings ([[OPCODE_PING]])
|
75
|
+
# automatically.
|
76
|
+
#
|
77
|
+
# - onping(webtube, frame) will be called upon receipt of an [[OPCODE_PING]]
|
78
|
+
# frame. [[Webtube]] will take care of ponging all the pings, but the
|
79
|
+
# listener may want to process such an event for statistical information.
|
80
|
+
#
|
81
|
+
# - onpong(webtube, frame) will be called upon receipt of an [[OPCODE_PONG]]
|
82
|
+
# frame.
|
83
|
+
#
|
84
|
+
# - onclose(webtube) will be called upon closure of the connection, for any
|
85
|
+
# reason.
|
86
|
+
#
|
87
|
+
# - onannoyedclose(webtube, frame) will be called upon receipt of an
|
88
|
+
# [[OPCODE_CLOSE]] frame with an explicit status code other than 1000.
|
89
|
+
# This typically indicates that the other side is annoyed, so the listener
|
90
|
+
# may want to log the condition for debugging or further analysis.
|
91
|
+
# Normally, once the handler returns, [[Webtube]] will respond with a close
|
92
|
+
# frame of the same status code and close the connection, but the handler
|
93
|
+
# may call [[Webtube#close]] to request a closure with a different status
|
94
|
+
# code or without one.
|
95
|
+
#
|
96
|
+
# - onexception(webtube, exception) will be called if an unhandled exception
|
97
|
+
# is raised during the [[Webtube]]'s lifecycle, including all of the
|
98
|
+
# listener event handlers. It may log the exception but should return
|
99
|
+
# normally so that the [[Webtube]] can issue a proper close frame for the
|
100
|
+
# other end and invoke the [[onclose]] handler, after which the exception
|
101
|
+
# will be raised again so the caller of [[Webtube#run]] will have a chance
|
102
|
+
# of handling it.
|
103
|
+
#
|
104
|
+
# Before calling any of the handlers, [[respond_to?]] will be used to check
|
105
|
+
# implementedness.
|
106
|
+
#
|
107
|
+
# If an exception occurs during processing, it may implement a specific
|
108
|
+
# status code to be passed to the other end via the [[OPCODE_CLOSE]] frame by
|
109
|
+
# implementing the [[websocket_close_status_code]] method returning the code
|
110
|
+
# as an integer. The default code, used if the exception does not specify
|
111
|
+
# one, is 1011 'unexpected condition'. An exception may explicitly suppress
|
112
|
+
# sending any code by having [[websocket_close_status_code]] return [[nil]]
|
113
|
+
# instead of an integer.
|
114
|
+
#
|
115
|
+
def run listener
|
116
|
+
@run_mutex.synchronize do
|
117
|
+
@thread = Thread.current
|
118
|
+
begin
|
119
|
+
listener.onopen self if listener.respond_to? :onopen
|
120
|
+
while @send_mutex.synchronize{@alive} do
|
121
|
+
begin
|
122
|
+
@reception_interrupt_mutex.synchronize do
|
123
|
+
@receiving_frame = true
|
124
|
+
end
|
125
|
+
frame = Webtube::Frame.read_from_socket @socket
|
126
|
+
ensure
|
127
|
+
@reception_interrupt_mutex.synchronize do
|
128
|
+
@receiving_frame = false
|
129
|
+
end
|
130
|
+
end
|
131
|
+
unless (frame.rsv & ~@allow_rsv_bits) == 0 then
|
132
|
+
raise Webtube::UnknownReservedBit.new(frame: frame)
|
133
|
+
end
|
134
|
+
if @serverp then
|
135
|
+
unless frame.masked?
|
136
|
+
raise Webtube::UnmaskedFrameToServer.new(frame: frame)
|
137
|
+
end
|
138
|
+
else
|
139
|
+
unless !frame.masked? then
|
140
|
+
raise Webtube::MaskedFrameToClient.new(frame: frame)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
if !frame.control_frame? then
|
144
|
+
# data frame
|
145
|
+
if frame.opcode != Webtube::OPCODE_CONTINUATION then
|
146
|
+
# initial frame
|
147
|
+
unless @allow_opcodes.include? frame.opcode then
|
148
|
+
raise Webtube::UnknownOpcode.new(frame: frame)
|
149
|
+
end
|
150
|
+
unless @defrag_buffer.empty? then
|
151
|
+
raise Webtube::MissingContinuationFrame.new
|
152
|
+
end
|
153
|
+
else
|
154
|
+
# continuation frame
|
155
|
+
if @defrag_buffer.empty? then
|
156
|
+
raise Webtube::UnexpectedContinuationFrame.new(frame: frame)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
@defrag_buffer.push frame
|
160
|
+
if frame.fin? then
|
161
|
+
opcode = @defrag_buffer.first.opcode
|
162
|
+
data = @defrag_buffer.map(&:payload).join ''
|
163
|
+
@defrag_buffer = []
|
164
|
+
if opcode == Webtube::OPCODE_TEXT then
|
165
|
+
# text messages must be encoded in UTF-8, as per RFC 6455
|
166
|
+
data.force_encoding 'UTF-8'
|
167
|
+
unless data.valid_encoding? then
|
168
|
+
data.force_encoding 'ASCII-8BIT'
|
169
|
+
raise Webtube::BadlyEncodedText.new(data: data)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
listener.onmessage self, data, opcode \
|
173
|
+
if listener.respond_to? :onmessage
|
174
|
+
end
|
175
|
+
elsif (0x08 .. 0x0F).include? frame.opcode then
|
176
|
+
# control frame
|
177
|
+
unless frame.fin? then
|
178
|
+
raise Webtube::FragmentedControlFrame.new(frame: frame)
|
179
|
+
end
|
180
|
+
case frame.opcode
|
181
|
+
when Webtube::OPCODE_CLOSE then
|
182
|
+
message = frame.payload
|
183
|
+
if message.length >= 2 then
|
184
|
+
status_code, = message.unpack 'n'
|
185
|
+
unless status_code == 1000 then
|
186
|
+
listener.onannoyedclose self, frame \
|
187
|
+
if listener.respond_to? :onannoyedclose
|
188
|
+
end
|
189
|
+
else
|
190
|
+
status_code = 1000
|
191
|
+
end
|
192
|
+
close status_code
|
193
|
+
when Webtube::OPCODE_PING then
|
194
|
+
listener.onping self, frame if listener.respond_to? :onping
|
195
|
+
send_message frame.payload, Webtube::OPCODE_PONG
|
196
|
+
when Webtube::OPCODE_PONG then
|
197
|
+
listener.onpong self, frame if listener.respond_to? :onpong
|
198
|
+
# ignore
|
199
|
+
else
|
200
|
+
unless @allow_opcodes.include? frame.opcode then
|
201
|
+
raise Webtube::UnknownOpcode.new(frame: frame)
|
202
|
+
end
|
203
|
+
end
|
204
|
+
listener.oncontrolframe self, frame \
|
205
|
+
if @allow_opcodes.include?(frame.opcode) and
|
206
|
+
listener.respond_to?(:oncontrolframe)
|
207
|
+
else
|
208
|
+
raise 'assertion failed'
|
209
|
+
end
|
210
|
+
end
|
211
|
+
rescue AbortReceiveLoop
|
212
|
+
# we're out of the loop now, so nothing further to do
|
213
|
+
rescue Exception => e
|
214
|
+
status_code = if e.respond_to? :websocket_close_status_code then
|
215
|
+
e.websocket_close_status_code
|
216
|
+
else
|
217
|
+
1011 # 'unexpected condition'
|
218
|
+
end
|
219
|
+
listener.onexception self, e if listener.respond_to? :onexception
|
220
|
+
begin
|
221
|
+
close status_code
|
222
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, Errno::ENOTCONN
|
223
|
+
# ignore, we have a bigger exception to handle
|
224
|
+
end
|
225
|
+
raise e
|
226
|
+
ensure
|
227
|
+
@thread = nil
|
228
|
+
listener.onclose self if listener.respond_to? :onclose
|
229
|
+
end
|
230
|
+
end
|
231
|
+
return
|
232
|
+
end
|
233
|
+
|
234
|
+
# Send a given message payload to the other party, using the given opcode.
|
235
|
+
# By default, the [[opcode]] is [[Webtube::OPCODE_TEXT]]. Re-encodes the
|
236
|
+
# payload if given in a non-UTF-8 encoding and [[opcode ==
|
237
|
+
# Webtube::OPCODE_TEXT]].
|
238
|
+
def send_message message, opcode = Webtube::OPCODE_TEXT
|
239
|
+
if opcode == Webtube::OPCODE_TEXT and message.encoding.name != 'UTF-8' then
|
240
|
+
message = message.encode 'UTF-8'
|
241
|
+
end
|
242
|
+
@send_mutex.synchronize do
|
243
|
+
raise 'WebSocket connection no longer live' unless @alive
|
244
|
+
# In order to ensure that the local kernel will treat our (data) frames
|
245
|
+
# atomically during the [[write]] syscall, we'll want to ensure that the
|
246
|
+
# frame size does not exceed 512 bytes -- the minimum permitted size for
|
247
|
+
# [[PIPE_BUF]]. At this frame size, the header size is up to four bytes
|
248
|
+
# for unmasked or eight bytes for masked frames.
|
249
|
+
Webtube::Frame.each_frame_for_message(
|
250
|
+
message: message,
|
251
|
+
opcode: opcode,
|
252
|
+
masked: !@serverp,
|
253
|
+
max_frame_body_size: 512 - (!@serverp ? 8 : 4)) do |frame|
|
254
|
+
@socket.write frame.header + frame.body
|
255
|
+
end
|
256
|
+
end
|
257
|
+
return
|
258
|
+
end
|
259
|
+
|
260
|
+
# Close the connection, thus preventing further processing.
|
261
|
+
#
|
262
|
+
# If [[status_code]] is supplied, it will be passed to the other side in the
|
263
|
+
# [[OPCODE_CLOSE]] frame. The default is 1000 which indicates normal
|
264
|
+
# closure. Sending a status code can be explicitly suppressed by passing
|
265
|
+
# [[nil]] instead of an integer; then, an empty close frame will be sent.
|
266
|
+
# Due to the way a close frame's payload is structured, this will also
|
267
|
+
# suppress delivery of [[close_explanation]], even if non-empty.
|
268
|
+
#
|
269
|
+
# Note that RFC 6455 requires the explanation to be encoded in UTF-8.
|
270
|
+
# Accordingly, this method will re-encode it unless it is already in UTF-8.
|
271
|
+
def close status_code = 1000, explanation = ""
|
272
|
+
# prepare the payload for the close frame
|
273
|
+
payload = ""
|
274
|
+
if status_code then
|
275
|
+
payload = [status_code].pack('n')
|
276
|
+
if explanation then
|
277
|
+
payload << explanation.encode('UTF-8')
|
278
|
+
end
|
279
|
+
end
|
280
|
+
# let the other side know we're closing
|
281
|
+
send_message payload, OPCODE_CLOSE
|
282
|
+
# break the main reception loop
|
283
|
+
@send_mutex.synchronize do
|
284
|
+
@alive = false
|
285
|
+
end
|
286
|
+
# if waiting for a frame (or parsing one), interrupt it
|
287
|
+
@reception_interrupt_mutex.synchronize do
|
288
|
+
@thread.raise AbortReceiveLoop.new if @receiving_frame
|
289
|
+
end
|
290
|
+
@socket.close if @close_socket
|
291
|
+
return
|
292
|
+
end
|
293
|
+
|
294
|
+
# Attempt to set up a [[WebSocket]] connection to the given [[url]]. Return
|
295
|
+
# the [[Webtube]] instance if successful or raise an appropriate
|
296
|
+
# [[Webtube::WebSocketUpgradeFailed]].
|
297
|
+
def self::connect url,
|
298
|
+
header_fields: {},
|
299
|
+
ssl_verify_mode: nil,
|
300
|
+
on_http_response: nil,
|
301
|
+
allow_rsv_bits: 0,
|
302
|
+
allow_opcodes: [Webtube::OPCODE_TEXT],
|
303
|
+
close_socket: true
|
304
|
+
# We'll replace the WebSocket protocol prefix with an HTTP-based one so
|
305
|
+
# [[URI::parse]] would know how to parse the rest of the URL.
|
306
|
+
case url
|
307
|
+
when /\Aws:/ then
|
308
|
+
hturl = 'http:' + $'
|
309
|
+
ssl = false
|
310
|
+
default_port = 80
|
311
|
+
when /\Awss:/ then
|
312
|
+
hturl = 'https:' + $'
|
313
|
+
ssl = true
|
314
|
+
default_port = 443
|
315
|
+
else
|
316
|
+
raise "unknown URI scheme; use ws: or wss: instead"
|
317
|
+
end
|
318
|
+
hturi = URI.parse hturl
|
319
|
+
|
320
|
+
reqhdr = {}
|
321
|
+
|
322
|
+
# Copy over the user-supplied header fields. Since Ruby hashes are
|
323
|
+
# case-sensitive but HTTP header field names are case-insensitive, we may
|
324
|
+
# have to combine fields whose names only differ in case.
|
325
|
+
header_fields.each_pair do |name, value|
|
326
|
+
name = name.downcase
|
327
|
+
if reqhdr.has_key? name then
|
328
|
+
reqhdr[name] += ', ' + value
|
329
|
+
else
|
330
|
+
reqhdr[name] = value
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
# Set up the WebSocket header fields (but we'll give user-specified values,
|
335
|
+
# if any, precedence)
|
336
|
+
reqhdr['host'] ||=
|
337
|
+
hturi.host + (hturi.port != default_port ? ":#{hturi.port}" : "")
|
338
|
+
reqhdr['upgrade'] ||= 'websocket'
|
339
|
+
reqhdr['connection'] ||= 'upgrade'
|
340
|
+
reqhdr['sec-websocket-key'] ||= SecureRandom.base64(16)
|
341
|
+
reqhdr['sec-websocket-version'] ||= '13'
|
342
|
+
|
343
|
+
start_options = {}
|
344
|
+
start_options[:use_ssl] = ssl
|
345
|
+
start_options[:verify_mode] = ssl_verify_mode if ssl and ssl_verify_mode
|
346
|
+
http = Net::HTTP.start hturi.host, hturi.port, **start_options
|
347
|
+
|
348
|
+
object_to_request = hturi.path
|
349
|
+
if object_to_request.empty? then
|
350
|
+
object_to_request = '/'
|
351
|
+
end
|
352
|
+
if hturi.query then
|
353
|
+
object_to_request += '?' + hturi.query
|
354
|
+
end
|
355
|
+
response = http.get object_to_request, reqhdr
|
356
|
+
on_http_response.call response if on_http_response
|
357
|
+
|
358
|
+
# Check that the server is seeing us now
|
359
|
+
unless response.code == '101' then
|
360
|
+
raise Webtube::WebSocketDeclined.new("the HTTP response code was not 101")
|
361
|
+
end
|
362
|
+
unless (response['Connection'] || '').downcase == 'upgrade' then
|
363
|
+
raise Webtube::WebSocketDeclined.new("the HTTP response did not say " +
|
364
|
+
"'Connection: upgrade'")
|
365
|
+
end
|
366
|
+
unless (response['Upgrade'] || '').downcase == 'websocket' then
|
367
|
+
raise Webtube::WebSocketDeclined.new("the HTTP response did not say " +
|
368
|
+
"'Upgrade: websocket'")
|
369
|
+
end
|
370
|
+
expected_accept = Digest::SHA1.base64digest(
|
371
|
+
reqhdr['sec-websocket-key'] +
|
372
|
+
'258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
373
|
+
unless (response['Sec-WebSocket-Accept'] || '') == expected_accept then
|
374
|
+
raise Webtube::WebSocketDeclined.new("the HTTP response did not say " +
|
375
|
+
"'Sec-WebSocket-Accept: #{expected_accept}'")
|
376
|
+
end
|
377
|
+
unless (response['Sec-WebSocket-Version'] || '13').
|
378
|
+
split(/\s*,\s*/).include? '13' then
|
379
|
+
raise Webtube::WebSocketVersionMismatch.new(
|
380
|
+
"Sec-WebSocket-Version negotiation failed")
|
381
|
+
end
|
382
|
+
|
383
|
+
# The connection has been set up. Now let's set up the Webtube.
|
384
|
+
socket = http.instance_eval{@socket}
|
385
|
+
socket.read_timeout = nil # turn off timeout
|
386
|
+
return Webtube.new socket, false,
|
387
|
+
allow_rsv_bits: allow_rsv_bits,
|
388
|
+
allow_opcodes: allow_opcodes,
|
389
|
+
close_socket: close_socket
|
390
|
+
end
|
391
|
+
|
392
|
+
# The application may want to store many Webtube instances in a hash or a
|
393
|
+
# set. In order to facilitate this, we'll need [[hash]] and [[eql?]]. The
|
394
|
+
# latter is already adequately -- comparing by identity -- implemented by
|
395
|
+
# [[Object]]; in order to ensure the former hashes by identity, we'll
|
396
|
+
# override it.
|
397
|
+
def hash
|
398
|
+
return object_id
|
399
|
+
end
|
400
|
+
|
401
|
+
# A technical exception, raised by [[Webtube#close]] if [[Webtube#run]] is
|
402
|
+
# currently waiting for a frame.
|
403
|
+
class AbortReceiveLoop < Exception
|
404
|
+
end
|
405
|
+
|
406
|
+
# Note that [[body]] holds the /raw/ data; that is, if [[masked?]] is true,
|
407
|
+
# it will need to be unmasked to get the payload. Call [[payload]] in order
|
408
|
+
# to abstract this away.
|
409
|
+
Frame = Struct.new(:header, :body)
|
410
|
+
class Frame
|
411
|
+
def fin?
|
412
|
+
return (header.getbyte(0) & 0x80) != 0
|
413
|
+
end
|
414
|
+
|
415
|
+
def fin= new_value
|
416
|
+
header.setbyte 0, header.getbyte(0) & 0x7F | (new_value ? 0x80 : 0x00)
|
417
|
+
return new_value
|
418
|
+
end
|
419
|
+
|
420
|
+
def rsv1
|
421
|
+
return (header.getbyte(0) & 0x40) != 0
|
422
|
+
end
|
423
|
+
|
424
|
+
def rsv2
|
425
|
+
return (header.getbyte(0) & 0x20) != 0
|
426
|
+
end
|
427
|
+
|
428
|
+
def rsv3
|
429
|
+
return (header.getbyte(0) & 0x10) != 0
|
430
|
+
end
|
431
|
+
|
432
|
+
# The three reserved bits of the frame, shifted rightwards to meet the
|
433
|
+
# binary point
|
434
|
+
def rsv
|
435
|
+
return (header.getbyte(0) & 0x70) >> 4
|
436
|
+
end
|
437
|
+
|
438
|
+
def opcode
|
439
|
+
return header.getbyte(0) & 0x0F
|
440
|
+
end
|
441
|
+
|
442
|
+
def opcode= new_opcode
|
443
|
+
header.setbyte 0, (header.getbyte(0) & ~0x0F) | (new_opcode & 0x0F)
|
444
|
+
return new_opcode
|
445
|
+
end
|
446
|
+
|
447
|
+
def control_frame?
|
448
|
+
return opcode >= 0x8
|
449
|
+
end
|
450
|
+
|
451
|
+
def masked?
|
452
|
+
return (header.getbyte(1) & 0x80) != 0
|
453
|
+
end
|
454
|
+
|
455
|
+
# Determine the size of this frame's extended payload length field in bytes
|
456
|
+
# from the 7-bit short payload length field.
|
457
|
+
def extended_payload_length_field_size
|
458
|
+
return case header.getbyte(1) & 0x7F
|
459
|
+
when 126 then 2
|
460
|
+
when 127 then 8
|
461
|
+
else 0
|
462
|
+
end
|
463
|
+
end
|
464
|
+
|
465
|
+
# Extract the length of this frame's payload. Enough bytes of the header
|
466
|
+
# must already have been read; see [[extended_payload_lenth_field_size]].
|
467
|
+
def payload_length
|
468
|
+
return case base = header.getbyte(1) & 0x7F
|
469
|
+
when 126 then header.unpack('@2 n').first
|
470
|
+
when 127 then header.unpack('@2 @>').first
|
471
|
+
else base
|
472
|
+
end
|
473
|
+
end
|
474
|
+
|
475
|
+
# Extract the mask as a 4-byte [[ASCII-8BIT]] string from this frame. If
|
476
|
+
# the frame has the [[masked?]] bit unset, return [[nil]] instead.
|
477
|
+
def mask
|
478
|
+
if masked? then
|
479
|
+
mask_offset = 2 + case header.getbyte(1) & 0x7F
|
480
|
+
when 126 then 2
|
481
|
+
when 127 then 8
|
482
|
+
else 0
|
483
|
+
end
|
484
|
+
return header[mask_offset, 4]
|
485
|
+
else
|
486
|
+
return nil
|
487
|
+
end
|
488
|
+
end
|
489
|
+
|
490
|
+
# Extract the frame's payload and return it as a [[String]] instance of the
|
491
|
+
# [[ASCII-8BIT]] encoding. If the frame has the [[masked?]] bit set, this
|
492
|
+
# also involves demasking.
|
493
|
+
def payload
|
494
|
+
return Frame.apply_mask(body, mask)
|
495
|
+
end
|
496
|
+
|
497
|
+
# Apply the given [[mask]], specified as a four-byte (!) [[String]], to the
|
498
|
+
# given [[data]]. Note that since the underlying operation is [[XOR]], the
|
499
|
+
# operation can be repeated to reverse itself.
|
500
|
+
#
|
501
|
+
# [[nil]] can be supplied instead of [[mask]] to indicate that no
|
502
|
+
# processing is needed.
|
503
|
+
#
|
504
|
+
def self::apply_mask data, mask
|
505
|
+
return data if mask.nil?
|
506
|
+
raise 'invalid mask' unless mask.bytesize == 4
|
507
|
+
result = data.dup
|
508
|
+
(0 ... result.bytesize).each do |i|
|
509
|
+
result.setbyte i, result.getbyte(i) ^ mask.getbyte(i & 3)
|
510
|
+
end
|
511
|
+
return result
|
512
|
+
end
|
513
|
+
|
514
|
+
# Read all the bytes of one WebSocket frame from the given [[socket]] and
|
515
|
+
# return them in a [[Frame]] instance. In case traffic ends before the
|
516
|
+
# frame is complete, raise [[BrokenFrame]].
|
517
|
+
#
|
518
|
+
# Note that this will call [[socket.read]] twice or thrice, and assumes no
|
519
|
+
# other thread will consume bytes from the socket inbetween. In a
|
520
|
+
# multithreaded environment, it may be necessary to apply external
|
521
|
+
# locking.
|
522
|
+
#
|
523
|
+
def self::read_from_socket socket
|
524
|
+
header = socket.read(2)
|
525
|
+
unless header and header.bytesize == 2 then
|
526
|
+
header ||= String.new.force_encoding('ASCII-8BIT')
|
527
|
+
raise BrokenFrame.new(header)
|
528
|
+
end
|
529
|
+
frame = Frame.new header
|
530
|
+
|
531
|
+
header_tail_size = frame.extended_payload_length_field_size +
|
532
|
+
(frame.masked? ? 4 : 0)
|
533
|
+
unless header_tail_size.zero? then
|
534
|
+
header_tail = socket.read(header_tail_size)
|
535
|
+
frame.header << header_tail if header_tail
|
536
|
+
unless header_tail and header_tail.bytesize == header_tail_size then
|
537
|
+
raise BrokenFrame.new(frame.header)
|
538
|
+
end
|
539
|
+
end
|
540
|
+
|
541
|
+
data_size = frame.payload_length
|
542
|
+
frame.body = socket.read(data_size)
|
543
|
+
unless frame.body and frame.body.bytesize == data_size then
|
544
|
+
raise BrokenFrame.new(frame.body ?
|
545
|
+
frame.header + frame.body :
|
546
|
+
frame.header)
|
547
|
+
end
|
548
|
+
|
549
|
+
return frame
|
550
|
+
end
|
551
|
+
|
552
|
+
# Given a frame's payload, prepare the header and return a [[Frame]]
|
553
|
+
# instance representing such a frame. Optionally, some header fields can
|
554
|
+
# also be set.
|
555
|
+
#
|
556
|
+
# It's OK for the caller to modify some header fields, such as [[fin]] or
|
557
|
+
# [[opcode]], on the returned [[Frame]] by calling the appropriate methods.
|
558
|
+
# Its body should not be modified after construction, however, because its
|
559
|
+
# length and possibly its mask is already encoded in the header.
|
560
|
+
def self::prepare(
|
561
|
+
payload: '',
|
562
|
+
opcode: OPCODE_TEXT,
|
563
|
+
fin: true,
|
564
|
+
masked: false)
|
565
|
+
header = [0].pack 'C' # we'll fill out the first byte later
|
566
|
+
mask_flag = masked ? 0x80 : 0x00
|
567
|
+
header << if payload.bytesize <= 125 then
|
568
|
+
[mask_flag | payload.bytesize].pack 'C'
|
569
|
+
elsif payload.bytesize <= 0xFFFF then
|
570
|
+
[mask_flag | 126, payload.bytesize].pack 'C n'
|
571
|
+
elsif payload.bytesize <= 0x7FFF_FFFF_FFFF_FFFF then
|
572
|
+
[mask_flag | 127, payload.bytesize].pack 'C Q>'
|
573
|
+
else
|
574
|
+
raise 'attempted to prepare a WebSocket frame with too big payload'
|
575
|
+
end
|
576
|
+
frame = Frame.new(header)
|
577
|
+
unless masked then
|
578
|
+
frame.body = payload
|
579
|
+
else
|
580
|
+
mask = SecureRandom.random_bytes(4)
|
581
|
+
frame.header << mask
|
582
|
+
frame.body = apply_mask(payload, mask)
|
583
|
+
end
|
584
|
+
|
585
|
+
# now, it's time to fill out the first byte
|
586
|
+
frame.fin = fin
|
587
|
+
frame.opcode = opcode
|
588
|
+
|
589
|
+
return frame
|
590
|
+
end
|
591
|
+
|
592
|
+
# Given a message and attributes, break it up into frames, and yields each
|
593
|
+
# such [[Frame]] separately for processing by the caller -- usually,
|
594
|
+
# delivery to the other end via the socket. Takes care to not fragment
|
595
|
+
# control messages. If masking is required, uses
|
596
|
+
# [[SecureRandom.random_bytes]] to generate masks for each frame.
|
597
|
+
def self::each_frame_for_message message: '',
|
598
|
+
opcode: OPCODE_TEXT,
|
599
|
+
masked: false,
|
600
|
+
max_frame_body_size: nil
|
601
|
+
message = message.dup.force_encoding Encoding::ASCII_8BIT
|
602
|
+
offset = 0
|
603
|
+
fin = true
|
604
|
+
begin
|
605
|
+
frame_length = message.bytesize - offset
|
606
|
+
fin = !(opcode <= 0x07 and
|
607
|
+
max_frame_body_size and
|
608
|
+
frame_length > max_frame_body_size)
|
609
|
+
frame_length = max_frame_body_size unless fin
|
610
|
+
yield Webtube::Frame.prepare(
|
611
|
+
opcode: opcode,
|
612
|
+
payload: message[offset, frame_length],
|
613
|
+
fin: fin,
|
614
|
+
masked: masked)
|
615
|
+
offset += frame_length
|
616
|
+
opcode = 0x00 # for continuation frames
|
617
|
+
end until fin
|
618
|
+
return
|
619
|
+
end
|
620
|
+
end
|
621
|
+
|
622
|
+
class ConnectionNotAlive < RuntimeError
|
623
|
+
def initialize
|
624
|
+
super "WebSocket connection is no longer alive and can not transmit " +
|
625
|
+
"any more messages"
|
626
|
+
return
|
627
|
+
end
|
628
|
+
end
|
629
|
+
|
630
|
+
class ProtocolError < StandardError
|
631
|
+
def websocket_close_status_code
|
632
|
+
return 1002
|
633
|
+
end
|
634
|
+
end
|
635
|
+
|
636
|
+
# Indicates that a complete frame could not be read from the underlying TCP
|
637
|
+
# connection. [[Webtube::Frame::read_from_socket]] will also give it the
|
638
|
+
# partial frame as a string so it could be further analysed, but this is
|
639
|
+
# optional.
|
640
|
+
class BrokenFrame < ProtocolError
|
641
|
+
attr_reader :partial_frame
|
642
|
+
|
643
|
+
def initialize message = "no complete WebSocket frame was available",
|
644
|
+
partial_frame = nil
|
645
|
+
super message
|
646
|
+
@partial_frame = partial_frame
|
647
|
+
return
|
648
|
+
end
|
649
|
+
end
|
650
|
+
|
651
|
+
class UnknownReservedBit < ProtocolError
|
652
|
+
attr_reader :frame
|
653
|
+
|
654
|
+
def initialize message = "frame with unknown RSV bit arrived",
|
655
|
+
frame: nil
|
656
|
+
super message
|
657
|
+
@frame = frame
|
658
|
+
return
|
659
|
+
end
|
660
|
+
|
661
|
+
def websocket_close_status_code
|
662
|
+
return 1003
|
663
|
+
end
|
664
|
+
end
|
665
|
+
|
666
|
+
class UnknownOpcode < ProtocolError
|
667
|
+
attr_reader :frame
|
668
|
+
|
669
|
+
def initialize message = "frame with unknown opcode arrived",
|
670
|
+
frame: nil
|
671
|
+
super message
|
672
|
+
@frame = frame
|
673
|
+
return
|
674
|
+
end
|
675
|
+
|
676
|
+
def websocket_close_status_code
|
677
|
+
return 1003
|
678
|
+
end
|
679
|
+
end
|
680
|
+
|
681
|
+
class UnmaskedFrameToServer < ProtocolError
|
682
|
+
attr_reader :frame
|
683
|
+
|
684
|
+
def initialize message = "unmasked frame arrived but we're the server",
|
685
|
+
frame: nil
|
686
|
+
super message
|
687
|
+
@frame = frame
|
688
|
+
return
|
689
|
+
end
|
690
|
+
end
|
691
|
+
|
692
|
+
class MaskedFrameToClient < ProtocolError
|
693
|
+
attr_reader :frame
|
694
|
+
|
695
|
+
def initialize message = "masked frame arrived but we're the client",
|
696
|
+
frame: nil
|
697
|
+
super message
|
698
|
+
@frame = frame
|
699
|
+
return
|
700
|
+
end
|
701
|
+
end
|
702
|
+
|
703
|
+
class MissingContinuationFrame < ProtocolError
|
704
|
+
def initialize message = "a new initial data frame arrived while only " +
|
705
|
+
"parts of a previous fragmented message had arrived"
|
706
|
+
super message
|
707
|
+
return
|
708
|
+
end
|
709
|
+
end
|
710
|
+
|
711
|
+
class UnexpectedContinuationFrame < ProtocolError
|
712
|
+
attr_reader :frame
|
713
|
+
|
714
|
+
def initialize message = "a continuation frame arrived but there was no " +
|
715
|
+
"fragmented message pending",
|
716
|
+
frame: nil
|
717
|
+
super message
|
718
|
+
@frame = frame
|
719
|
+
return
|
720
|
+
end
|
721
|
+
end
|
722
|
+
|
723
|
+
class BadlyEncodedText < ProtocolError
|
724
|
+
attr_reader :data
|
725
|
+
|
726
|
+
def initialize message = "invalid UTF-8 in a text-type message",
|
727
|
+
data: data
|
728
|
+
super message
|
729
|
+
@data = data
|
730
|
+
return
|
731
|
+
end
|
732
|
+
|
733
|
+
def websocket_close_status_code
|
734
|
+
return 1007
|
735
|
+
end
|
736
|
+
end
|
737
|
+
|
738
|
+
class FragmentedControlFrame < ProtocolError
|
739
|
+
attr_reader :frame
|
740
|
+
|
741
|
+
def initialize message = "a control frame arrived without its FIN flag set",
|
742
|
+
frame: nil
|
743
|
+
super message
|
744
|
+
@frame = frame
|
745
|
+
return
|
746
|
+
end
|
747
|
+
end
|
748
|
+
|
749
|
+
class WebSocketUpgradeFailed < StandardError
|
750
|
+
end
|
751
|
+
|
752
|
+
class WebSocketDeclined < WebSocketUpgradeFailed
|
753
|
+
end
|
754
|
+
|
755
|
+
class WebSocketVersionMismatch < WebSocketUpgradeFailed
|
756
|
+
end
|
757
|
+
end
|