webtube 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|