ftw 0.0.6 → 0.0.7

Sign up to get free protection for your applications and to get access to all the features.
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
- TBD. Will likely surround 'rack'. Need to find out what servers actually can
65
- support HTTP Upgrade.
65
+ Not sure yet...
66
66
 
67
- It's possible the 'cramp' gem supports all the server-side features we need
68
- (except for SPDY, I suppose, which I might be able to contribute upstream)
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
@@ -2,3 +2,4 @@ require "ftw/agent"
2
2
  require "ftw/connection"
3
3
  require "ftw/dns"
4
4
  require "ftw/version"
5
+ require "ftw/server"
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
- # @params
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
- connection
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!)
@@ -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 "backport-bij" # for Array#rotate, IO::WaitWritable, etc, in ruby < 1.9
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
- include FTW::Poolable
20
- include Cabin::Inspectable
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
- close if connected?
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 e
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 true
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
- @socket.sysread(@read_size, @read_buffer)
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
- ready = IO.select(nil, [@socket], nil, timeout)
195
- return !ready.nil?
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
- #return false if @reader_closed
204
- ready = IO.select([@socket], nil, nil, timeout)
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 an important piece
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.connect_nonblock
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
- # If the exception string is *not* 'read would block' we have a real
252
- # problem.
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 ConnectTimeout.new if time_left < 0
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
- # try connect_nonblock again if the socket is ready
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 secure
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
- [:domain, :path, :comment, :expires, :secure, :httponly].each do |iv|
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