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 +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/lib/ftw/crlf.rb
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
require "ftw/namespace"
|
2
2
|
|
3
|
+
# This module provides a 'CRLF' constant for use with protocols that need it.
|
4
|
+
# I find it easier to specify CRLF instead of literal "\r\n"
|
3
5
|
module FTW::CRLF
|
4
6
|
# carriage-return + line-feed
|
5
7
|
CRLF = "\r\n"
|
6
|
-
end
|
8
|
+
end # module FTW::CRLF
|
data/lib/ftw/dns.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
require "ftw/namespace"
|
2
2
|
require "socket" # for Socket.gethostbyname
|
3
|
+
require "ftw/singleton"
|
3
4
|
|
4
5
|
# I wrap whatever Ruby provides because it is historically very
|
5
6
|
# inconsistent in implementation behavior across ruby platforms and versions.
|
@@ -9,16 +10,12 @@ require "socket" # for Socket.gethostbyname
|
|
9
10
|
# I didn't really want to write a DNS library, but a consistent API and
|
10
11
|
# behavior is necessary for my continued sanity :)
|
11
12
|
class FTW::DNS
|
13
|
+
extend FTW::Singleton
|
12
14
|
# TODO(sissel): Switch to using Resolv::DNS since it lets you (the programmer)
|
13
15
|
# choose dns configuration (servers, etc)
|
14
16
|
|
15
17
|
V4_IN_V6_PREFIX = "0:" * 12
|
16
18
|
|
17
|
-
# Get a singleton instance of FTW::DNS
|
18
|
-
def self.singleton
|
19
|
-
@resolver ||= self.new
|
20
|
-
end # def self.singleton
|
21
|
-
|
22
19
|
private
|
23
20
|
|
24
21
|
# Resolve a hostname.
|
@@ -48,10 +45,12 @@ class FTW::DNS
|
|
48
45
|
return addresses[rand(addresses.size)]
|
49
46
|
end # def resolve_random
|
50
47
|
|
48
|
+
# Unserialize a 4-byte ipv4 address into a human-readable a.b.c.d string
|
51
49
|
def unpack_v4(address)
|
52
50
|
return address.unpack("C4").join(".")
|
53
51
|
end # def unpack_v4
|
54
52
|
|
53
|
+
# Unserialize a 16-byte ipv6 address into a human-readable a:b:c:...:d string
|
55
54
|
def unpack_v6(address)
|
56
55
|
if address.length == 16
|
57
56
|
# Unpack 16 bit chunks, convert to hex, join with ":"
|
data/lib/ftw/namespace.rb
CHANGED
data/lib/ftw/pool.rb
CHANGED
@@ -44,7 +44,12 @@ class FTW::Pool
|
|
44
44
|
return object if !object.nil?
|
45
45
|
end
|
46
46
|
# Otherwise put the return value of default_block in the
|
47
|
-
# pool and return it.
|
48
|
-
|
47
|
+
# pool and return it, but don't put nil values in the pool.
|
48
|
+
obj = default_block.call
|
49
|
+
if obj.nil?
|
50
|
+
return nil
|
51
|
+
else
|
52
|
+
return add(identifier, obj)
|
53
|
+
end
|
49
54
|
end # def fetch
|
50
55
|
end # class FTW::Pool
|
data/lib/ftw/protocol.rb
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
require "ftw/namespace"
|
2
|
+
require "cabin"
|
3
|
+
require "logger"
|
4
|
+
|
5
|
+
# This module provides web protocol handling as a mixin.
|
6
|
+
module FTW::Protocol
|
7
|
+
# Read an HTTP message from a given connection
|
8
|
+
#
|
9
|
+
# This method blocks until a full http message header has been consumed
|
10
|
+
# (request *or* response)
|
11
|
+
#
|
12
|
+
# The body of the message, if any, will not be consumed, and the read
|
13
|
+
# position for the connection will be left at the end of the message headers.
|
14
|
+
#
|
15
|
+
# The 'connection' object must respond to #read(timeout) and #pushback(string)
|
16
|
+
def read_http_message(connection)
|
17
|
+
parser = HTTP::Parser.new
|
18
|
+
headers_done = false
|
19
|
+
parser.on_headers_complete = proc { headers_done = true; :stop }
|
20
|
+
|
21
|
+
# headers_done will be set to true when parser finishes parsing the http
|
22
|
+
# headers for this request
|
23
|
+
while !headers_done
|
24
|
+
# TODO(sissel): This read could toss an exception of the server aborts
|
25
|
+
# prior to sending the full headers. Figure out a way to make this happy.
|
26
|
+
# Perhaps fabricating a 500 response?
|
27
|
+
data = connection.read(16384)
|
28
|
+
|
29
|
+
# Feed the data into the parser. Offset will be nonzero if there's
|
30
|
+
# extra data beyond the header.
|
31
|
+
offset = parser << data
|
32
|
+
end
|
33
|
+
|
34
|
+
# If we consumed part of the body while parsing headers, put it back
|
35
|
+
# onto the connection's read buffer so the next consumer can use it.
|
36
|
+
if offset < data.length
|
37
|
+
connection.pushback(data[offset .. -1])
|
38
|
+
end
|
39
|
+
|
40
|
+
# This will have an 'http_method' if it's a request
|
41
|
+
if !parser.http_method.nil?
|
42
|
+
# have http_method, so this is an HTTP Request message
|
43
|
+
request = FTW::Request.new
|
44
|
+
request.method = parser.http_method
|
45
|
+
request.request_uri = parser.request_url
|
46
|
+
request.version = "#{parser.http_major}.#{parser.http_minor}".to_f
|
47
|
+
parser.headers.each { |field, value| request.headers.add(field, value) }
|
48
|
+
return request
|
49
|
+
else
|
50
|
+
# otherwise, no http_method, so this is an HTTP Response message
|
51
|
+
response = FTW::Response.new
|
52
|
+
response.version = "#{parser.http_major}.#{parser.http_minor}".to_f
|
53
|
+
response.status = parser.status_code
|
54
|
+
parser.headers.each { |field, value| response.headers.add(field, value) }
|
55
|
+
return response
|
56
|
+
end
|
57
|
+
end # def read_http_message
|
58
|
+
|
59
|
+
public(:read_http_message)
|
60
|
+
end # module FTW::Protocol
|
data/lib/ftw/request.rb
CHANGED
@@ -4,7 +4,7 @@ require "ftw/crlf"
|
|
4
4
|
require "ftw/http/message"
|
5
5
|
require "ftw/namespace"
|
6
6
|
require "ftw/response"
|
7
|
-
require "
|
7
|
+
require "ftw/protocol"
|
8
8
|
require "uri" # ruby stdlib
|
9
9
|
|
10
10
|
# An HTTP Request.
|
@@ -12,6 +12,7 @@ require "uri" # ruby stdlib
|
|
12
12
|
# See RFC2616 section 5: <http://tools.ietf.org/html/rfc2616#section-5>
|
13
13
|
class FTW::Request
|
14
14
|
include FTW::HTTP::Message
|
15
|
+
include FTW::Protocol
|
15
16
|
include FTW::CRLF
|
16
17
|
include Cabin::Inspectable
|
17
18
|
|
@@ -82,36 +83,8 @@ class FTW::Request
|
|
82
83
|
end
|
83
84
|
end
|
84
85
|
|
85
|
-
|
86
|
-
|
87
|
-
parser = HTTP::Parser.new
|
88
|
-
headers_done = false
|
89
|
-
parser.on_headers_complete = proc { headers_done = true; :stop }
|
90
|
-
|
91
|
-
# headers_done will be set to true when parser finishes parsing the http
|
92
|
-
# headers for this request
|
93
|
-
while !headers_done
|
94
|
-
# TODO(sissel): This read could toss an exception of the server aborts
|
95
|
-
# prior to sending the full headers. Figure out a way to make this happy.
|
96
|
-
# Perhaps fabricating a 500 response?
|
97
|
-
data = connection.read
|
98
|
-
|
99
|
-
# Feed the data into the parser. Offset will be nonzero if there's
|
100
|
-
# extra data beyond the header.
|
101
|
-
offset = parser << data
|
102
|
-
end
|
103
|
-
|
104
|
-
# Done reading response header
|
105
|
-
response = FTW::Response.new
|
106
|
-
response.version = "#{parser.http_major}.#{parser.http_minor}".to_f
|
107
|
-
response.status = parser.status_code
|
108
|
-
parser.headers.each { |field, value| response.headers.add(field, value) }
|
109
|
-
|
110
|
-
# If we consumed part of the body while parsing headers, put it back
|
111
|
-
# onto the connection's read buffer so the next consumer can use it.
|
112
|
-
if offset < data.length
|
113
|
-
connection.pushback(data[offset .. -1])
|
114
|
-
end
|
86
|
+
response = read_http_message(connection)
|
87
|
+
# TODO(sissel): make sure we got a response, not a request, cuz that'd be weird.
|
115
88
|
return response
|
116
89
|
end # def execute
|
117
90
|
|
data/lib/ftw/server.rb
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
require "ftw/namespace"
|
2
|
+
|
3
|
+
# A web server.
|
4
|
+
class FTW::Server
|
5
|
+
# This class is raised when an error occurs starting the server sockets.
|
6
|
+
class ServerSetupFailure < StandardError; end
|
7
|
+
|
8
|
+
# This class is raised when an invalid address is given to the server to
|
9
|
+
# listen on.
|
10
|
+
class InvalidAddress < StandardError; end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
# The pattern addresses must match. This is used in FTW::Server#initialize.
|
15
|
+
ADDRESS_RE = /^(.*):([^:]+)$/
|
16
|
+
|
17
|
+
# Create a new server listening on the given addresses
|
18
|
+
#
|
19
|
+
# This method will create, bind, and listen, so any errors during that
|
20
|
+
# process be raised as ServerSetupFailure
|
21
|
+
#
|
22
|
+
# The parameter 'addresses' can be a single string or an array of strings.
|
23
|
+
# These strings MUST have the form "address:port". If the 'address' part
|
24
|
+
# is missing, it is assumed to be 0.0.0.0
|
25
|
+
def initialize(addresses)
|
26
|
+
addresses = [addresses] if !addresses.is_a?(Array)
|
27
|
+
dns = FTW::DNS.singleton
|
28
|
+
|
29
|
+
@sockets = {}
|
30
|
+
|
31
|
+
failures = []
|
32
|
+
# address format is assumed to be 'host:port'
|
33
|
+
# TODO(sissel): The split on ":" breaks ipv6 addresses, yo.
|
34
|
+
addresses.each do |address|
|
35
|
+
m = ADDRESS_RE.match(address)
|
36
|
+
if !m
|
37
|
+
raise InvalidAddress.new("Invalid address #{address.inspect}, spected string with format 'host:port'")
|
38
|
+
end
|
39
|
+
host, port = m[1..2] # first capture is host, second capture is port
|
40
|
+
|
41
|
+
# Permit address being simply ':PORT'
|
42
|
+
host = "0.0.0.0" if host.nil?
|
43
|
+
|
44
|
+
# resolve each hostname, use the first one that successfully binds.
|
45
|
+
local_failures = []
|
46
|
+
dns.resolve(host).each do |ip|
|
47
|
+
#family = ip.include?(":") ? Socket::AF_INET6 : Socket::AF_INET
|
48
|
+
#socket = Socket.new(family, Socket::SOCK_STREAM, 0)
|
49
|
+
#sockaddr = Socket.pack_sockaddr_in(port, ip)
|
50
|
+
socket = TCPServer.new(ip, port)
|
51
|
+
#begin
|
52
|
+
#socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
|
53
|
+
#socket.bind(sockaddr)
|
54
|
+
# If we get here, bind was successful
|
55
|
+
#rescue Errno::EADDRNOTAVAIL => e
|
56
|
+
# TODO(sissel): Record this failure.
|
57
|
+
#local_failures << "Could not bind to #{ip}:#{port}, address not available on this system."
|
58
|
+
#next
|
59
|
+
#rescue Errno::EACCES
|
60
|
+
# TODO(sissel): Record this failure.
|
61
|
+
#local_failures << "No permission to bind to #{ip}:#{port}: #{e.inspect}"
|
62
|
+
#next
|
63
|
+
#end
|
64
|
+
|
65
|
+
begin
|
66
|
+
socket.listen(100)
|
67
|
+
rescue Errno::EADDRINUSE
|
68
|
+
local_failures << "Address in use, #{ip}:#{port}, cannot listen."
|
69
|
+
next
|
70
|
+
end
|
71
|
+
|
72
|
+
# Break when successfully listened
|
73
|
+
p :accept? => socket.respond_to?(:accept)
|
74
|
+
@sockets["#{host}(#{ip}):#{port}"] = socket
|
75
|
+
local_failures.clear
|
76
|
+
break
|
77
|
+
end
|
78
|
+
failures += local_failures
|
79
|
+
end
|
80
|
+
|
81
|
+
# Abort if there were failures
|
82
|
+
raise ServerSetupFailure.new(failures) if failures.any?
|
83
|
+
end # def initialize
|
84
|
+
|
85
|
+
# Close the server sockets
|
86
|
+
def close
|
87
|
+
@sockets.each do |name, socket|
|
88
|
+
socket.close
|
89
|
+
end
|
90
|
+
end # def close
|
91
|
+
|
92
|
+
# Yield FTW::Connection instances to the block as clients connect.
|
93
|
+
def each_connection(&block)
|
94
|
+
# TODO(sissel): Select on all sockets
|
95
|
+
# TODO(sissel): Accept and yield to the block
|
96
|
+
while true
|
97
|
+
sockets = @sockets.values
|
98
|
+
read, write, error = IO.select(sockets, nil, nil, nil)
|
99
|
+
read.each do |serversocket|
|
100
|
+
p serversocket.methods.sort
|
101
|
+
socket, addrinfo = serversocket.accept
|
102
|
+
connection = FTW::Connection.from_io(socket)
|
103
|
+
yield connection
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end # def each_connection
|
107
|
+
|
108
|
+
public(:initialize, :close, :each_connection)
|
109
|
+
end # class FTW::Server
|
110
|
+
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require "ftw/namespace"
|
2
|
+
|
3
|
+
module FTW::Singleton
|
4
|
+
def self.included(klass)
|
5
|
+
raise ArgumentError.new("In #{klass.name}, you want to use 'extend #{self.name}', not 'include ...'")
|
6
|
+
end # def included
|
7
|
+
|
8
|
+
def singleton
|
9
|
+
@instance ||= self.new
|
10
|
+
return @instance
|
11
|
+
end # def self.singleton
|
12
|
+
end # module FTW::Singleton
|
13
|
+
|
data/lib/ftw/version.rb
CHANGED
data/lib/ftw/websocket.rb
CHANGED
@@ -4,6 +4,7 @@ require "base64" # stdlib
|
|
4
4
|
require "digest/sha1" # stdlib
|
5
5
|
require "cabin"
|
6
6
|
require "ftw/websocket/parser"
|
7
|
+
require "ftw/websocket/writer"
|
7
8
|
require "ftw/crlf"
|
8
9
|
|
9
10
|
# WebSockets, RFC6455.
|
@@ -16,17 +17,20 @@ class FTW::WebSocket
|
|
16
17
|
include FTW::CRLF
|
17
18
|
include Cabin::Inspectable
|
18
19
|
|
20
|
+
# The frame identifier for a 'text' frame
|
19
21
|
TEXTFRAME = 0x0001
|
20
22
|
|
23
|
+
# Search RFC6455 for this string and you will find its definitions.
|
24
|
+
# It is used in servers accepting websocket upgrades.
|
21
25
|
WEBSOCKET_ACCEPT_UUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
22
26
|
|
23
|
-
private
|
24
|
-
|
25
27
|
# Protocol phases
|
26
28
|
# 1. tcp connect
|
27
29
|
# 2. http handshake (RFC6455 section 4)
|
28
30
|
# 3. websocket protocol
|
29
31
|
|
32
|
+
private
|
33
|
+
|
30
34
|
# Creates a new websocket and fills in the given http request with any
|
31
35
|
# necessary settings.
|
32
36
|
def initialize(request)
|
@@ -115,75 +119,18 @@ class FTW::WebSocket
|
|
115
119
|
# The text payload of each message will be yielded to the block.
|
116
120
|
def each(&block)
|
117
121
|
loop do
|
118
|
-
|
119
|
-
|
120
|
-
|
122
|
+
@parser.feed(@connection.read(16384)) do |payload|
|
123
|
+
yield payload
|
124
|
+
end
|
121
125
|
end
|
122
126
|
end # def each
|
123
127
|
|
124
|
-
# Implement masking as described by http://tools.ietf.org/html/rfc6455#section-5.3
|
125
|
-
# Basically, we take a 4-byte random string and use it, round robin, to XOR
|
126
|
-
# every byte. Like so:
|
127
|
-
# message[0] ^ key[0]
|
128
|
-
# message[1] ^ key[1]
|
129
|
-
# message[2] ^ key[2]
|
130
|
-
# message[3] ^ key[3]
|
131
|
-
# message[4] ^ key[0]
|
132
|
-
# ...
|
133
|
-
def mask(message, key)
|
134
|
-
masked = []
|
135
|
-
mask_bytes = key.unpack("C4")
|
136
|
-
i = 0
|
137
|
-
message.each_byte do |byte|
|
138
|
-
masked << (byte ^ mask_bytes[i % 4])
|
139
|
-
i += 1
|
140
|
-
end
|
141
|
-
return masked.pack("C*")
|
142
|
-
end # def mask
|
143
|
-
|
144
128
|
# Publish a message text.
|
145
129
|
#
|
146
130
|
# This will send a websocket text frame over the connection.
|
147
131
|
def publish(message)
|
148
|
-
|
149
|
-
|
150
|
-
#
|
151
|
-
# 0 1 2 3
|
152
|
-
# 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
|
153
|
-
# +-+-+-+-+-------+-+-------------+-------------------------------+
|
154
|
-
# |F|R|R|R| opcode|M| Payload len | Extended payload length |
|
155
|
-
# |I|S|S|S| (4) |A| (7) | (16/64) |
|
156
|
-
# |N|V|V|V| |S| | (if payload len==126/127) |
|
157
|
-
# | |1|2|3| |K| | |
|
158
|
-
# +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
|
159
|
-
# | Extended payload length continued, if payload len == 127 |
|
160
|
-
# + - - - - - - - - - - - - - - - +-------------------------------+
|
161
|
-
# | |Masking-key, if MASK set to 1 |
|
162
|
-
# +-------------------------------+-------------------------------+
|
163
|
-
# | Masking-key (continued) | Payload Data |
|
164
|
-
# +-------------------------------- - - - - - - - - - - - - - - - +
|
165
|
-
# : Payload Data continued ... :
|
166
|
-
# + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|
167
|
-
# | Payload Data continued ... |
|
168
|
-
# +---------------------------------------------------------------+
|
169
|
-
# TODO(sissel): Support 'fin' flag
|
170
|
-
# Set 'fin' flag and opcode of 'text frame'
|
171
|
-
length = message.length
|
172
|
-
mask_key = [rand(1 << 32)].pack("Q")
|
173
|
-
if message.length >= (1 << 16)
|
174
|
-
pack = "CCSA4A*" # flags+opcode, mask+len, 2-byte len, payload
|
175
|
-
data = [ 0x80 | TEXTFRAME, 0x80 | 126, message.length, mask_key, mask(message, mask_key) ]
|
176
|
-
@connection.write(data.pack(pack))
|
177
|
-
elsif message.length >= (1 << 7)
|
178
|
-
length = 126
|
179
|
-
pack = "CCQA4A*" # flags+opcode, mask+len, 8-byte len, payload
|
180
|
-
data = [ 0x80 | TEXTFRAME, 0x80 | 127, message.length, mask_key, mask(message, mask_key) ]
|
181
|
-
@connection.write(data.pack(pack))
|
182
|
-
else
|
183
|
-
data = [ 0x80 | TEXTFRAME, 0x80 | message.length, mask_key, mask(message, mask_key) ]
|
184
|
-
pack = "CCA4A*" # flags+opcode, mask+len, payload
|
185
|
-
@connection.write(data.pack(pack))
|
186
|
-
end
|
132
|
+
writer = FTW::WebSocket::Writer.singleton
|
133
|
+
writer.write_text(@connection, message)
|
187
134
|
end # def publish
|
188
135
|
|
189
136
|
public(:initialize, :connection=, :handshake_ok?, :each, :publish)
|
@@ -0,0 +1,28 @@
|
|
1
|
+
|
2
|
+
# The UUID comes from:
|
3
|
+
# http://tools.ietf.org/html/rfc6455#page-23
|
4
|
+
#
|
5
|
+
# The opcode definitions come from:
|
6
|
+
# http://tools.ietf.org/html/rfc6455#section-11.8
|
7
|
+
module FTW::WebSocket::Constants
|
8
|
+
WEBSOCKET_ACCEPT_UUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
9
|
+
|
10
|
+
# Indication that this frame is a continuation in a fragmented message
|
11
|
+
# See RFC6455 page 33.
|
12
|
+
OPCODE_CONTINUATION = 0
|
13
|
+
|
14
|
+
# Indication that this frame contains a text message
|
15
|
+
OPCODE_TEXT = 1
|
16
|
+
|
17
|
+
# Indication that this frame contains a binary message
|
18
|
+
OPCODE_BINARY = 2
|
19
|
+
|
20
|
+
# Indication that this frame is a 'connection close' message
|
21
|
+
OPCODE_CLOSE = 8
|
22
|
+
|
23
|
+
# Indication that this frame is a 'ping' message
|
24
|
+
OPCODE_PING = 9
|
25
|
+
|
26
|
+
# Indication that this frame is a 'pong' message
|
27
|
+
OPCODE_PONG = 10
|
28
|
+
end # module FTW::WebSocket::Constants
|