DanaDanger-equity 0.4 → 0.5

Sign up to get free protection for your applications and to get access to all the features.
data/bin/equity CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  #
3
- # equity 0.4 - queueing software load balancer with transmission inspection
3
+ # equity 0.5 - queueing software load balancer with transmission inspection
4
4
  # and simple HTTP header rewriting
5
5
  #
6
6
  # Usage: equity [-dp] [-h "Header: Value"] <listen-port> [<node-address>:]<node-port> ...
@@ -25,7 +25,7 @@ end
25
25
 
26
26
  # Prints a debug message if debugging is enabled.
27
27
  def debug(socket, message)
28
- return unless $debugging
28
+ return unless $debugging
29
29
  if socket
30
30
  if node = Equity::Node.with_socket(socket)
31
31
  client_id = client_id(node)
@@ -124,7 +124,7 @@ options.each do |option, argument|
124
124
  end
125
125
 
126
126
  if ARGV.length < 2
127
- STDERR.puts 'equity 0.4'
127
+ STDERR.puts 'equity 0.5'
128
128
  STDERR.puts 'Usage: equity [<options>] <listen-port> [<node-address>:]<node-port> ...'
129
129
  STDERR.print "\n"
130
130
  STDERR.puts 'Node addresses default to localhost.'
@@ -185,7 +185,7 @@ trap 'SIGINT', Proc.new {
185
185
  }
186
186
 
187
187
  # Start up control server.
188
- $control_server = Equity::Manager.new($nodes, $listen_port)
188
+ $control_server = Equity::Manager.new($nodes, $listen_port, $debugging)
189
189
 
190
190
  # Start up server.
191
191
  $client_queue = []
@@ -221,8 +221,8 @@ while true
221
221
  mate.write(data)
222
222
  rescue Errno::EPIPE, Errno::ECONNRESET
223
223
  # Connection closed. Disconnect this node.
224
- node.disconnect
225
224
  debug(socket, 'disconnected')
225
+ node.disconnect
226
226
  end
227
227
  end
228
228
  end
@@ -15,9 +15,20 @@
15
15
  #
16
16
 
17
17
  require 'equity/controller'
18
+ require 'getoptlong'
18
19
 
19
- # Process arguments.
20
- if ARGV.length != 2
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)
21
32
  STDERR.puts 'Usage: equityctl [<host>:]<port> <command>'
22
33
  STDERR.print "\n"
23
34
  STDERR.puts 'The host defaults to localhost.'
@@ -25,9 +36,38 @@ if ARGV.length != 2
25
36
  STDERR.puts 'Command Description'
26
37
  STDERR.puts 'status Displays each node\'s address, port, connection status,'
27
38
  STDERR.puts ' connection counter, and failure counter.'
39
+ STDERR.puts 'shutdown Close connections immediately and shut down.'
40
+ STDERR.print "\n\n"
41
+ STDERR.puts 'Usage: equityctl --genkey'
42
+ STDERR.print "\n"
43
+ STDERR.puts 'With the --genkey option, equityctl generates a public/private key pair for'
44
+ STDERR.puts 'authenticating itself to equity.'
28
45
  exit(64) # EX_USAGE
29
46
  end
30
47
 
48
+ # If --genkey was specified, generate a key pair and exit.
49
+ if $genkey
50
+ puts "Generating key..."
51
+ begin
52
+ key = Equity::Controller::Key.generate
53
+ rescue Exception => e
54
+ STDERR.puts e.message
55
+ exit(1)
56
+ end
57
+ puts "Done! For reference, your key is stored here:"
58
+ key.paths.each {|path| puts " #{path}"}
59
+ # Tell the user where they need to put their public key in order for it to be
60
+ # useful.
61
+ puts "\nTo be authorized to use equityctl with your own equity processes, your public"
62
+ puts "key must be appended to:"
63
+ puts " " + Equity::Controller::Key::PERSONAL_AUTHORIZED_KEYS_PATH
64
+ puts "\nTo be authorized to use equityctl with other people's equity processes, your"
65
+ puts "public key must be appended to:"
66
+ puts " " + Equity::Controller::Key::SYSTEM_AUTHORIZED_KEYS_PATH
67
+ exit
68
+ end
69
+
70
+ # Extract address, port, and command from arguments.
31
71
  address, port = ARGV.shift.split(':', 2)
32
72
  if port.nil?
33
73
  port = address
@@ -47,6 +87,8 @@ begin
47
87
  nodes.each do |node|
48
88
  puts "#{node} #{node.connected? ? 'C' : '-'} #{node.counter} #{node.failure_counter}"
49
89
  end
90
+ when 'shutdown'
91
+ controller.shutdown || raise(NoResponseError)
50
92
  else
51
93
  STDERR.puts "Unrecognized command: #{command}"
52
94
  STDERR.puts 'Run equityctl with no arguments for a list of commands.'
@@ -2,13 +2,15 @@ require 'socket'
2
2
  require 'dl/import'
3
3
  require 'equity/manager'
4
4
  require 'equity/node'
5
+ require 'equity/controller/key'
5
6
 
6
7
  module Equity
7
8
  class Controller
8
- def initialize(host, port)
9
+ def initialize(host, port, key = Key.new)
9
10
  @host = host.dup
10
11
  @port = port.to_i
11
12
  @socket = UDPSocket.new
13
+ @key = key
12
14
  end
13
15
 
14
16
  def node_status
@@ -28,6 +30,11 @@ module Equity
28
30
  nodes
29
31
  end
30
32
 
33
+ def shutdown
34
+ reply = send(Manager::CMD_SHUTDOWN)
35
+ !!reply
36
+ end
37
+
31
38
  module Alarm
32
39
  extend DL::Importable
33
40
  if RUBY_PLATFORM =~ /darwin/
@@ -42,6 +49,7 @@ module Equity
42
49
  private
43
50
 
44
51
  def send(message)
52
+ message = @key.sign(message)
45
53
  @socket.send(message, 0, @host, @port)
46
54
  begin
47
55
  trap('ALRM') { raise Errno::EINTR }
@@ -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
@@ -1,24 +1,51 @@
1
+ require "equity/controller/key"
2
+
1
3
  module Equity
2
4
  class Manager
3
5
 
4
- CMD_NODE_STATUS = "\000"
6
+ CMD_NODE_STATUS = "S"
7
+ CMD_SHUTDOWN = "K"
5
8
 
6
- def initialize(nodes, port)
9
+ CMD_NAMES = {
10
+ CMD_NODE_STATUS => "status",
11
+ CMD_SHUTDOWN => "shutdown"
12
+ }
13
+
14
+ def initialize(nodes, port, debugging = false)
7
15
  @nodes = nodes
8
16
  @socket = UDPSocket.new
9
17
  @socket.bind(nil, port)
18
+ @debugging = debugging
10
19
  end
11
20
 
12
21
  def process_commands
22
+ # Read commands from the network.
13
23
  begin
14
- data, address = @socket.recvfrom_nonblock(1024)
24
+ data, address = @socket.recvfrom_nonblock(4096)
15
25
  rescue Errno::EAGAIN
16
26
  end
17
27
  return unless data
18
-
28
+ command = data.split(/#{Controller::Key::SIGNATURE_SEPARATOR}/).first
29
+ debug("received manager command: #{command_name(command)}", address)
30
+ # Verify the signature and ensure that the key used to sign the command
31
+ # is authorized.
32
+ unless verified_data = Controller::Key.verify(data)
33
+ debug("signature verification failed - ignoring command", address)
34
+ return
35
+ end
36
+ unless Controller::Key.signature_key(data).authorized?
37
+ debug("command signed by unauthorized key - ignoring command", address)
38
+ return
39
+ end
40
+ data = verified_data
41
+ # Process the command.
42
+ debug("signature verified - processing command", address)
19
43
  case data
20
44
  when CMD_NODE_STATUS
21
45
  reply = node_status_message
46
+ when CMD_SHUTDOWN
47
+ UDPSocket.new.send(generic_ok_message, 0, address[2], address[1])
48
+ exit
22
49
  else
23
50
  return
24
51
  end
@@ -41,5 +68,20 @@ module Equity
41
68
  message
42
69
  end
43
70
 
71
+ def generic_ok_message
72
+ "OK"
73
+ end
74
+
75
+ def debug(message, sockaddr)
76
+ return unless @debugging
77
+ nameinfo = Socket.getnameinfo(sockaddr,
78
+ Socket::NI_NUMERICHOST | Socket::NI_NUMERICSERV)
79
+ puts "[#{nameinfo[0]}:#{nameinfo[1]}] #{message}"
80
+ end
81
+
82
+ def command_name(command)
83
+ CMD_NAMES[command] || "unknown"
84
+ end
85
+
44
86
  end
45
87
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: DanaDanger-equity
3
3
  version: !ruby/object:Gem::Version
4
- version: "0.4"
4
+ version: "0.5"
5
5
  platform: ruby
6
6
  authors:
7
7
  - DanaDanger
@@ -26,6 +26,7 @@ files:
26
26
  - bin/equity
27
27
  - bin/equityctl
28
28
  - lib/equity/controller.rb
29
+ - lib/equity/controller/key.rb
29
30
  - lib/equity/manager.rb
30
31
  - lib/equity/node.rb
31
32
  - lib/equity/socket_pairing.rb