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