equity 0.5
Sign up to get free protection for your applications and to get access to all the features.
- data/bin/equity +294 -0
- data/bin/equityctl +103 -0
- data/lib/equity/controller.rb +71 -0
- data/lib/equity/controller/key.rb +177 -0
- data/lib/equity/manager.rb +97 -0
- data/lib/equity/node.rb +113 -0
- data/lib/equity/socket_pairing.rb +15 -0
- metadata +68 -0
data/bin/equity
ADDED
@@ -0,0 +1,294 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
#
|
3
|
+
# equity 0.5 - queueing software load balancer with transmission inspection
|
4
|
+
# and simple HTTP header rewriting
|
5
|
+
#
|
6
|
+
# Usage: equity [-dp] [-h "Header: Value"] <listen-port> [<node-address>:]<node-port> ...
|
7
|
+
# Run equity with no arguments for detailed usage information.
|
8
|
+
#
|
9
|
+
|
10
|
+
require 'socket'
|
11
|
+
require 'equity/node'
|
12
|
+
require 'equity/manager'
|
13
|
+
require 'getoptlong'
|
14
|
+
|
15
|
+
# Returns a string that can be used to identify a particular node in debugging
|
16
|
+
# messages. At the moment this returns the address and port of the client being
|
17
|
+
# served by the node.
|
18
|
+
def client_id(node)
|
19
|
+
if node.connected?
|
20
|
+
"#{node.client_address}:#{node.client_port}"
|
21
|
+
else
|
22
|
+
"not connected"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Prints a debug message if debugging is enabled.
|
27
|
+
def debug(socket, message)
|
28
|
+
return unless $debugging
|
29
|
+
if socket
|
30
|
+
if node = Equity::Node.with_socket(socket)
|
31
|
+
client_id = client_id(node)
|
32
|
+
else
|
33
|
+
client_id = nil
|
34
|
+
begin
|
35
|
+
nameinfo = Socket.getnameinfo(socket.getpeername,
|
36
|
+
Socket::NI_NUMERICHOST | Socket::NI_NUMERICSERV)
|
37
|
+
client_id = "#{nameinfo[0]}:#{nameinfo[1]}"
|
38
|
+
rescue
|
39
|
+
client_id = "???:???"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
print "[#{client_id}] "
|
43
|
+
end
|
44
|
+
puts message
|
45
|
+
end
|
46
|
+
|
47
|
+
# Prints data in hex and ASCII if packet dumping is enabled.
|
48
|
+
BYTES_PER_PACKET_LINE = 16
|
49
|
+
SAFE_PACKET_RX = (32..126).to_a.collect! {|n| n.chr}.join("").sub!(/[\[\]]/, '\\\1')
|
50
|
+
def dump_packet(data, src, dst)
|
51
|
+
return unless $dump_packets
|
52
|
+
|
53
|
+
node = Equity::Node.with_socket(src) || return
|
54
|
+
to_server = (src == node.sockets.first)
|
55
|
+
arrow = (to_server ? "--->" : "<---")
|
56
|
+
print "[#{client_id(node)}] #{arrow} #{node.address}:#{node.port}\n\n"
|
57
|
+
|
58
|
+
data = data.dup
|
59
|
+
until data.empty?
|
60
|
+
bytes = data.slice!(0, BYTES_PER_PACKET_LINE)
|
61
|
+
hex = bytes.unpack("H*").first + (" " * (BYTES_PER_PACKET_LINE - bytes.length))
|
62
|
+
hex1 = hex.slice!(0, BYTES_PER_PACKET_LINE)
|
63
|
+
hex2 = hex
|
64
|
+
hex1.gsub!(/(....)/, '\1 ')
|
65
|
+
hex2.gsub!(/(....)/, '\1 ')
|
66
|
+
bytes.gsub!(/[^#{SAFE_PACKET_RX}]/, ".")
|
67
|
+
puts " #{hex1} #{hex2} #{bytes}"
|
68
|
+
end
|
69
|
+
print "\n"
|
70
|
+
end
|
71
|
+
|
72
|
+
# Handles rewriting of HTTP request headers. This method isn't very smart about
|
73
|
+
# how it detects HTTP requests, but should work for most situations.
|
74
|
+
def rewrite_headers!(data, src)
|
75
|
+
return if $header_rewrites.empty?
|
76
|
+
return unless http_slash_idx = data.index(" HTTP/")
|
77
|
+
node = Equity::Node.with_socket(src) || return
|
78
|
+
return unless (src == node.sockets.first)
|
79
|
+
|
80
|
+
debug(src, "rewriting HTTP request headers")
|
81
|
+
|
82
|
+
before_headers = data.slice!(0, data.index("\r\n", http_slash_idx))
|
83
|
+
headers = data.slice!(0, data.index("\r\n\r\n") + 4)
|
84
|
+
after_headers = data
|
85
|
+
|
86
|
+
$header_rewrites.each do |header, value|
|
87
|
+
if headers.sub!(/\r\n#{header}: (.+?)\r\n/m, "\r\n#{header}: #{value}\r\n")
|
88
|
+
debug(src, " #{header}: #{$1} => #{value}")
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
headers.chop!
|
93
|
+
$header_additions.each do |header, value|
|
94
|
+
next if headers =~ /\r\n#{header}: /m
|
95
|
+
headers += "#{header}: #{value}\r\n"
|
96
|
+
debug(src, " #{header}: => #{value}")
|
97
|
+
end
|
98
|
+
headers += "\r\n"
|
99
|
+
|
100
|
+
data.replace(before_headers + headers + after_headers)
|
101
|
+
rescue
|
102
|
+
# If an exception occurs, ignore it. Rewriting the headers is far less
|
103
|
+
# important than keeping the program alive.
|
104
|
+
end
|
105
|
+
|
106
|
+
|
107
|
+
# Process arguments.
|
108
|
+
$debugging = !!ARGV.delete('-d')
|
109
|
+
$dump_packets = !!ARGV.delete('-p')
|
110
|
+
$header_rewrites = {}
|
111
|
+
$header_additions = {}
|
112
|
+
|
113
|
+
options = GetoptLong.new(
|
114
|
+
["-d", "--debug", GetoptLong::NO_ARGUMENT],
|
115
|
+
["-p", "--packets", GetoptLong::NO_ARGUMENT],
|
116
|
+
["-h", "--header", GetoptLong::REQUIRED_ARGUMENT],
|
117
|
+
["-H", "--add-header", GetoptLong::REQUIRED_ARGUMENT]
|
118
|
+
)
|
119
|
+
options.each do |option, argument|
|
120
|
+
case option
|
121
|
+
when "-d"
|
122
|
+
$debugging = true
|
123
|
+
when "-p"
|
124
|
+
$dump_packets = true
|
125
|
+
when "-h", "-H"
|
126
|
+
header = argument.split(/: */, 2)
|
127
|
+
unless header.length == 2
|
128
|
+
STDERR.puts "I don't understand this header rewrite:"
|
129
|
+
STDERR.puts " #{argument}"
|
130
|
+
STDERR.puts "Run equity with no arguments for help."
|
131
|
+
exit(64) # EX_USAGE
|
132
|
+
end
|
133
|
+
$header_rewrites[header.first] = header.last
|
134
|
+
$header_additions[header.first] = header.last if option == "-H"
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
if ARGV.length < 2
|
139
|
+
STDERR.puts 'equity 0.5'
|
140
|
+
STDERR.puts 'Usage: equity [<options>] <listen-port> [<node-address>:]<node-port> ...'
|
141
|
+
STDERR.print "\n"
|
142
|
+
STDERR.puts 'Node addresses default to localhost. Node ports can be single port numbers, or'
|
143
|
+
STDERR.puts 'you can specify a range like so: 10001-10010'
|
144
|
+
STDERR.print "\n"
|
145
|
+
STDERR.puts 'The following options can be used:'
|
146
|
+
STDERR.puts '-d Debug mode. Stays in the foreground and prints status messages.'
|
147
|
+
STDERR.puts '-p Packet dumper. Stays in the foreground and displays the raw data being'
|
148
|
+
STDERR.puts ' transferred.'
|
149
|
+
STDERR.puts '-h "<header>: <value>" Rewrite an HTTP request header before retransmitting.'
|
150
|
+
STDERR.puts ' Can be used more than once to rewrite several headers. The space after the'
|
151
|
+
STDERR.puts ' colon is optional.'
|
152
|
+
STDERR.puts '-H Same as -h, but the header will be added to every HTTP request (as opposed'
|
153
|
+
STDERR.puts ' to only being rewritten if it was in the original request).'
|
154
|
+
STDERR.print "\n"
|
155
|
+
exit(64) # EX_USAGE
|
156
|
+
end
|
157
|
+
|
158
|
+
# Instantiate nodes.
|
159
|
+
$listen_port = ARGV.shift.to_i
|
160
|
+
|
161
|
+
$nodes = []
|
162
|
+
ARGV.each do |node_spec|
|
163
|
+
address, port = node_spec.split(':', 2)
|
164
|
+
if port.nil?
|
165
|
+
port = address
|
166
|
+
address = 'localhost'
|
167
|
+
end
|
168
|
+
if port =~ /^(\d+)-(\d+)$/
|
169
|
+
port_range = ($1.to_i)..($2.to_i)
|
170
|
+
port_range.each do |p|
|
171
|
+
$nodes << Equity::Node.new(address, p)
|
172
|
+
end
|
173
|
+
else
|
174
|
+
$nodes << Equity::Node.new(address, port)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
# Daemonize.
|
179
|
+
unless $debugging || $dump_packets
|
180
|
+
fork && exit
|
181
|
+
Process.setsid
|
182
|
+
trap 'SIGHUP', 'IGNORE'
|
183
|
+
fork && exit
|
184
|
+
Dir.chdir '/tmp'
|
185
|
+
File.umask 0000
|
186
|
+
ObjectSpace.each_object(IO) do |io|
|
187
|
+
unless [STDIN, STDOUT, STDERR].include?(io)
|
188
|
+
io.close rescue nil
|
189
|
+
end
|
190
|
+
end
|
191
|
+
STDIN.reopen "/dev/null"
|
192
|
+
STDOUT.reopen "/dev/null", "a"
|
193
|
+
STDERR.reopen STDOUT
|
194
|
+
end
|
195
|
+
|
196
|
+
# Print debugging info for header rewrites.
|
197
|
+
$header_rewrites.each do |header, value|
|
198
|
+
if $header_additions.keys.include?(header)
|
199
|
+
debug(nil, "Will add HTTP request header `#{header}' with value `#{value}'")
|
200
|
+
else
|
201
|
+
debug(nil, "Will rewrite value of HTTP request header `#{header}' to `#{value}'")
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
# Set up SIGINT handler to print node counters.
|
206
|
+
trap 'SIGINT', Proc.new {
|
207
|
+
debug(nil, "\nNode Counters")
|
208
|
+
$nodes.each do |node|
|
209
|
+
debug(nil, "#{node} - #{node.counter} connections")
|
210
|
+
end
|
211
|
+
exit
|
212
|
+
}
|
213
|
+
|
214
|
+
# Start up control server.
|
215
|
+
$control_server = Equity::Manager.new($nodes, $listen_port, $debugging)
|
216
|
+
|
217
|
+
# Start up server.
|
218
|
+
$client_queue = []
|
219
|
+
|
220
|
+
$server = TCPServer.new(nil, $listen_port)
|
221
|
+
$server.listen(5)
|
222
|
+
|
223
|
+
debug(nil, "Ready on port #{$listen_port}")
|
224
|
+
|
225
|
+
# Loop forever.
|
226
|
+
while true
|
227
|
+
# Wait for one or more sockets to be readable.
|
228
|
+
sockets = [$server, $nodes.collect {|n| n.sockets}]
|
229
|
+
sockets.flatten!
|
230
|
+
selected = select(sockets, nil, nil, 5)
|
231
|
+
if selected
|
232
|
+
selected[0].each do |socket|
|
233
|
+
if socket == $server
|
234
|
+
# Incoming connection. Accept it and queue it.
|
235
|
+
client = $server.accept
|
236
|
+
$client_queue << client
|
237
|
+
debug(client, 'connection accepted')
|
238
|
+
else
|
239
|
+
# Data received. Transfer it to the socket's mate.
|
240
|
+
node = $nodes.find {|n| n.owns_socket?(socket)}
|
241
|
+
next if node.nil?
|
242
|
+
begin
|
243
|
+
data = socket.recvfrom(65536)[0]
|
244
|
+
raise Errno::EPIPE if data.empty?
|
245
|
+
mate = socket.mate
|
246
|
+
rewrite_headers!(data, socket)
|
247
|
+
dump_packet(data, socket, mate)
|
248
|
+
mate.write(data)
|
249
|
+
rescue Errno::EPIPE, Errno::ECONNRESET
|
250
|
+
# Connection closed. Disconnect this node.
|
251
|
+
debug(socket, 'disconnected')
|
252
|
+
node.disconnect
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
# Dequeue as many clients as possible, showing favor to nodes that have
|
259
|
+
# handled the fewest clients.
|
260
|
+
while !$client_queue.empty?
|
261
|
+
client = $client_queue.shift
|
262
|
+
found_a_node = false
|
263
|
+
failure_count = 0
|
264
|
+
sorted_notes = $nodes.sort {|a,b| a.counter <=> b.counter}
|
265
|
+
sorted_notes.each do |node|
|
266
|
+
next if node.connected?
|
267
|
+
begin
|
268
|
+
node.connect(client)
|
269
|
+
rescue Exception => e
|
270
|
+
debug(client, "connection to #{node} failed: #{e.message}")
|
271
|
+
failure_count += 1
|
272
|
+
next
|
273
|
+
end
|
274
|
+
debug(client, "assigned to #{node}")
|
275
|
+
found_a_node = true
|
276
|
+
break
|
277
|
+
end
|
278
|
+
|
279
|
+
$client_queue.unshift(client) unless found_a_node
|
280
|
+
|
281
|
+
if failure_count == $nodes.length
|
282
|
+
# All nodes failed. Disconnect all queued clients.
|
283
|
+
debug(nil, 'All nodes failed')
|
284
|
+
$client_queue.each {|client| client.close}
|
285
|
+
$client_queue.replace []
|
286
|
+
end
|
287
|
+
|
288
|
+
break unless found_a_node
|
289
|
+
end
|
290
|
+
|
291
|
+
# Check for control packets.
|
292
|
+
$control_server.process_commands
|
293
|
+
exit if $nodes.all? {|node| node.shutdown? && !node.connected?}
|
294
|
+
end
|
data/bin/equityctl
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
#
|
3
|
+
# equityctl - management client for equity
|
4
|
+
#
|
5
|
+
# Usage: equityctl [<host>:]<port> <command>
|
6
|
+
#
|
7
|
+
# The host defaults to localhost.
|
8
|
+
#
|
9
|
+
# Command Description
|
10
|
+
# status Displays each node's address, port, connection status, and
|
11
|
+
# connection counter.
|
12
|
+
#
|
13
|
+
# Equity uses UDP packets to send control information, so all of UDP's caveats
|
14
|
+
# apply to equityctl.
|
15
|
+
#
|
16
|
+
|
17
|
+
require 'equity/controller'
|
18
|
+
require 'getoptlong'
|
19
|
+
|
20
|
+
# Process options.
|
21
|
+
options = GetoptLong.new(
|
22
|
+
["--genkey", GetoptLong::NO_ARGUMENT]
|
23
|
+
)
|
24
|
+
options.each do |option, argument|
|
25
|
+
case option
|
26
|
+
when "--genkey"
|
27
|
+
$genkey = true
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
if ($genkey && !ARGV.empty?) || (!$genkey && ARGV.length != 2)
|
32
|
+
STDERR.puts 'Usage: equityctl [<host>:]<port> <command>'
|
33
|
+
STDERR.print "\n"
|
34
|
+
STDERR.puts 'The host defaults to localhost.'
|
35
|
+
STDERR.print "\n"
|
36
|
+
STDERR.puts 'Command Description'
|
37
|
+
STDERR.puts 'status Displays each node\'s address, port, connection status,'
|
38
|
+
STDERR.puts ' connection counter, and failure counter.'
|
39
|
+
STDERR.puts 'shutdown Close connections immediately and shut down.'
|
40
|
+
STDERR.puts 'waitdown Wait for connections to close, then shut down.'
|
41
|
+
STDERR.print "\n\n"
|
42
|
+
STDERR.puts 'Usage: equityctl --genkey'
|
43
|
+
STDERR.print "\n"
|
44
|
+
STDERR.puts 'With the --genkey option, equityctl generates a public/private key pair for'
|
45
|
+
STDERR.puts 'authenticating itself to equity.'
|
46
|
+
exit(64) # EX_USAGE
|
47
|
+
end
|
48
|
+
|
49
|
+
# If --genkey was specified, generate a key pair and exit.
|
50
|
+
if $genkey
|
51
|
+
puts "Generating key..."
|
52
|
+
begin
|
53
|
+
key = Equity::Controller::Key.generate
|
54
|
+
rescue Exception => e
|
55
|
+
STDERR.puts e.message
|
56
|
+
exit(1)
|
57
|
+
end
|
58
|
+
puts "Done! For reference, your key is stored here:"
|
59
|
+
key.paths.each {|path| puts " #{path}"}
|
60
|
+
# Tell the user where they need to put their public key in order for it to be
|
61
|
+
# useful.
|
62
|
+
puts "\nTo be authorized to use equityctl with your own equity processes, your public"
|
63
|
+
puts "key must be appended to:"
|
64
|
+
puts " " + Equity::Controller::Key::PERSONAL_AUTHORIZED_KEYS_PATH
|
65
|
+
puts "\nTo be authorized to use equityctl with other people's equity processes, your"
|
66
|
+
puts "public key must be appended to:"
|
67
|
+
puts " " + Equity::Controller::Key::SYSTEM_AUTHORIZED_KEYS_PATH
|
68
|
+
exit
|
69
|
+
end
|
70
|
+
|
71
|
+
# Extract address, port, and command from arguments.
|
72
|
+
address, port = ARGV.shift.split(':', 2)
|
73
|
+
if port.nil?
|
74
|
+
port = address
|
75
|
+
address = 'localhost'
|
76
|
+
end
|
77
|
+
|
78
|
+
command = ARGV.shift
|
79
|
+
|
80
|
+
# Send command.
|
81
|
+
class NoResponseError < Exception; end
|
82
|
+
|
83
|
+
controller = Equity::Controller.new(address, port.to_i)
|
84
|
+
begin
|
85
|
+
case command
|
86
|
+
when 'status'
|
87
|
+
nodes = controller.node_status || raise(NoResponseError)
|
88
|
+
nodes.each do |node|
|
89
|
+
puts "#{node} #{node.connected? ? 'C' : '-'} #{node.counter} #{node.failure_counter}"
|
90
|
+
end
|
91
|
+
when 'shutdown'
|
92
|
+
controller.shutdown || raise(NoResponseError)
|
93
|
+
when 'waitdown'
|
94
|
+
controller.waitdown || raise(NoResponseError)
|
95
|
+
else
|
96
|
+
STDERR.puts "Unrecognized command: #{command}"
|
97
|
+
STDERR.puts 'Run equityctl with no arguments for a list of commands.'
|
98
|
+
exit(64)
|
99
|
+
end
|
100
|
+
rescue NoResponseError
|
101
|
+
STDERR.puts "No response from #{address}:#{port}"
|
102
|
+
exit(1)
|
103
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
require 'socket'
|
2
|
+
require 'dl/import'
|
3
|
+
require 'equity/manager'
|
4
|
+
require 'equity/node'
|
5
|
+
require 'equity/controller/key'
|
6
|
+
|
7
|
+
module Equity
|
8
|
+
class Controller
|
9
|
+
def initialize(host, port, key = Key.new)
|
10
|
+
@host = host.dup
|
11
|
+
@port = port.to_i
|
12
|
+
@socket = UDPSocket.new
|
13
|
+
@key = key
|
14
|
+
end
|
15
|
+
|
16
|
+
def node_status
|
17
|
+
reply = send(Manager::CMD_NODE_STATUS)
|
18
|
+
return nil unless reply
|
19
|
+
|
20
|
+
nodes = []
|
21
|
+
count = reply.slice!(0, 1)[0]
|
22
|
+
count.times do
|
23
|
+
length, connected, counter, port = reply.slice!(0, 9).unpack('nCNn')
|
24
|
+
address = reply.slice!(0, length - 9)
|
25
|
+
node = Node::Static.new(address, port)
|
26
|
+
node.connected = (connected != 0)
|
27
|
+
node.counter = counter
|
28
|
+
nodes << node
|
29
|
+
end
|
30
|
+
nodes
|
31
|
+
end
|
32
|
+
|
33
|
+
def shutdown
|
34
|
+
reply = send(Manager::CMD_SHUTDOWN)
|
35
|
+
!!reply
|
36
|
+
end
|
37
|
+
|
38
|
+
def waitdown
|
39
|
+
reply = send(Manager::CMD_WAITDOWN)
|
40
|
+
!!reply
|
41
|
+
end
|
42
|
+
|
43
|
+
module Alarm
|
44
|
+
extend DL::Importable
|
45
|
+
if RUBY_PLATFORM =~ /darwin/
|
46
|
+
so_ext = 'dylib'
|
47
|
+
else
|
48
|
+
so_ext = 'so'
|
49
|
+
end
|
50
|
+
dlload "libc.#{so_ext}"
|
51
|
+
extern "unsigned int alarm(unsigned int)"
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def send(message)
|
57
|
+
message = @key.sign(message)
|
58
|
+
@socket.send(message, 0, @host, @port)
|
59
|
+
begin
|
60
|
+
trap('ALRM') { raise Errno::EINTR }
|
61
|
+
Alarm.alarm(15)
|
62
|
+
reply, address = @socket.recvfrom(1024)
|
63
|
+
rescue Errno::EINTR
|
64
|
+
reply = nil
|
65
|
+
end
|
66
|
+
Alarm.alarm(0)
|
67
|
+
reply
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,177 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
require 'fileutils'
|
3
|
+
require 'base64'
|
4
|
+
|
5
|
+
module Equity
|
6
|
+
class Controller
|
7
|
+
class Key
|
8
|
+
|
9
|
+
# Length, in bits, of generated keys.
|
10
|
+
LENGTH = 2048
|
11
|
+
|
12
|
+
# Default file system path where the user's key is stored. This path is
|
13
|
+
# the private half of the key. The public key can be found by appending
|
14
|
+
# ".pub" to this path.
|
15
|
+
DEFAULT_IDENTITY_PATH = File.join(ENV['HOME'], ".equity", "id_dsa")
|
16
|
+
|
17
|
+
# Array of paths that contain the lists of public keys that identify
|
18
|
+
# people who are authorized to control equity.
|
19
|
+
PERSONAL_AUTHORIZED_KEYS_PATH = File.join(ENV['HOME'], ".equity", "authorized_keys")
|
20
|
+
SYSTEM_AUTHORIZED_KEYS_PATH = "/etc/equity/authorized_keys"
|
21
|
+
|
22
|
+
# Byte sequence that separates the data and the signature in signed data.
|
23
|
+
SIGNATURE_SEPARATOR = 0.chr
|
24
|
+
|
25
|
+
# Generates a new DSA key pair and stores it in a pair of files at the
|
26
|
+
# default identity path. Returns the generated key.
|
27
|
+
def self.generate
|
28
|
+
# Generate a DSA key pair.
|
29
|
+
dsa = OpenSSL::PKey::DSA.generate(LENGTH)
|
30
|
+
# Write the private key to disk.
|
31
|
+
FileUtils.mkdir_p(File.dirname(DEFAULT_IDENTITY_PATH))
|
32
|
+
File.open(DEFAULT_IDENTITY_PATH, "w") do |io|
|
33
|
+
io.chmod(0600)
|
34
|
+
io.write(dsa.to_pem)
|
35
|
+
end
|
36
|
+
# Write the public key to disk.
|
37
|
+
File.open(DEFAULT_IDENTITY_PATH + ".pub", "w") do |io|
|
38
|
+
io.write(dsa.public_key.to_pem)
|
39
|
+
end
|
40
|
+
# Return a Key object with the newly generated key.
|
41
|
+
new
|
42
|
+
end
|
43
|
+
|
44
|
+
# Loads a key from disk or a string. If the loaded key includes the
|
45
|
+
# private half of the key and the key has been loaded from disk, the
|
46
|
+
# file's permissions are verified to ensure that only the owner can
|
47
|
+
# access it. Raises a SecurityError if the file's permissions are unsafe.
|
48
|
+
def initialize(path_or_pem = DEFAULT_IDENTITY_PATH)
|
49
|
+
# Try to interpret path_or_pem as a PEM string first.
|
50
|
+
begin
|
51
|
+
@dsa = OpenSSL::PKey::DSA.new(path_or_pem)
|
52
|
+
rescue
|
53
|
+
# That didn't work. It must be a path.
|
54
|
+
@path = path_or_pem
|
55
|
+
@dsa = OpenSSL::PKey::DSA.new(IO.read(@path))
|
56
|
+
# If this is a private key, make sure nobody else can read it.
|
57
|
+
if @dsa.private?
|
58
|
+
mode = File.stat(@path).mode
|
59
|
+
unless mode & 077 == 0
|
60
|
+
raise SecurityError, "unsafe permissions on #{@path} - must not allow any access by group or world"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Returns an array of paths to all files associated with this key.
|
67
|
+
def paths
|
68
|
+
return [] if @path.nil?
|
69
|
+
if @dsa.private?
|
70
|
+
[@path, @path + ".pub"]
|
71
|
+
else
|
72
|
+
[@path + ".pub"]
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# Appends a cryptographic signature to +data+.
|
77
|
+
def sign(data)
|
78
|
+
sig = @dsa.sign(OpenSSL::Digest::DSS1.new, data)
|
79
|
+
a1sig = OpenSSL::ASN1.decode( sig )
|
80
|
+
|
81
|
+
sig_r = a1sig.value[0].value.to_s(2)
|
82
|
+
sig_s = a1sig.value[1].value.to_s(2)
|
83
|
+
|
84
|
+
if sig_r.length > 20 || sig_s.length > 20
|
85
|
+
raise OpenSSL::PKey::DSAError, "bad sig size"
|
86
|
+
end
|
87
|
+
|
88
|
+
sig_r = "\0" * ( 20 - sig_r.length ) + sig_r if sig_r.length < 20
|
89
|
+
sig_s = "\0" * ( 20 - sig_s.length ) + sig_s if sig_s.length < 20
|
90
|
+
|
91
|
+
return [
|
92
|
+
data,
|
93
|
+
Base64.encode64(sig_r + sig_s),
|
94
|
+
@dsa.public_key.to_pem
|
95
|
+
].join(SIGNATURE_SEPARATOR)
|
96
|
+
end
|
97
|
+
|
98
|
+
# Verifies the cryptographic signature appended to +data_with_sig+. If
|
99
|
+
# the signature is legitimate, the data is returned. Otherwise this
|
100
|
+
# method returns +false+.
|
101
|
+
def self.verify(data_with_sig)
|
102
|
+
data, sig, public_pem = data_with_sig.split(/#{SIGNATURE_SEPARATOR}/)
|
103
|
+
sig = Base64.decode64(sig)
|
104
|
+
dsa = OpenSSL::PKey::DSA.new(public_pem)
|
105
|
+
sig_r = sig[0,20].unpack("H*")[0].to_i(16)
|
106
|
+
sig_s = sig[20,20].unpack("H*")[0].to_i(16)
|
107
|
+
a1sig = OpenSSL::ASN1::Sequence([
|
108
|
+
OpenSSL::ASN1::Integer(sig_r),
|
109
|
+
OpenSSL::ASN1::Integer(sig_s)
|
110
|
+
])
|
111
|
+
if dsa.verify(OpenSSL::Digest::DSS1.new, a1sig.to_der, data)
|
112
|
+
data
|
113
|
+
else
|
114
|
+
false
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# Returns true if this key is authorized to control equity.
|
119
|
+
def authorized?
|
120
|
+
self.class.authorized_keys.include?(self)
|
121
|
+
end
|
122
|
+
|
123
|
+
# Returns an array of keys which are authorized to control equity. Raises
|
124
|
+
# a SecurityError if either of the authorized key files are writable by
|
125
|
+
# group or world, or if a private key is found in the files.
|
126
|
+
def self.authorized_keys
|
127
|
+
# Return cached keys if they've already been loaded.
|
128
|
+
return @authorized_keys if @authorized_keys
|
129
|
+
# Read the keys and verify file permissions.
|
130
|
+
pem = ""
|
131
|
+
if File.exist?(PERSONAL_AUTHORIZED_KEYS_PATH)
|
132
|
+
mode = File.stat(PERSONAL_AUTHORIZED_KEYS_PATH).mode
|
133
|
+
unless mode & 022 == 0
|
134
|
+
raise SecurityError, "unsafe permissions on #{PERSONAL_AUTHORIZED_KEYS_PATH} - must not be writable by group or world"
|
135
|
+
end
|
136
|
+
pem = IO.read(PERSONAL_AUTHORIZED_KEYS_PATH) + "\n"
|
137
|
+
end
|
138
|
+
if File.exist?(SYSTEM_AUTHORIZED_KEYS_PATH)
|
139
|
+
mode = File.stat(SYSTEM_AUTHORIZED_KEYS_PATH).mode
|
140
|
+
unless mode & 022 == 0
|
141
|
+
raise SecurityError, "unsafe permissions on #{SYSTEM_AUTHORIZED_KEYS_PATH} - must not be writable by group or world"
|
142
|
+
end
|
143
|
+
pem += IO.read(SYSTEM_AUTHORIZED_KEYS_PATH)
|
144
|
+
end
|
145
|
+
# Instantiate the keys and verify that they're all public.
|
146
|
+
pems = pem.split(/-----END DSA PUBLIC KEY-----/)
|
147
|
+
pems.pop
|
148
|
+
pems.collect! do |pem|
|
149
|
+
key = new(pem + "-----END DSA PUBLIC KEY-----")
|
150
|
+
if key.private?
|
151
|
+
raise SecurityError, "private key found in authorized keys - only public keys should be authorized"
|
152
|
+
end
|
153
|
+
key
|
154
|
+
end
|
155
|
+
# Cache the loaded keys.
|
156
|
+
@authorized_keys = pems
|
157
|
+
end
|
158
|
+
|
159
|
+
# Returns true if this key includes the private half of the key.
|
160
|
+
def private?
|
161
|
+
@dsa.private?
|
162
|
+
end
|
163
|
+
|
164
|
+
# Two keys are equal if they have the same public key.
|
165
|
+
def ==(other)
|
166
|
+
@dsa.public_key.to_s == other.instance_eval {@dsa.public_key.to_s}
|
167
|
+
end
|
168
|
+
|
169
|
+
# Returns the public half of the key that signed +data_with_sig+.
|
170
|
+
def self.signature_key(data_with_sig)
|
171
|
+
data, sig, public_pem = data_with_sig.split(/#{SIGNATURE_SEPARATOR}/)
|
172
|
+
new(public_pem)
|
173
|
+
end
|
174
|
+
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require "equity/controller/key"
|
2
|
+
|
3
|
+
module Equity
|
4
|
+
class Manager
|
5
|
+
|
6
|
+
CMD_NODE_STATUS = "S"
|
7
|
+
CMD_SHUTDOWN = "K"
|
8
|
+
CMD_WAITDOWN = "W"
|
9
|
+
|
10
|
+
CMD_NAMES = {
|
11
|
+
CMD_NODE_STATUS => "status",
|
12
|
+
CMD_SHUTDOWN => "shutdown",
|
13
|
+
CMD_WAITDOWN => "waitdown"
|
14
|
+
}
|
15
|
+
|
16
|
+
def initialize(nodes, port, debugging = false)
|
17
|
+
@nodes = nodes
|
18
|
+
@socket = UDPSocket.new
|
19
|
+
@socket.bind(nil, port)
|
20
|
+
@debugging = debugging
|
21
|
+
end
|
22
|
+
|
23
|
+
def process_commands
|
24
|
+
# Read commands from the network.
|
25
|
+
begin
|
26
|
+
data, address = @socket.recvfrom_nonblock(4096)
|
27
|
+
rescue Errno::EAGAIN
|
28
|
+
end
|
29
|
+
return unless data
|
30
|
+
command = data.split(/#{Controller::Key::SIGNATURE_SEPARATOR}/).first
|
31
|
+
debug("received manager command: #{command_name(command)}", address)
|
32
|
+
# Verify the signature and ensure that the key used to sign the command
|
33
|
+
# is authorized.
|
34
|
+
unless verified_data = Controller::Key.verify(data)
|
35
|
+
debug("signature verification failed - ignoring command", address)
|
36
|
+
return
|
37
|
+
end
|
38
|
+
unless Controller::Key.signature_key(data).authorized?
|
39
|
+
debug("command signed by unauthorized key - ignoring command", address)
|
40
|
+
return
|
41
|
+
end
|
42
|
+
data = verified_data
|
43
|
+
# Process the command.
|
44
|
+
debug("signature verified - processing command", address)
|
45
|
+
case data
|
46
|
+
when CMD_NODE_STATUS
|
47
|
+
reply = node_status_message
|
48
|
+
when CMD_SHUTDOWN
|
49
|
+
UDPSocket.new.send(generic_ok_message, 0, address[2], address[1])
|
50
|
+
exit
|
51
|
+
when CMD_WAITDOWN
|
52
|
+
reply = generic_ok_message
|
53
|
+
waitdown
|
54
|
+
else
|
55
|
+
debug("command not understood (#{data.unpack('H*')})", address)
|
56
|
+
return
|
57
|
+
end
|
58
|
+
UDPSocket.new.send(reply, 0, address[2], address[1])
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def node_status_message
|
64
|
+
message = [@nodes.length].pack('C')
|
65
|
+
@nodes.each do |node|
|
66
|
+
message += [
|
67
|
+
9 + node.address.length,
|
68
|
+
(node.connected? ? 1 : 0),
|
69
|
+
node.counter,
|
70
|
+
node.port
|
71
|
+
].pack('nCNn')
|
72
|
+
message += node.address
|
73
|
+
end
|
74
|
+
message
|
75
|
+
end
|
76
|
+
|
77
|
+
def generic_ok_message
|
78
|
+
"OK"
|
79
|
+
end
|
80
|
+
|
81
|
+
def waitdown
|
82
|
+
@nodes.each {|node| node.stop_accepting!}
|
83
|
+
end
|
84
|
+
|
85
|
+
def debug(message, sockaddr)
|
86
|
+
return unless @debugging
|
87
|
+
nameinfo = Socket.getnameinfo(sockaddr,
|
88
|
+
Socket::NI_NUMERICHOST | Socket::NI_NUMERICSERV)
|
89
|
+
puts "[#{nameinfo[0]}:#{nameinfo[1]}] #{message}"
|
90
|
+
end
|
91
|
+
|
92
|
+
def command_name(command)
|
93
|
+
CMD_NAMES[command] || "unknown"
|
94
|
+
end
|
95
|
+
|
96
|
+
end
|
97
|
+
end
|
data/lib/equity/node.rb
ADDED
@@ -0,0 +1,113 @@
|
|
1
|
+
require 'equity/socket_pairing'
|
2
|
+
|
3
|
+
module Equity
|
4
|
+
# Represents a cluster node.
|
5
|
+
class Node
|
6
|
+
attr_reader :counter
|
7
|
+
attr_reader :failure_counter
|
8
|
+
attr_reader :address
|
9
|
+
attr_reader :port
|
10
|
+
attr_reader :client_address
|
11
|
+
attr_reader :client_port
|
12
|
+
|
13
|
+
def initialize(address, port)
|
14
|
+
@client = nil
|
15
|
+
@counter = 0
|
16
|
+
@failure_counter = 0
|
17
|
+
@address = address.dup
|
18
|
+
@port = port.to_i
|
19
|
+
@active = true
|
20
|
+
end
|
21
|
+
|
22
|
+
def connect(client)
|
23
|
+
raise(RuntimeError, 'node is already connected') if connected?
|
24
|
+
raise(RuntimeError, 'node has been shut down') if shutdown?
|
25
|
+
begin
|
26
|
+
@client = client
|
27
|
+
@server = TCPSocket.new(@address, @port)
|
28
|
+
@server.extend SocketPairing
|
29
|
+
@server.pair_with(client)
|
30
|
+
@counter += 1
|
31
|
+
|
32
|
+
begin
|
33
|
+
nameinfo = Socket.getnameinfo(@client.getpeername,
|
34
|
+
Socket::NI_NUMERICHOST | Socket::NI_NUMERICSERV)
|
35
|
+
@client_address, @client_port = nameinfo
|
36
|
+
rescue
|
37
|
+
@client_address, @client_port = "???", "???"
|
38
|
+
end
|
39
|
+
|
40
|
+
@server
|
41
|
+
rescue Exception => e
|
42
|
+
@client = nil
|
43
|
+
@server = nil
|
44
|
+
@failure_counter += 1
|
45
|
+
raise e
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def disconnect
|
50
|
+
@client.close
|
51
|
+
@server.close
|
52
|
+
@client = nil
|
53
|
+
@server = nil
|
54
|
+
@client_address = nil
|
55
|
+
@client_port = nil
|
56
|
+
end
|
57
|
+
|
58
|
+
def connected?
|
59
|
+
!!@client
|
60
|
+
end
|
61
|
+
|
62
|
+
def sockets
|
63
|
+
connected? ? [@client, @server] : []
|
64
|
+
end
|
65
|
+
|
66
|
+
def owns_socket?(socket)
|
67
|
+
connected? && ((socket == @client) || (socket == @server))
|
68
|
+
end
|
69
|
+
|
70
|
+
def to_s
|
71
|
+
"#{@address}:#{@port}"
|
72
|
+
end
|
73
|
+
|
74
|
+
def self.with_socket(socket)
|
75
|
+
ObjectSpace.each_object(self) do |node|
|
76
|
+
return node if node.owns_socket?(socket)
|
77
|
+
end
|
78
|
+
nil
|
79
|
+
end
|
80
|
+
|
81
|
+
def stop_accepting!
|
82
|
+
@active = false
|
83
|
+
end
|
84
|
+
|
85
|
+
def shutdown?
|
86
|
+
!@active
|
87
|
+
end
|
88
|
+
|
89
|
+
class Static < Node
|
90
|
+
attr_writer :counter
|
91
|
+
|
92
|
+
def connected=(bool)
|
93
|
+
@client = bool
|
94
|
+
end
|
95
|
+
|
96
|
+
def connect(client)
|
97
|
+
raise(RuntimeError, 'static nodes cannot be connected')
|
98
|
+
end
|
99
|
+
|
100
|
+
def disconnect
|
101
|
+
raise(RuntimeError, 'static nodes cannot be disconnected')
|
102
|
+
end
|
103
|
+
|
104
|
+
def sockets
|
105
|
+
[]
|
106
|
+
end
|
107
|
+
|
108
|
+
def owns_socket?(socket)
|
109
|
+
false
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Equity
|
2
|
+
# Enables pairing of sockets, so a client socket can be linked to a server
|
3
|
+
# socket.
|
4
|
+
module SocketPairing
|
5
|
+
attr_reader :mate
|
6
|
+
|
7
|
+
def pair_with(other_socket, one_way = false)
|
8
|
+
@mate = other_socket
|
9
|
+
unless one_way
|
10
|
+
other_socket.extend SocketPairing
|
11
|
+
other_socket.pair_with(self, true)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
metadata
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: equity
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease: false
|
5
|
+
segments:
|
6
|
+
- 0
|
7
|
+
- 5
|
8
|
+
version: "0.5"
|
9
|
+
platform: ruby
|
10
|
+
authors:
|
11
|
+
- Dana Contreras
|
12
|
+
autorequire:
|
13
|
+
bindir: bin
|
14
|
+
cert_chain: []
|
15
|
+
|
16
|
+
date: 2010-03-27 00:00:00 -04:00
|
17
|
+
default_executable:
|
18
|
+
dependencies: []
|
19
|
+
|
20
|
+
description:
|
21
|
+
email:
|
22
|
+
executables:
|
23
|
+
- equity
|
24
|
+
- equityctl
|
25
|
+
extensions: []
|
26
|
+
|
27
|
+
extra_rdoc_files: []
|
28
|
+
|
29
|
+
files:
|
30
|
+
- bin/equity
|
31
|
+
- bin/equityctl
|
32
|
+
- lib/equity/controller.rb
|
33
|
+
- lib/equity/controller/key.rb
|
34
|
+
- lib/equity/manager.rb
|
35
|
+
- lib/equity/node.rb
|
36
|
+
- lib/equity/socket_pairing.rb
|
37
|
+
has_rdoc: true
|
38
|
+
homepage: http://github.com/DanaDanger/equity
|
39
|
+
licenses: []
|
40
|
+
|
41
|
+
post_install_message:
|
42
|
+
rdoc_options: []
|
43
|
+
|
44
|
+
require_paths:
|
45
|
+
- lib
|
46
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
47
|
+
requirements:
|
48
|
+
- - ">="
|
49
|
+
- !ruby/object:Gem::Version
|
50
|
+
segments:
|
51
|
+
- 0
|
52
|
+
version: "0"
|
53
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
54
|
+
requirements:
|
55
|
+
- - ">="
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
segments:
|
58
|
+
- 0
|
59
|
+
version: "0"
|
60
|
+
requirements: []
|
61
|
+
|
62
|
+
rubyforge_project:
|
63
|
+
rubygems_version: 1.3.6
|
64
|
+
signing_key:
|
65
|
+
specification_version: 3
|
66
|
+
summary: Queueing software load balancer.
|
67
|
+
test_files: []
|
68
|
+
|