em-ws-client 0.1.2 → 0.2.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.
- data/Gemfile +4 -0
- data/LICENSE +20 -0
- data/README.markdown +86 -20
- data/Rakefile +9 -1
- data/autobahn/fuzzer.rb +94 -0
- data/autobahn/report.html +3615 -0
- data/em-ws-client.gemspec +6 -3
- data/lib/em-ws-client.rb +10 -190
- data/lib/em-ws-client/client.rb +300 -0
- data/lib/em-ws-client/decoder.rb +238 -0
- data/lib/em-ws-client/encoder.rb +74 -0
- data/lib/em-ws-client/handshake.rb +97 -0
- data/lib/em-ws-client/protocol.rb +12 -0
- data/spec/codec_spec.rb +15 -0
- data/spec/handshake_spec.rb +96 -0
- data/spec/helper.rb +3 -0
- metadata +16 -20
- data/example/echo.rb +0 -33
- data/lib/codec/draft10decoder.rb +0 -122
- data/lib/codec/draft10encoder.rb +0 -45
- data/spec/em-ws-client.rb +0 -0
@@ -0,0 +1,238 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
module EventMachine::WebSocketCodec
|
4
|
+
|
5
|
+
# Internal: A WebSocket frame decoder
|
6
|
+
# based on RFC 6455
|
7
|
+
class Decoder
|
8
|
+
|
9
|
+
include Protocol
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@fragmented = false
|
13
|
+
@buffer = ""
|
14
|
+
@chunks = nil
|
15
|
+
@callbacks = {}
|
16
|
+
end
|
17
|
+
|
18
|
+
def onclose █ @callbacks[:close] = block; end
|
19
|
+
def onping █ @callbacks[:ping] = block; end
|
20
|
+
def onpong █ @callbacks[:pong] = block; end
|
21
|
+
def onframe █ @callbacks[:frame] = block; end
|
22
|
+
def onerror █ @callbacks[:error] = block; end
|
23
|
+
|
24
|
+
# Public: Feed the decoder raw data from the wire
|
25
|
+
#
|
26
|
+
# data - The raw websocket frame data
|
27
|
+
#
|
28
|
+
# Examples
|
29
|
+
#
|
30
|
+
# decoder << raw
|
31
|
+
#
|
32
|
+
# Returns nothing
|
33
|
+
def << data
|
34
|
+
|
35
|
+
# put the data into the buffer, as
|
36
|
+
# we might be replaying
|
37
|
+
if data
|
38
|
+
@buffer << data
|
39
|
+
end
|
40
|
+
|
41
|
+
# Don't do work if we don't have to
|
42
|
+
if @buffer.length < 2
|
43
|
+
return
|
44
|
+
end
|
45
|
+
|
46
|
+
# decode the first 2 bytes, with
|
47
|
+
# opcode, lengthgth, masking bit, and frag bit
|
48
|
+
h1, h2 = @buffer.unpack("CC")
|
49
|
+
|
50
|
+
# check the fragmentation bit to see
|
51
|
+
# if this is a message fragment
|
52
|
+
fin = ((h1 & 0x80) == 0x80)
|
53
|
+
|
54
|
+
# used to keep track of our position in the buffer
|
55
|
+
offset = 2
|
56
|
+
|
57
|
+
# see above for possible opcodes
|
58
|
+
opcode = (h1 & 0x0F)
|
59
|
+
|
60
|
+
# the leading length idicator
|
61
|
+
length = (h2 & 0x7F)
|
62
|
+
|
63
|
+
# masking bit, is the data masked with
|
64
|
+
# a specified masking key?
|
65
|
+
masked = ((h2 & 0x80) == 0x80)
|
66
|
+
|
67
|
+
# Find errors and fail fast
|
68
|
+
if h1 & 0b01110000 != 0
|
69
|
+
return emit :error, 1002, "RSV bits must be 0"
|
70
|
+
end
|
71
|
+
|
72
|
+
if opcode > 7
|
73
|
+
if !fin
|
74
|
+
return emit :error, 1002, "Control frame cannot be fragmented"
|
75
|
+
elsif length > 125
|
76
|
+
return emit :error, 1002, "Control frame is too large #{length}"
|
77
|
+
elsif opcode > 0xA
|
78
|
+
return emit :error, 1002, "Unexpected reserved opcode #{opcode}"
|
79
|
+
elsif opcode == CLOSE && length == 1
|
80
|
+
return emit :error, 1002, "Close control frame with payload of length 1"
|
81
|
+
end
|
82
|
+
else
|
83
|
+
if opcode != CONTINUATION && opcode != TEXT_FRAME && opcode != BINARY_FRAME
|
84
|
+
return emit :error, 1002, "Unexpected reserved opcode #{opcode}"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Get the actual size of the payload
|
89
|
+
if length > 125
|
90
|
+
if length == 126
|
91
|
+
length = @buffer.unpack("@#{offset}n").first
|
92
|
+
offset += 2
|
93
|
+
else
|
94
|
+
length = @buffer.unpack("@#{offset}L!>").first
|
95
|
+
offset += 8
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# unpack the masking key
|
100
|
+
if masked
|
101
|
+
key = @buffer.unpack("@#{offset}N").first
|
102
|
+
offset += 4
|
103
|
+
end
|
104
|
+
|
105
|
+
# replay on next frame
|
106
|
+
if @buffer.size < (length + offset)
|
107
|
+
return false
|
108
|
+
end
|
109
|
+
|
110
|
+
# Read the important bits
|
111
|
+
payload = @buffer.unpack("@#{offset}C#{length}")
|
112
|
+
|
113
|
+
# Unmask the data if it"s masked
|
114
|
+
if masked
|
115
|
+
payload.bytesize.times do |i|
|
116
|
+
payload[i] = ((payload[i] ^ (key >> ((3 - (i % 4)) * 8))) & 0xFF)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
payload = payload.pack("C*")
|
121
|
+
|
122
|
+
case opcode
|
123
|
+
when CONTINUATION
|
124
|
+
|
125
|
+
# We shouldn't get a contination without
|
126
|
+
# knowing whether or not it's binary or text
|
127
|
+
unless @fragmented
|
128
|
+
return emit :error, 1002, "Unexepected continuation"
|
129
|
+
end
|
130
|
+
|
131
|
+
if @fragmented == :text
|
132
|
+
@chunks << payload.force_encoding("UTF-8")
|
133
|
+
else
|
134
|
+
@chunks << payload
|
135
|
+
end
|
136
|
+
|
137
|
+
if fin
|
138
|
+
if @fragmented == :text && !valid_utf8?(@chunks)
|
139
|
+
return emit :error, 1007, "Invalid UTF"
|
140
|
+
end
|
141
|
+
|
142
|
+
emit :frame, @chunks, @fragmented == :binary
|
143
|
+
@chunks = nil
|
144
|
+
@fragmented = false
|
145
|
+
end
|
146
|
+
|
147
|
+
when TEXT_FRAME
|
148
|
+
# We shouldn't get a text frame when we
|
149
|
+
# are expecting a continuation
|
150
|
+
if @fragmented
|
151
|
+
return emit :error, 1002, "Unexepected frame"
|
152
|
+
end
|
153
|
+
|
154
|
+
# emit or buffer
|
155
|
+
if fin
|
156
|
+
unless valid_utf8?(payload)
|
157
|
+
return emit :error, 1007, "Invalid UTF Hmm"
|
158
|
+
end
|
159
|
+
|
160
|
+
emit :frame, payload, false
|
161
|
+
else
|
162
|
+
@chunks = payload.force_encoding("UTF-8")
|
163
|
+
@fragmented = :text
|
164
|
+
end
|
165
|
+
|
166
|
+
when BINARY_FRAME
|
167
|
+
# We shouldn't get a text frame when we
|
168
|
+
# are expecting a continuation
|
169
|
+
if @fragmented
|
170
|
+
return emit :error, 1002, "Unexepected frame"
|
171
|
+
end
|
172
|
+
|
173
|
+
# emit or buffer
|
174
|
+
if fin
|
175
|
+
emit :frame, payload, true
|
176
|
+
else
|
177
|
+
@chunks = payload
|
178
|
+
@fragmented = :binary
|
179
|
+
end
|
180
|
+
|
181
|
+
when CLOSE
|
182
|
+
code, explain = payload.unpack("nA*")
|
183
|
+
if explain && !valid_utf8?(explain)
|
184
|
+
emit :close, 1007
|
185
|
+
else
|
186
|
+
emit :close, response_close_code(code)
|
187
|
+
end
|
188
|
+
|
189
|
+
when PING
|
190
|
+
emit :ping, payload
|
191
|
+
|
192
|
+
when PONG
|
193
|
+
emit :pong, payload
|
194
|
+
|
195
|
+
end
|
196
|
+
|
197
|
+
# Remove data we made use of and call back
|
198
|
+
# TODO: remove recursion
|
199
|
+
@buffer = @buffer[offset + length..-1] || ""
|
200
|
+
if not @buffer.empty?
|
201
|
+
self << nil
|
202
|
+
end
|
203
|
+
|
204
|
+
end
|
205
|
+
|
206
|
+
private
|
207
|
+
|
208
|
+
# trigger event for listener
|
209
|
+
def emit event, *args
|
210
|
+
if @callbacks.key?(event)
|
211
|
+
@callbacks[event].call(*args)
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# Determine if the close code we received is valid
|
216
|
+
# and close if it's not
|
217
|
+
def response_close_code code
|
218
|
+
case code
|
219
|
+
when 1000,1001,1002,1003,1007,1008,1009,1010,1011
|
220
|
+
1000
|
221
|
+
when 3000..3999
|
222
|
+
1000
|
223
|
+
when 4000..4999
|
224
|
+
1000
|
225
|
+
when nil
|
226
|
+
1000
|
227
|
+
else
|
228
|
+
1002
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def valid_utf8? str
|
233
|
+
str.force_encoding("UTF-8").valid_encoding?
|
234
|
+
end
|
235
|
+
|
236
|
+
end
|
237
|
+
|
238
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
module EventMachine::WebSocketCodec
|
4
|
+
|
5
|
+
# Internal: Encodes messages into WebSocket frames
|
6
|
+
# based on RFC 6455
|
7
|
+
class Encoder
|
8
|
+
|
9
|
+
include Protocol
|
10
|
+
|
11
|
+
# Encode a standard payload to a hybi10
|
12
|
+
# WebSocket frame
|
13
|
+
def encode data, opcode=TEXT_FRAME
|
14
|
+
frame = []
|
15
|
+
frame << (opcode | 0x80)
|
16
|
+
|
17
|
+
packr = "CC"
|
18
|
+
|
19
|
+
if opcode == TEXT_FRAME
|
20
|
+
data.force_encoding("UTF-8")
|
21
|
+
|
22
|
+
if !data.valid_encoding?
|
23
|
+
raise "Invalid UTF!"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# append frame length and mask bit 0x80
|
28
|
+
len = data ? data.bytesize : 0
|
29
|
+
if len <= 125
|
30
|
+
frame << (len | 0x80)
|
31
|
+
elsif len < 65536
|
32
|
+
frame << (126 | 0x80)
|
33
|
+
frame << len
|
34
|
+
packr << "n"
|
35
|
+
else
|
36
|
+
frame << (127 | 0x80)
|
37
|
+
frame << len
|
38
|
+
packr << "L!>"
|
39
|
+
end
|
40
|
+
|
41
|
+
# generate a masking key
|
42
|
+
key = rand(2 ** 31)
|
43
|
+
|
44
|
+
# mask each byte with the key
|
45
|
+
frame << key
|
46
|
+
packr << "N"
|
47
|
+
|
48
|
+
#puts "op #{opcode} len #{len} bytes #{data}"
|
49
|
+
# Apply the masking key to every byte
|
50
|
+
len.times do |i|
|
51
|
+
frame << ((data.getbyte(i) ^ (key >> ((3 - (i % 4)) * 8))) & 0xFF)
|
52
|
+
end
|
53
|
+
|
54
|
+
frame.pack("#{packr}C*")
|
55
|
+
end
|
56
|
+
|
57
|
+
# create a close payload with code
|
58
|
+
def close code, message
|
59
|
+
encode [code ? code : 1000, message].pack("nA*"), CLOSE
|
60
|
+
end
|
61
|
+
|
62
|
+
# create a ping payload
|
63
|
+
def ping data=nil
|
64
|
+
encode data, PING
|
65
|
+
end
|
66
|
+
|
67
|
+
# create a pong payload
|
68
|
+
def pong data=nil
|
69
|
+
encode data, PONG
|
70
|
+
end
|
71
|
+
|
72
|
+
end
|
73
|
+
|
74
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
module EventMachine::WebSocketCodec
|
4
|
+
|
5
|
+
# Internal: Responsbile for generating the request and
|
6
|
+
# validating the response
|
7
|
+
class Handshake
|
8
|
+
|
9
|
+
class HandshakeError < StandardError; end
|
10
|
+
|
11
|
+
Status = /^HTTP\/1.1 (\d+)/i.freeze
|
12
|
+
Header = /^([^:]+):\s*(.+)$/i.freeze
|
13
|
+
|
14
|
+
attr_accessor :extra
|
15
|
+
|
16
|
+
def initialize uri, origin="em-ws-client"
|
17
|
+
@uri = uri
|
18
|
+
@origin = origin
|
19
|
+
@buffer = ""
|
20
|
+
@complete = false
|
21
|
+
@valid = false
|
22
|
+
@extra = ""
|
23
|
+
end
|
24
|
+
|
25
|
+
def request
|
26
|
+
headers = ["GET #{path} HTTP/1.1"]
|
27
|
+
headers << "Connection: keep-alive, Upgrade"
|
28
|
+
headers << "Host: #{host}"
|
29
|
+
headers << "Sec-WebSocket-Key: #{request_key}"
|
30
|
+
headers << "Sec-WebSocket-Version: 13"
|
31
|
+
headers << "Origin: #{@origin}"
|
32
|
+
headers << "Upgrade: websocket"
|
33
|
+
headers << "User-Agent: em-ws-client"
|
34
|
+
headers << "\r\n"
|
35
|
+
|
36
|
+
headers.join "\r\n"
|
37
|
+
end
|
38
|
+
|
39
|
+
def << data
|
40
|
+
@buffer << data
|
41
|
+
|
42
|
+
if @buffer.index "\r\n\r\n"
|
43
|
+
|
44
|
+
response, @extra = @buffer.split("\r\n\r\n", 2)
|
45
|
+
|
46
|
+
lines = response.split "\r\n"
|
47
|
+
|
48
|
+
if Status =~ lines.shift
|
49
|
+
if $1.to_i != 101
|
50
|
+
raise HandshakeError.new "Received code #{$1}"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
table = {}
|
55
|
+
lines.each do |line|
|
56
|
+
header = /^([^:]+):\s*(.+)$/.match(line)
|
57
|
+
table[header[1].downcase.strip] = header[2].strip if header
|
58
|
+
end
|
59
|
+
|
60
|
+
@complete = true
|
61
|
+
if table["sec-websocket-accept"] == response_key
|
62
|
+
@valid = true
|
63
|
+
else
|
64
|
+
raise HandshakeError.new "Invalid Sec-Websocket-Accept"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def complete?
|
70
|
+
@complete
|
71
|
+
end
|
72
|
+
|
73
|
+
def valid?
|
74
|
+
@valid
|
75
|
+
end
|
76
|
+
|
77
|
+
def host
|
78
|
+
@uri.host + (@uri.port ? ":#{@uri.port}" : "")
|
79
|
+
end
|
80
|
+
|
81
|
+
def path
|
82
|
+
(@uri.path.empty? ? "/" : @uri.path) + (@uri.query ? "?#{@uri.query}" : "")
|
83
|
+
end
|
84
|
+
|
85
|
+
# Build a unique request key to match against
|
86
|
+
def request_key
|
87
|
+
@request_key ||= SecureRandom::base64
|
88
|
+
end
|
89
|
+
|
90
|
+
# Build the response key from the given request key
|
91
|
+
# for comparison with the response value.
|
92
|
+
def response_key
|
93
|
+
Base64.encode64(Digest::SHA1.digest("#{request_key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11")).chomp
|
94
|
+
end
|
95
|
+
|
96
|
+
end
|
97
|
+
end
|
data/spec/codec_spec.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
require "helper"
|
2
|
+
|
3
|
+
module EM::WebSocketCodec
|
4
|
+
describe Decoder do
|
5
|
+
|
6
|
+
it "should decode an encoded message" do
|
7
|
+
dec = Decoder.new
|
8
|
+
enc = Encoder.new
|
9
|
+
|
10
|
+
str = enc.encode("simple message")
|
11
|
+
#dec.<<(str).should == "simple message"
|
12
|
+
end
|
13
|
+
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
require "helper"
|
2
|
+
|
3
|
+
module EM::WebSocketCodec
|
4
|
+
describe Handshake do
|
5
|
+
|
6
|
+
def craft_response status, message, headers
|
7
|
+
response = ["HTTP/1.1 #{status} #{message}"]
|
8
|
+
headers.each do |key, val|
|
9
|
+
response << "#{key}: #{val}"
|
10
|
+
end
|
11
|
+
response << "\r\n"
|
12
|
+
response.join "\r\n"
|
13
|
+
end
|
14
|
+
|
15
|
+
it "should generate a request a 24 byte key" do
|
16
|
+
handshake = Handshake.new URI.parse("ws://test.com/test"), "em-test"
|
17
|
+
handshake.request_key.length.should == 24
|
18
|
+
end
|
19
|
+
|
20
|
+
it "should craft an HTTP request" do
|
21
|
+
handshake = Handshake.new URI.parse("ws://test.com/test"), "em-test"
|
22
|
+
handshake.request.length.should > 0
|
23
|
+
end
|
24
|
+
|
25
|
+
it "should handle an good response" do
|
26
|
+
handshake = Handshake.new URI.parse("ws://test.com/test"), "em-test"
|
27
|
+
|
28
|
+
handshake << craft_response(101, "Switching Protocol", {
|
29
|
+
"Upgrade" => "WebSocket",
|
30
|
+
"Connection" => "Upgrade",
|
31
|
+
"Sec-WebSocket-Accept" => handshake.response_key
|
32
|
+
})
|
33
|
+
|
34
|
+
handshake.complete?.should be_true
|
35
|
+
handshake.valid?.should be_true
|
36
|
+
handshake.extra.should be_empty
|
37
|
+
end
|
38
|
+
|
39
|
+
it "should handle an bad response" do
|
40
|
+
handshake = Handshake.new URI.parse("ws://test.com/test"), "em-test"
|
41
|
+
|
42
|
+
exception = false
|
43
|
+
begin
|
44
|
+
handshake << craft_response(200, "OK", {
|
45
|
+
"Upgrade" => "WebSocket",
|
46
|
+
"Connection" => "Upgrade",
|
47
|
+
"Sec-WebSocket-Accept" => handshake.response_key
|
48
|
+
})
|
49
|
+
rescue Handshake::HandshakeError => err
|
50
|
+
exception = true
|
51
|
+
end
|
52
|
+
|
53
|
+
handshake.complete?.should be_false
|
54
|
+
exception.should be_true
|
55
|
+
end
|
56
|
+
|
57
|
+
it "should handle an chunked response" do
|
58
|
+
handshake = Handshake.new URI.parse("ws://test.com/test"), "em-test"
|
59
|
+
|
60
|
+
response = craft_response(101, "Switching Protocol", {
|
61
|
+
"Upgrade" => "WebSocket",
|
62
|
+
"Connection" => "Upgrade",
|
63
|
+
"Sec-WebSocket-Accept" => handshake.response_key
|
64
|
+
})
|
65
|
+
|
66
|
+
response[0..-2].each_byte do |byte|
|
67
|
+
handshake << byte
|
68
|
+
handshake.complete?.should be_false
|
69
|
+
end
|
70
|
+
|
71
|
+
handshake << response[-1..-1]
|
72
|
+
|
73
|
+
handshake.complete?.should be_true
|
74
|
+
handshake.valid?.should be_true
|
75
|
+
handshake.extra.should be_empty
|
76
|
+
end
|
77
|
+
|
78
|
+
it "should handle an response with framing after it" do
|
79
|
+
handshake = Handshake.new URI.parse("ws://test.com/test"), "em-test"
|
80
|
+
|
81
|
+
response = craft_response(101, "Switching Protocol", {
|
82
|
+
"Upgrade" => "WebSocket",
|
83
|
+
"Connection" => "Upgrade",
|
84
|
+
"Sec-WebSocket-Accept" => handshake.response_key
|
85
|
+
})
|
86
|
+
|
87
|
+
response << "extradata"
|
88
|
+
handshake << response
|
89
|
+
|
90
|
+
handshake.complete?.should be_true
|
91
|
+
handshake.valid?.should be_true
|
92
|
+
handshake.extra.should == "extradata"
|
93
|
+
end
|
94
|
+
|
95
|
+
end
|
96
|
+
end
|