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 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