DanaDanger-equity 0.4 → 0.5

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