ftw 0.0.1 → 0.0.4

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.
Files changed (46) hide show
  1. data/README.md +7 -8
  2. data/lib/ftw.rb +4 -0
  3. data/lib/ftw/agent.rb +203 -20
  4. data/lib/ftw/connection.rb +117 -63
  5. data/lib/ftw/cookies.rb +87 -0
  6. data/lib/ftw/crlf.rb +1 -1
  7. data/lib/ftw/dns.rb +14 -5
  8. data/lib/ftw/http/headers.rb +15 -1
  9. data/lib/ftw/http/message.rb +9 -1
  10. data/lib/ftw/namespace.rb +1 -0
  11. data/lib/ftw/pool.rb +50 -0
  12. data/lib/ftw/poolable.rb +19 -0
  13. data/lib/ftw/request.rb +92 -28
  14. data/lib/ftw/response.rb +179 -0
  15. data/lib/ftw/version.rb +1 -1
  16. data/lib/ftw/websocket.rb +194 -0
  17. data/lib/ftw/websocket/parser.rb +183 -0
  18. data/test/all.rb +16 -0
  19. data/test/ftw/crlf.rb +12 -0
  20. data/test/ftw/http/dns.rb +6 -0
  21. data/test/{net/ftw → ftw}/http/headers.rb +5 -5
  22. data/test/testing.rb +0 -9
  23. metadata +13 -26
  24. data/lib/net-ftw.rb +0 -1
  25. data/lib/net/ftw.rb +0 -5
  26. data/lib/net/ftw/agent.rb +0 -10
  27. data/lib/net/ftw/connection.rb +0 -296
  28. data/lib/net/ftw/connection2.rb +0 -247
  29. data/lib/net/ftw/crlf.rb +0 -6
  30. data/lib/net/ftw/dns.rb +0 -57
  31. data/lib/net/ftw/http.rb +0 -2
  32. data/lib/net/ftw/http/client.rb +0 -116
  33. data/lib/net/ftw/http/client2.rb +0 -80
  34. data/lib/net/ftw/http/connection.rb +0 -42
  35. data/lib/net/ftw/http/headers.rb +0 -122
  36. data/lib/net/ftw/http/machine.rb +0 -38
  37. data/lib/net/ftw/http/message.rb +0 -91
  38. data/lib/net/ftw/http/request.rb +0 -80
  39. data/lib/net/ftw/http/response.rb +0 -80
  40. data/lib/net/ftw/http/server.rb +0 -5
  41. data/lib/net/ftw/machine.rb +0 -59
  42. data/lib/net/ftw/namespace.rb +0 -6
  43. data/lib/net/ftw/protocol/tls.rb +0 -12
  44. data/lib/net/ftw/websocket.rb +0 -139
  45. data/test/net/ftw/crlf.rb +0 -12
  46. data/test/net/ftw/http/dns.rb +0 -6
@@ -1,5 +1,5 @@
1
1
  require "ftw/namespace"
2
2
 
3
3
  module FTW
4
- VERSION = "0.0.1"
4
+ VERSION = "0.0.4"
5
5
  end
@@ -0,0 +1,194 @@
1
+ require "ftw/namespace"
2
+ require "openssl"
3
+ require "base64" # stdlib
4
+ require "digest/sha1" # stdlib
5
+ require "cabin"
6
+ require "ftw/websocket/parser"
7
+
8
+ # WebSockets, RFC6455.
9
+ #
10
+ # TODO(sissel): Find a comfortable way to make this websocket stuff
11
+ # both use HTTP::Connection for the HTTP handshake and also be usable
12
+ # from HTTP::Client
13
+ # TODO(sissel): Also consider SPDY and the kittens.
14
+ class FTW::WebSocket
15
+ include FTW::CRLF
16
+ include Cabin::Inspectable
17
+
18
+ TEXTFRAME = 0x0001
19
+
20
+ WEBSOCKET_ACCEPT_UUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
21
+
22
+ # Protocol phases
23
+ # 1. tcp connect
24
+ # 2. http handshake (RFC6455 section 4)
25
+ # 3. websocket protocol
26
+
27
+ # Creates a new websocket and fills in the given http request with any
28
+ # necessary settings.
29
+ public
30
+ def initialize(request)
31
+ @key_nonce = generate_key_nonce
32
+ @request = request
33
+ prepare(@request)
34
+ @parser = FTW::WebSocket::Parser.new
35
+ end # def initialize
36
+
37
+ # Set the connection for this websocket. This is usually invoked by FTW::Agent
38
+ # after the websocket upgrade and handshake have been successful.
39
+ #
40
+ # You probably don't call this yourself.
41
+ public
42
+ def connection=(connection)
43
+ @connection = connection
44
+ end # def connection=
45
+
46
+ # Prepare the request. This sets any required headers and attributes as
47
+ # specified by RFC6455
48
+ private
49
+ def prepare(request)
50
+ # RFC6455 section 4.1:
51
+ # "2. The method of the request MUST be GET, and the HTTP version MUST
52
+ # be at least 1.1."
53
+ request.method = "GET"
54
+ request.version = 1.1
55
+
56
+ # RFC6455 section 4.2.1 bullet 3
57
+ request.headers.set("Upgrade", "websocket")
58
+ # RFC6455 section 4.2.1 bullet 4
59
+ request.headers.set("Connection", "Upgrade")
60
+ # RFC6455 section 4.2.1 bullet 5
61
+ request.headers.set("Sec-WebSocket-Key", @key_nonce)
62
+ # RFC6455 section 4.2.1 bullet 6
63
+ request.headers.set("Sec-WebSocket-Version", 13)
64
+ # RFC6455 section 4.2.1 bullet 7 (optional)
65
+ # The Origin header is optional for non-browser clients.
66
+ #request.headers.set("Origin", ...)
67
+ # RFC6455 section 4.2.1 bullet 8 (optional)
68
+ #request.headers.set("Sec-Websocket-Protocol", ...)
69
+ # RFC6455 section 4.2.1 bullet 9 (optional)
70
+ #request.headers.set("Sec-Websocket-Extensions", ...)
71
+ # RFC6455 section 4.2.1 bullet 10 (optional)
72
+ # TODO(sissel): Any other headers like cookies, auth headers, are allowed.
73
+ end # def prepare
74
+
75
+ # Generate a websocket key nonce.
76
+ private
77
+ def generate_key_nonce
78
+ # RFC6455 section 4.1 says:
79
+ # ---
80
+ # 7. The request MUST include a header field with the name
81
+ # |Sec-WebSocket-Key|. The value of this header field MUST be a
82
+ # nonce consisting of a randomly selected 16-byte value that has
83
+ # been base64-encoded (see Section 4 of [RFC4648]). The nonce
84
+ # MUST be selected randomly for each connection.
85
+ # ---
86
+ #
87
+ # It's not totally clear to me how cryptographically strong this random
88
+ # nonce needs to be, and if it does not need to be strong and it would
89
+ # benefit users who do not have ruby with openssl enabled, maybe just use
90
+ # rand() to generate this string.
91
+ #
92
+ # Thus, generate a random 16 byte string and encode i with base64.
93
+ # Array#pack("m") packs with base64 encoding.
94
+ return Base64.strict_encode64(OpenSSL::Random.random_bytes(16))
95
+ end # def generate_key_nonce
96
+
97
+ # Is this Response acceptable for our WebSocket Upgrade request?
98
+ public
99
+ def handshake_ok?(response)
100
+ # See RFC6455 section 4.2.2
101
+ return false unless response.status == 101 # "Switching Protocols"
102
+ return false unless response.headers.get("upgrade") == "websocket"
103
+ return false unless response.headers.get("connection") == "Upgrade"
104
+
105
+ # Now verify Sec-WebSocket-Accept. It should be the SHA-1 of the
106
+ # Sec-WebSocket-Key (in base64) + WEBSOCKET_ACCEPT_UUID
107
+ expected = @key_nonce + WEBSOCKET_ACCEPT_UUID
108
+ expected_hash = Digest::SHA1.base64digest(expected)
109
+ return false unless response.headers.get("Sec-WebSocket-Accept") == expected_hash
110
+
111
+ return true
112
+ end # def handshake_ok?
113
+
114
+ # Iterate over each WebSocket message. This method will run forever unless you
115
+ # break from it.
116
+ #
117
+ # The text payload of each message will be yielded to the block.
118
+ public
119
+ def each(&block)
120
+ loop do
121
+ payload = @parser.feed(@connection.read)
122
+ next if payload.nil?
123
+ yield payload
124
+ end
125
+ end # def each
126
+
127
+ # Implement masking as described by http://tools.ietf.org/html/rfc6455#section-5.3
128
+ # Basically, we take a 4-byte random string and use it, round robin, to XOR
129
+ # every byte. Like so:
130
+ # message[0] ^ key[0]
131
+ # message[1] ^ key[1]
132
+ # message[2] ^ key[2]
133
+ # message[3] ^ key[3]
134
+ # message[4] ^ key[0]
135
+ # ...
136
+ private
137
+ def mask(message, key)
138
+ masked = []
139
+ mask_bytes = key.unpack("C4")
140
+ i = 0
141
+ message.each_byte do |byte|
142
+ masked << (byte ^ mask_bytes[i % 4])
143
+ i += 1
144
+ end
145
+ return masked.pack("C*")
146
+ end # def mask
147
+
148
+ # Publish a message text.
149
+ #
150
+ # This will send a websocket text frame over the connection.
151
+ public
152
+ def publish(message)
153
+ # TODO(sissel): Support server and client modes.
154
+ # Server MUST NOT mask. Client MUST mask.
155
+ #
156
+ # 0 1 2 3
157
+ # 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
158
+ # +-+-+-+-+-------+-+-------------+-------------------------------+
159
+ # |F|R|R|R| opcode|M| Payload len | Extended payload length |
160
+ # |I|S|S|S| (4) |A| (7) | (16/64) |
161
+ # |N|V|V|V| |S| | (if payload len==126/127) |
162
+ # | |1|2|3| |K| | |
163
+ # +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
164
+ # | Extended payload length continued, if payload len == 127 |
165
+ # + - - - - - - - - - - - - - - - +-------------------------------+
166
+ # | |Masking-key, if MASK set to 1 |
167
+ # +-------------------------------+-------------------------------+
168
+ # | Masking-key (continued) | Payload Data |
169
+ # +-------------------------------- - - - - - - - - - - - - - - - +
170
+ # : Payload Data continued ... :
171
+ # + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
172
+ # | Payload Data continued ... |
173
+ # +---------------------------------------------------------------+
174
+ # TODO(sissel): Support 'fin' flag
175
+ # Set 'fin' flag and opcode of 'text frame'
176
+ length = message.length
177
+ mask_key = [rand(1 << 32)].pack("Q")
178
+ if message.length >= (1 << 16)
179
+ pack = "CCSA4A*" # flags+opcode, mask+len, 2-byte len, payload
180
+ data = [ 0x80 | TEXTFRAME, 0x80 | 126, message.length, mask_key, mask(message, mask_key) ]
181
+ @connection.write(data.pack(pack))
182
+ elsif message.length >= (1 << 7)
183
+ length = 126
184
+ pack = "CCQA4A*" # flags+opcode, mask+len, 8-byte len, payload
185
+ data = [ 0x80 | TEXTFRAME, 0x80 | 127, message.length, mask_key, mask(message, mask_key) ]
186
+ @connection.write(data.pack(pack))
187
+ else
188
+ data = [ 0x80 | TEXTFRAME, 0x80 | message.length, mask_key, mask(message, mask_key) ]
189
+ pack = "CCA4A*" # flags+opcode, mask+len, payload
190
+ @connection.write(data.pack(pack))
191
+ end
192
+ end # def publish
193
+ end # class FTW::WebSocket
194
+
@@ -0,0 +1,183 @@
1
+ require "ftw/namespace"
2
+ require "ftw/websocket"
3
+
4
+ # This class implements a parser for WebSocket messages over a stream.
5
+ #
6
+ # Protocol diagram copied from RFC6455
7
+ # 0 1 2 3
8
+ # 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
9
+ # +-+-+-+-+-------+-+-------------+-------------------------------+
10
+ # |F|R|R|R| opcode|M| Payload len | Extended payload length |
11
+ # |I|S|S|S| (4) |A| (7) | (16/64) |
12
+ # |N|V|V|V| |S| | (if payload len==126/127) |
13
+ # | |1|2|3| |K| | |
14
+ # +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
15
+ # | Extended payload length continued, if payload len == 127 |
16
+ # + - - - - - - - - - - - - - - - +-------------------------------+
17
+ # | |Masking-key, if MASK set to 1 |
18
+ # +-------------------------------+-------------------------------+
19
+ # | Masking-key (continued) | Payload Data |
20
+ # +-------------------------------- - - - - - - - - - - - - - - - +
21
+ # : Payload Data continued ... :
22
+ # + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
23
+ # | Payload Data continued ... |
24
+ # +---------------------------------------------------------------+
25
+ class FTW::WebSocket::Parser
26
+ # XXX: Implement control frames: http://tools.ietf.org/html/rfc6455#section-5.5
27
+
28
+ # States are based on the minimal unit of 'byte'
29
+ STATES = [ :flags_and_opcode, :mask_and_payload_init, :payload_length, :payload ]
30
+
31
+ # A new WebSocket protocol parser.
32
+ def initialize
33
+ @logger = Cabin::Channel.get($0)
34
+ @opcode = 0
35
+ @masking_key = ""
36
+ @flag_final_payload = 0
37
+ @flag_mask = 0
38
+
39
+ transition(:flags_and_opcode, 1)
40
+ @buffer = ""
41
+ @buffer.force_encoding("BINARY")
42
+ end # def initialize
43
+
44
+ # Transition to a specified state and set the next required read length.
45
+ private
46
+ def transition(state, next_length)
47
+ @logger.debug("Transitioning", :transition => state, :nextlen => next_length)
48
+ @state = state
49
+ need(next_length)
50
+ end # def transition
51
+
52
+ # Feed data to this parser.
53
+ #
54
+ # Currently, it will return the raw payload of websocket messages.
55
+ # Otherwise, it returns nil if no complete message has yet been consumed.
56
+ public
57
+ def feed(data)
58
+ @buffer << data
59
+ while have?(@need)
60
+ value = send(@state)
61
+ # Return if our state yields a value.
62
+ return value if !value.nil?
63
+ #yield value if !value.nil? and block_given?
64
+ end
65
+ return nil
66
+ end # def <<
67
+
68
+ # Do we have at least 'length' bytes in the buffer?
69
+ private
70
+ def have?(length)
71
+ return length <= @buffer.size
72
+ end # def have?
73
+
74
+ # Get 'length' string from the buffer.
75
+ private
76
+ def get(length=nil)
77
+ length = @need if length.nil?
78
+ data = @buffer[0 ... length]
79
+ @buffer = @buffer[length .. -1]
80
+ return data
81
+ end # def get
82
+
83
+ # Set the minimum number of bytes we need in the buffer for the next read.
84
+ private
85
+ def need(length)
86
+ @need = length
87
+ end # def need
88
+
89
+ # State: Flags (fin, etc) and Opcode.
90
+ # See: http://tools.ietf.org/html/rfc6455#section-5.3
91
+ private
92
+ def flags_and_opcode
93
+ # 0
94
+ # 0 1 2 3 4 5 6 7
95
+ # +-+-+-+-+-------
96
+ # |F|R|R|R| opcode
97
+ # |I|S|S|S| (4)
98
+ # |N|V|V|V|
99
+ # | |1|2|3|
100
+ byte = get.bytes.first
101
+ @opcode = byte & 0xF # last 4 bites
102
+ @fin = (byte & 0x80 == 0x80)# first bit
103
+
104
+ #p :byte => byte, :bits => byte.to_s(2), :opcode => @opcode, :fin => @fin
105
+ # mask_and_payload_length has a minimum length
106
+ # of 1 byte, so start there.
107
+ transition(:mask_and_payload_init, 1)
108
+
109
+ # This state yields no output.
110
+ return nil
111
+ end # def flags_and_opcode
112
+
113
+ # State: mask_and_payload_init
114
+ # See: http://tools.ietf.org/html/rfc6455#section-5.2
115
+ private
116
+ def mask_and_payload_init
117
+ byte = get.bytes.first
118
+ @mask = byte & 0x80 # first bit (msb)
119
+ @payload_length = byte & 0x7F # remaining bits are the length
120
+ case @payload_length
121
+ when 126 # 2 byte, unsigned value is the payload length
122
+ transition(:extended_payload_length, 2)
123
+ when 127 # 8 byte, unsigned value is the payload length
124
+ transition(:extended_payload_length, 8)
125
+ else
126
+ # Keep the current payload length, a 7 bit value.
127
+ # Go to read the payload
128
+ transition(:payload, @payload_length)
129
+ end # case @payload_length
130
+
131
+ # This state yields no output.
132
+ return nil
133
+ end # def mask_and_payload_init
134
+
135
+ # State: payload_length
136
+ # This is the 'extended payload length' with support for both 16
137
+ # and 64 bit lengths.
138
+ # See: http://tools.ietf.org/html/rfc6455#section-5.2
139
+ private
140
+ def payload_length
141
+ # 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
142
+ # +-+-+-+-+-------+-+-------------+-------------------------------+
143
+ # |F|R|R|R| opcode|M| Payload len | Extended payload length |
144
+ # |I|S|S|S| (4) |A| (7) | (16/64) |
145
+ # |N|V|V|V| |S| | (if payload len==126/127) |
146
+ # | |1|2|3| |K| | |
147
+ # +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
148
+ # | Extended payload length continued, if payload len == 127 |
149
+ # + - - - - - - - - - - - - - - - +-------------------------------+
150
+ # | |Masking-key, if MASK set to 1 |
151
+ # +-------------------------------+-------------------------------+
152
+ data = get
153
+ case @need
154
+ when 2
155
+ @payload_length = data.unpack("S")
156
+ when 8
157
+ @payload_length = data.unpack("Q")
158
+ else
159
+ raise "Unknown payload_length byte length '#{@need}'"
160
+ end
161
+
162
+ transition(:payload, @payload_length)
163
+
164
+ # This state yields no output.
165
+ return nil
166
+ end # def payload_length
167
+
168
+ # State: payload
169
+ # Read the full payload and return it.
170
+ # See: http://tools.ietf.org/html/rfc6455#section-5.3
171
+ #
172
+ private
173
+ def payload
174
+ # TODO(sissel): Handle massive payload lengths without exceeding memory.
175
+ # Perhaps if the payload is large (say, larger than 500KB by default),
176
+ # instead of returning the whole thing, simply return an Enumerable that
177
+ # yields chunks of the payload. There's no reason to buffer the entire
178
+ # thing. Have the consumer of this library make that decision.
179
+ data = get(@need)
180
+ transition(:flags_and_opcode, 1)
181
+ return data
182
+ end # def payload
183
+ end # class FTW::WebSocket::Parser
@@ -0,0 +1,16 @@
1
+ require "rubygems"
2
+ require "minitest/spec"
3
+ require "minitest/autorun"
4
+
5
+ # Get coverage report
6
+ require "simplecov"
7
+ SimpleCov.start
8
+
9
+ # Add '../lib' to the require path.
10
+ $: << File.join(File.dirname(__FILE__), "..", "lib")
11
+
12
+ glob = File.join(File.dirname(__FILE__), "ftw", "**", "*.rb")
13
+ Dir.glob(glob).each do |path|
14
+ puts "Loading tests from #{path}"
15
+ require File.expand_path(path)
16
+ end
@@ -0,0 +1,12 @@
1
+ require File.join(File.expand_path(__FILE__).sub(/\/ftw\/.*/, "/testing"))
2
+ require "ftw/crlf"
3
+
4
+ describe FTW::CRLF do
5
+ test "CRLF is as expected" do
6
+ class Foo
7
+ include FTW::CRLF
8
+ end
9
+
10
+ assert_equal("\r\n", Foo::CRLF)
11
+ end
12
+ end
@@ -0,0 +1,6 @@
1
+ require File.join(File.expand_path(__FILE__).sub(/\/ftw\/.*/, "/testing"))
2
+ require "ftw/dns"
3
+
4
+ describe FTW::DNS do
5
+ # TODO(sissel): mock Socket.gethostbyname?
6
+ end
@@ -1,9 +1,9 @@
1
- require File.join(File.expand_path(__FILE__).sub(/\/net\/ftw\/.*/, "/testing"))
2
- require "net/ftw/http/headers"
1
+ require File.join(File.expand_path(__FILE__).sub(/\/ftw\/.*/, "/testing"))
2
+ require "ftw/http/headers"
3
3
 
4
- describe Net::FTW::HTTP::Headers do
4
+ describe FTW::HTTP::Headers do
5
5
  before do
6
- @headers = Net::FTW::HTTP::Headers.new
6
+ @headers = FTW::HTTP::Headers.new
7
7
  end
8
8
 
9
9
  test "add adds" do
@@ -47,4 +47,4 @@ describe Net::FTW::HTTP::Headers do
47
47
  @headers.remove("foo", "two")
48
48
  assert_equal("one", @headers.get("foo"))
49
49
  end
50
- end # describe Net::FTW::HTTP::Headers
50
+ end # describe FTW::HTTP::Headers
@@ -1,6 +1,5 @@
1
1
  require "rubygems"
2
2
  require "minitest/spec"
3
- require "minitest/autorun"
4
3
 
5
4
  # Add '../lib' to the require path.
6
5
  $: << File.join(File.dirname(__FILE__), "..", "lib")
@@ -13,11 +12,3 @@ class MiniTest::Spec
13
12
  alias :test :it
14
13
  end
15
14
  end
16
-
17
- if __FILE__ == $0
18
- glob = File.join(File.dirname(__FILE__), "net", "**", "*.rb")
19
- Dir.glob(glob).each do |path|
20
- puts "Loading tests from #{path}"
21
- require File.expand_path(path)
22
- end
23
- end