ftw 0.0.6 → 0.0.7
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/README.md +14 -4
- data/lib/ftw.rb +1 -0
- data/lib/ftw/agent.rb +34 -17
- data/lib/ftw/connection.rb +109 -23
- data/lib/ftw/cookies.rb +16 -6
- data/lib/ftw/crlf.rb +3 -1
- data/lib/ftw/dns.rb +4 -5
- data/lib/ftw/namespace.rb +2 -0
- data/lib/ftw/pool.rb +7 -2
- data/lib/ftw/protocol.rb +60 -0
- data/lib/ftw/request.rb +4 -31
- data/lib/ftw/server.rb +110 -0
- data/lib/ftw/singleton.rb +13 -0
- data/lib/ftw/version.rb +3 -1
- data/lib/ftw/websocket.rb +11 -64
- data/lib/ftw/websocket/constants.rb +28 -0
- data/lib/ftw/websocket/parser.rb +51 -12
- data/lib/ftw/websocket/rack.rb +77 -0
- data/lib/ftw/websocket/writer.rb +114 -0
- data/lib/rack/handler/ftw.rb +153 -0
- data/test/all.rb +10 -2
- data/test/docs.rb +42 -0
- metadata +27 -19
data/README.md
CHANGED
@@ -21,6 +21,7 @@ Desired features:
|
|
21
21
|
* HTTP and SPDY support.
|
22
22
|
* WebSockets support.
|
23
23
|
* SSL/TLS support.
|
24
|
+
* Browser Agent features like cookies and caching
|
24
25
|
* An API that lets me do what I need.
|
25
26
|
* Server and Client modes.
|
26
27
|
* Support for both normal operation and EventMachine would be nice.
|
@@ -61,11 +62,13 @@ I do not plan on exposing any direct means for invoking SPDY.
|
|
61
62
|
|
62
63
|
## Server API
|
63
64
|
|
64
|
-
|
65
|
-
support HTTP Upgrade.
|
65
|
+
Not sure yet...
|
66
66
|
|
67
|
-
|
68
|
-
|
67
|
+
Since Rack is not supported, I'll have to do a lot of legwork myself.
|
68
|
+
|
69
|
+
* Implement a proper Socket Server api
|
70
|
+
* Implement a HTTP server on top of that (add SPDY support later)
|
71
|
+
* Implement a Sinatra-like DSL on top of HTTP
|
69
72
|
|
70
73
|
## Other Projects
|
71
74
|
|
@@ -76,3 +79,10 @@ Here are some related projects that I have no affiliation with:
|
|
76
79
|
* https://github.com/lifo/cramp - real-time web framework (async, websockets)
|
77
80
|
* https://github.com/igrigorik/em-http-request - HTTP client for EventMachine
|
78
81
|
* https://github.com/geemus/excon - http client library
|
82
|
+
|
83
|
+
## Missing Features
|
84
|
+
|
85
|
+
* No Rack support, for now. There are technical requirements the Rack SPEC that
|
86
|
+
prevent rack applications from really servicing uploads, HTTP Upgrades, etc.
|
87
|
+
Details here: https://github.com/rack/rack/issues/347
|
88
|
+
|
data/lib/ftw.rb
CHANGED
data/lib/ftw/agent.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
require "ftw/namespace"
|
2
2
|
require "ftw/request"
|
3
3
|
require "ftw/connection"
|
4
|
+
require "ftw/protocol"
|
4
5
|
require "ftw/pool"
|
5
6
|
require "ftw/websocket"
|
6
7
|
require "addressable/uri"
|
@@ -35,6 +36,9 @@ require "logger"
|
|
35
36
|
#
|
36
37
|
# TODO(sissel): TBD: implement cookies... delicious chocolate chip cookies.
|
37
38
|
class FTW::Agent
|
39
|
+
include FTW::Protocol
|
40
|
+
|
41
|
+
# List of standard HTTP methods described in RFC2616
|
38
42
|
STANDARD_METHODS = %w(options get head post put delete trace connect)
|
39
43
|
|
40
44
|
# Everything is private by default.
|
@@ -95,6 +99,8 @@ class FTW::Agent
|
|
95
99
|
# This will send the http request. If the websocket handshake
|
96
100
|
# is successful, a FTW::WebSocket instance will be returned.
|
97
101
|
# Otherwise, a FTW::Response will be returned.
|
102
|
+
#
|
103
|
+
# See {#request} for what the 'uri' and 'options' parameters should be.
|
98
104
|
def websocket!(uri, options={})
|
99
105
|
# TODO(sissel): Use FTW::Agent#upgrade! ?
|
100
106
|
req = request("GET", uri, options)
|
@@ -103,17 +109,6 @@ class FTW::Agent
|
|
103
109
|
if ws.handshake_ok?(response)
|
104
110
|
# response.body is a FTW::Connection
|
105
111
|
ws.connection = response.body
|
106
|
-
|
107
|
-
# TODO(sissel): Investigate this bug
|
108
|
-
# There seems to be a bug in http_parser.rb (or in this library) where
|
109
|
-
# websocket responses lead with a newline for some reason. Work around
|
110
|
-
# it.
|
111
|
-
data = response.body.read
|
112
|
-
if data[0] == "\n"
|
113
|
-
response.body.pushback(data[1..-1])
|
114
|
-
else
|
115
|
-
response.body.pushback(data)
|
116
|
-
end
|
117
112
|
return ws
|
118
113
|
else
|
119
114
|
return response
|
@@ -158,12 +153,16 @@ class FTW::Agent
|
|
158
153
|
#
|
159
154
|
# Redirects are always followed.
|
160
155
|
#
|
161
|
-
# @
|
156
|
+
# @param [FTW::Request]
|
162
157
|
# @return [FTW::Response] the response for this request.
|
163
158
|
def execute(request)
|
164
159
|
# TODO(sissel): Make redirection-following optional, but default.
|
165
160
|
|
166
|
-
connection = connect(request.headers["Host"], request.port)
|
161
|
+
connection, error = connect(request.headers["Host"], request.port)
|
162
|
+
if !error.nil?
|
163
|
+
p :error => error
|
164
|
+
raise error
|
165
|
+
end
|
167
166
|
connection.secure if request.protocol == "https"
|
168
167
|
response = request.execute(connection)
|
169
168
|
|
@@ -192,7 +191,12 @@ class FTW::Agent
|
|
192
191
|
@logger.debug("Redirecting", :location => response.headers["Location"])
|
193
192
|
redirects += 1
|
194
193
|
request.use_uri(response.headers["Location"])
|
195
|
-
connection = connect(request.headers["Host"], request.port)
|
194
|
+
connection, error = connect(request.headers["Host"], request.port)
|
195
|
+
# TODO(sissel): Do better error handling than raising.
|
196
|
+
if !error.nil?
|
197
|
+
p :error => error
|
198
|
+
raise error
|
199
|
+
end
|
196
200
|
connection.secure if request.protocol == "https"
|
197
201
|
response = request.execute(connection)
|
198
202
|
end
|
@@ -213,15 +217,28 @@ class FTW::Agent
|
|
213
217
|
def connect(host, port)
|
214
218
|
address = "#{host}:#{port}"
|
215
219
|
@logger.debug("Fetching from pool", :address => address)
|
220
|
+
error = nil
|
216
221
|
connection = @pool.fetch(address) do
|
217
222
|
@logger.info("New connection to #{address}")
|
218
223
|
connection = FTW::Connection.new(address)
|
219
|
-
connection.connect
|
220
|
-
|
224
|
+
error = connection.connect
|
225
|
+
if !error.nil?
|
226
|
+
# Return nil to the pool, so like, we failed..
|
227
|
+
nil
|
228
|
+
else
|
229
|
+
# Otherwise return our new connection
|
230
|
+
connection
|
231
|
+
end
|
221
232
|
end
|
233
|
+
|
234
|
+
if !error.nil?
|
235
|
+
@logger.error("Connection failed", :destination => address, :error => error)
|
236
|
+
return nil, error
|
237
|
+
end
|
238
|
+
|
222
239
|
@logger.debug("Pool fetched a connection", :connection => connection)
|
223
240
|
connection.mark
|
224
|
-
return connection
|
241
|
+
return connection, nil
|
225
242
|
end # def connect
|
226
243
|
|
227
244
|
public(:initialize, :execute, :websocket!, :upgrade!)
|
data/lib/ftw/connection.rb
CHANGED
@@ -4,7 +4,7 @@ require "ftw/poolable"
|
|
4
4
|
require "ftw/namespace"
|
5
5
|
require "socket"
|
6
6
|
require "timeout" # ruby stdlib, just for the Timeout exception.
|
7
|
-
require "
|
7
|
+
require "backports" # for Array#rotate, IO::WaitWritable, etc, in ruby < 1.9
|
8
8
|
|
9
9
|
# A network connection. This is TCP.
|
10
10
|
#
|
@@ -12,12 +12,27 @@ require "backport-bij" # for Array#rotate, IO::WaitWritable, etc, in ruby < 1.9
|
|
12
12
|
# (at least, in MRI you can)
|
13
13
|
#
|
14
14
|
# You can activate SSL/TLS on this connection by invoking FTW::Connection#secure
|
15
|
+
#
|
16
|
+
# This class also implements buffering itself because some IO-like classes
|
17
|
+
# (OpenSSL::SSL::SSLSocket) do not support IO#ungetbyte
|
15
18
|
class FTW::Connection
|
19
|
+
include FTW::Poolable
|
20
|
+
include Cabin::Inspectable
|
21
|
+
|
22
|
+
# A connection attempt timed out
|
16
23
|
class ConnectTimeout < StandardError; end
|
24
|
+
|
25
|
+
# A connection attempt was rejected
|
26
|
+
class ConnectRefused < StandardError; end
|
27
|
+
|
28
|
+
# A read timed out
|
17
29
|
class ReadTimeout < StandardError; end
|
30
|
+
|
31
|
+
# A write timed out
|
18
32
|
class WriteTimeout < StandardError; end
|
19
|
-
|
20
|
-
|
33
|
+
|
34
|
+
# Secure setup timed out
|
35
|
+
class SecureHandshakeTimeout < StandardError; end
|
21
36
|
|
22
37
|
private
|
23
38
|
|
@@ -38,6 +53,10 @@ class FTW::Connection
|
|
38
53
|
@destinations = destinations
|
39
54
|
end
|
40
55
|
|
56
|
+
setup
|
57
|
+
end # def initialize
|
58
|
+
|
59
|
+
def setup
|
41
60
|
@logger = Cabin::Channel.get($0)
|
42
61
|
@connect_timeout = 2
|
43
62
|
|
@@ -61,13 +80,47 @@ class FTW::Connection
|
|
61
80
|
# TODO(sissel): Barf if a destination is not of the form "host:port"
|
62
81
|
end # def initialize
|
63
82
|
|
83
|
+
# Create a new connection from an existing IO instance (like a socket)
|
84
|
+
#
|
85
|
+
# Valid modes are :server and :client.
|
86
|
+
#
|
87
|
+
# * specify :server if this connection is from a server (via Socket#accept)
|
88
|
+
# * specify :client if this connection is from a client (via Socket#connect)
|
89
|
+
def self.from_io(io, mode=:server)
|
90
|
+
valid_modes = [:server, :client]
|
91
|
+
if !valid_modes.include?(mode)
|
92
|
+
raise InvalidArgument.new("Invalid connection mode '#{mode}'. Valid modes: #{valid_modes.inspect}")
|
93
|
+
end
|
94
|
+
|
95
|
+
connection = self.new(nil) # New connection with no destinations
|
96
|
+
connection.instance_eval do
|
97
|
+
@socket = io
|
98
|
+
@connected = true
|
99
|
+
port, address = Socket.unpack_sockaddr_in(io.getpeername)
|
100
|
+
@remote_address = "#{address}:#{port}"
|
101
|
+
@mode = mode
|
102
|
+
end
|
103
|
+
return connection
|
104
|
+
end # def self.from_io
|
105
|
+
|
64
106
|
# Connect now.
|
65
107
|
#
|
66
108
|
# Timeout value is optional. If no timeout is given, this method
|
67
109
|
# blocks until a connection is successful or an error occurs.
|
110
|
+
#
|
111
|
+
# You should check the return value of this method to determine if
|
112
|
+
# a connection was successful.
|
113
|
+
#
|
114
|
+
# Possible return values are on error include:
|
115
|
+
#
|
116
|
+
# * FTW::Connection::ConnectRefused
|
117
|
+
# * FTW::Connection::ConnectTimeout
|
118
|
+
#
|
119
|
+
# @return [nil] if the connection was successful
|
120
|
+
# @return [StandardError or subclass] if the connection failed
|
68
121
|
def connect(timeout=nil)
|
69
122
|
# TODO(sissel): Raise if we're already connected?
|
70
|
-
|
123
|
+
disconnect("reconnecting") if connected?
|
71
124
|
host, port = @destinations.first.split(":")
|
72
125
|
@destinations = @destinations.rotate # round-robin
|
73
126
|
|
@@ -88,10 +141,11 @@ class FTW::Connection
|
|
88
141
|
# Connect with timeout
|
89
142
|
begin
|
90
143
|
@socket.connect_nonblock(sockaddr)
|
91
|
-
rescue IO::WaitWritable
|
144
|
+
rescue IO::WaitWritable, Errno::EINPROGRESS
|
92
145
|
# Ruby actually raises Errno::EINPROGRESS, but for some reason
|
93
146
|
# the documentation says to use this IO::WaitWritable thing...
|
94
147
|
# I don't get it, but whatever :(
|
148
|
+
|
95
149
|
if writable?(timeout)
|
96
150
|
begin
|
97
151
|
@socket.connect_nonblock(sockaddr) # check connection failure
|
@@ -99,18 +153,25 @@ class FTW::Connection
|
|
99
153
|
# Ignore, we're already connected.
|
100
154
|
rescue Errno::ECONNREFUSED => e
|
101
155
|
# Fire 'disconnected' event with reason :refused
|
102
|
-
return
|
156
|
+
return ConnectRefused.new("#{host}[#{@remote_address}]:#{port}")
|
157
|
+
rescue Errno::ETIMEDOUT
|
158
|
+
# This occurs when the system's TCP timeout hits, we have no control
|
159
|
+
# over this, as far as I can tell. *maybe* setsockopt(2) has a flag
|
160
|
+
# for this, but I haven't checked..
|
161
|
+
# TODO(sissel): We should instead do 'retry' unless we've exceeded
|
162
|
+
# the timeout.
|
163
|
+
return ConnectTimeout.new("#{host}[#{@remote_address}]:#{port}")
|
103
164
|
end
|
104
165
|
else
|
105
166
|
# Connection timeout
|
106
167
|
# Fire 'disconnected' event with reason :timeout
|
107
|
-
return ConnectTimeout.new
|
168
|
+
return ConnectTimeout.new("#{host}[#{@remote_address}]:#{port}")
|
108
169
|
end
|
109
170
|
end
|
110
171
|
|
111
172
|
# We're now connected.
|
112
173
|
@connected = true
|
113
|
-
return
|
174
|
+
return nil
|
114
175
|
end # def connect
|
115
176
|
|
116
177
|
# Is this Connection connected?
|
@@ -138,7 +199,7 @@ class FTW::Connection
|
|
138
199
|
#
|
139
200
|
# This method is not guaranteed to read exactly 'length' bytes. See
|
140
201
|
# IO#sysread
|
141
|
-
def read(timeout=nil)
|
202
|
+
def read(length=16384, timeout=nil)
|
142
203
|
data = ""
|
143
204
|
data.force_encoding("BINARY") if data.respond_to?(:force_encoding)
|
144
205
|
have_pushback = !@pushback_buffer.empty?
|
@@ -151,7 +212,8 @@ class FTW::Connection
|
|
151
212
|
|
152
213
|
if readable?(timeout)
|
153
214
|
begin
|
154
|
-
|
215
|
+
# Read at most 'length' data, so read less from the socket
|
216
|
+
@socket.sysread(@read_size - data.length, @read_buffer)
|
155
217
|
data << @read_buffer
|
156
218
|
return data
|
157
219
|
rescue EOFError => e
|
@@ -191,8 +253,8 @@ class FTW::Connection
|
|
191
253
|
#
|
192
254
|
# The time out is in seconds. Fractional seconds are OK.
|
193
255
|
def writable?(timeout)
|
194
|
-
|
195
|
-
return !
|
256
|
+
readable, writable, errors = IO.select(nil, [@socket], nil, timeout)
|
257
|
+
return !writable.nil?
|
196
258
|
end # def writable?
|
197
259
|
|
198
260
|
# Is this connection readable? Returns true if it is readable within
|
@@ -200,9 +262,8 @@ class FTW::Connection
|
|
200
262
|
#
|
201
263
|
# The time out is in seconds. Fractional seconds are OK.
|
202
264
|
def readable?(timeout)
|
203
|
-
|
204
|
-
|
205
|
-
return !ready.nil?
|
265
|
+
readable, writable, errors = IO.select([@socket], nil, nil, timeout)
|
266
|
+
return !readable.nil?
|
206
267
|
end # def readable?
|
207
268
|
|
208
269
|
# The host:port
|
@@ -225,18 +286,30 @@ class FTW::Connection
|
|
225
286
|
require "openssl"
|
226
287
|
sslcontext = OpenSSL::SSL::SSLContext.new
|
227
288
|
sslcontext.ssl_version = :TLSv1
|
228
|
-
# If you use VERIFY_NONE, you are removing
|
289
|
+
# If you use VERIFY_NONE, you are removing the trust feature of TLS. Don't do that.
|
290
|
+
# Encryption without trust means you don't know who you are talking to.
|
229
291
|
sslcontext.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
230
292
|
# TODO(sissel): Try to be smart about setting this default.
|
231
293
|
sslcontext.ca_path = "/etc/ssl/certs"
|
232
294
|
@socket = OpenSSL::SSL::SSLSocket.new(@socket, sslcontext)
|
233
295
|
|
296
|
+
# TODO(sissel): Set up local certificat/key stuff. This is required for
|
297
|
+
# server-side ssl operation, I think.
|
298
|
+
|
299
|
+
if client?
|
300
|
+
do_secure(:connect_nonblock)
|
301
|
+
else
|
302
|
+
do_secure(:accept_nonblock)
|
303
|
+
end
|
304
|
+
end # def secure
|
305
|
+
|
306
|
+
def do_secure(handshake_method)
|
234
307
|
# SSLSocket#connect_nonblock will do the SSL/TLS handshake.
|
235
308
|
# TODO(sissel): refactor this into a method that both secure and connect
|
236
309
|
# methods can call.
|
237
310
|
start = Time.now
|
238
311
|
begin
|
239
|
-
@socket.
|
312
|
+
@socket.send(handshake_method)
|
240
313
|
rescue IO::WaitReadable, IO::WaitWritable
|
241
314
|
# The ruby OpenSSL docs for 1.9.3 have example code saying I should use
|
242
315
|
# IO::WaitReadable, but in the real world it raises an SSLError with
|
@@ -248,30 +321,43 @@ class FTW::Connection
|
|
248
321
|
# raises, WaitWritable (ok, Errno::EINPROGRESS, technically)
|
249
322
|
# Ruby's SSL exception for 'this call would block' is pretty shitty.
|
250
323
|
#
|
251
|
-
#
|
252
|
-
#
|
324
|
+
# So we rescue both IO::Wait{Readable,Writable} and keep trying
|
325
|
+
# until timeout occurs.
|
326
|
+
#
|
253
327
|
|
254
328
|
if !timeout.nil?
|
255
329
|
time_left = timeout - (Time.now - start)
|
256
|
-
raise
|
330
|
+
raise SecureHandshakeTimeout.new if time_left < 0
|
257
331
|
r, w, e = IO.select([@socket], [@socket], nil, time_left)
|
258
332
|
else
|
259
333
|
r, w, e = IO.select([@socket], [@socket], nil, timeout)
|
260
334
|
end
|
261
335
|
|
262
|
-
#
|
336
|
+
# keep going if the socket is ready
|
263
337
|
retry if r.size > 0 || w.size > 0
|
338
|
+
rescue => e
|
339
|
+
@logger.warn(e)
|
340
|
+
raise e
|
264
341
|
end
|
265
342
|
|
266
343
|
@secure = true
|
267
|
-
end # def
|
344
|
+
end # def do_secure
|
268
345
|
|
269
346
|
# Has this connection been secured?
|
270
347
|
def secured?
|
271
348
|
return @secure
|
272
349
|
end # def secured?
|
273
350
|
|
351
|
+
def client?
|
352
|
+
return @mode == :client
|
353
|
+
end # def client?
|
354
|
+
|
355
|
+
def server?
|
356
|
+
return @mode == :server
|
357
|
+
end # def server?
|
358
|
+
|
274
359
|
public(:connect, :connected?, :write, :read, :pushback, :disconnect,
|
275
|
-
:writable?, :readable?, :peer, :to_io, :secure, :secured
|
360
|
+
:writable?, :readable?, :peer, :to_io, :secure, :secured?,
|
361
|
+
:client?, :server?)
|
276
362
|
end # class FTW::Connection
|
277
363
|
|
data/lib/ftw/cookies.rb
CHANGED
@@ -3,9 +3,11 @@ require "cabin"
|
|
3
3
|
|
4
4
|
# Based on behavior and things described in RFC6265
|
5
5
|
class FTW::Cookies
|
6
|
+
|
7
|
+
# This is a Cookie. It expires, has a value, a name, etc.
|
8
|
+
# I could have used stdlib CGI::Cookie, but it actually parses cookie strings
|
9
|
+
# incorrectly and also lacks the 'httponly' attribute.
|
6
10
|
class Cookie
|
7
|
-
# I could use stdlib CGI::Cookie, but it actually parses cookie strings
|
8
|
-
# incorrectly and also lacks the 'httponly' attribute
|
9
11
|
attr_accessor :name
|
10
12
|
attr_accessor :value
|
11
13
|
|
@@ -18,12 +20,16 @@ class FTW::Cookies
|
|
18
20
|
|
19
21
|
# TODO(sissel): Support 'extension-av' ? RFC6265 section 4.1.1
|
20
22
|
# extension-av = <any CHAR except CTLs or ";">
|
21
|
-
|
23
|
+
|
24
|
+
# List of standard cookie attributes
|
25
|
+
STANDARD_ATTRIBUTES = [:domain, :path, :comment, :expires, :secure, :httponly]
|
26
|
+
|
27
|
+
# A new cookie. Value and attributes are optional.
|
22
28
|
def initialize(name, value=nil, attributes={})
|
23
29
|
@name = name
|
24
30
|
@value = value
|
25
31
|
|
26
|
-
|
32
|
+
STANDARD_ATTRIBUTES.each do |iv|
|
27
33
|
instance_variable_set("@#{iv.to_s}", attributes.delete(iv))
|
28
34
|
end
|
29
35
|
|
@@ -52,9 +58,9 @@ class FTW::Cookies
|
|
52
58
|
# TODO(sissel): Parse the Max-Age value and convert it to 'expires'
|
53
59
|
#extra[:expires] =
|
54
60
|
when /^Domain=/
|
55
|
-
extra[:domain] = attr[7
|
61
|
+
extra[:domain] = attr[7..-1]
|
56
62
|
when /^Path=/
|
57
|
-
extra[:path] = attr[5
|
63
|
+
extra[:path] = attr[5..-1]
|
58
64
|
when /^Secure/
|
59
65
|
extra[:secure] = true
|
60
66
|
when /^HttpOnly/
|
@@ -66,20 +72,24 @@ class FTW::Cookies
|
|
66
72
|
end # def Cookie.parse
|
67
73
|
end # class Cookie
|
68
74
|
|
75
|
+
# A new cookies store
|
69
76
|
def initialize
|
70
77
|
@cookies = []
|
71
78
|
end # def initialize
|
72
79
|
|
80
|
+
# Add a cookie
|
73
81
|
def add(name, value=nil, attributes={})
|
74
82
|
cookie = Cookie.new(name, value, attributes)
|
75
83
|
@cookies << cookie
|
76
84
|
end # def add
|
77
85
|
|
86
|
+
# Add a cookie from a header 'Set-Cookie' value
|
78
87
|
def add_from_header(set_cookie_string)
|
79
88
|
cookie = Cookie.parse(set_cookie_string)
|
80
89
|
@cookies << cookie
|
81
90
|
end # def add_from_header
|
82
91
|
|
92
|
+
# Get cookies for a URL
|
83
93
|
def for_url(url)
|
84
94
|
# TODO(sissel): only return cookies that are valid for the url
|
85
95
|
return @cookies
|