bixby-common 0.3.8

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. data/.document +5 -0
  2. data/Gemfile +40 -0
  3. data/Gemfile.lock +147 -0
  4. data/Rakefile +58 -0
  5. data/VERSION +1 -0
  6. data/bixby-common.gemspec +135 -0
  7. data/lib/bixby-common.rb +1 -0
  8. data/lib/bixby_common/api/json_request.rb +36 -0
  9. data/lib/bixby_common/api/json_response.rb +83 -0
  10. data/lib/bixby_common/bixby.rb +23 -0
  11. data/lib/bixby_common/command_response.rb +80 -0
  12. data/lib/bixby_common/command_spec.rb +138 -0
  13. data/lib/bixby_common/exception/bundle_not_found.rb +5 -0
  14. data/lib/bixby_common/exception/command_exception.rb +13 -0
  15. data/lib/bixby_common/exception/command_not_found.rb +5 -0
  16. data/lib/bixby_common/exception/encryption_error.rb +5 -0
  17. data/lib/bixby_common/util/crypto_util.rb +130 -0
  18. data/lib/bixby_common/util/debug.rb +19 -0
  19. data/lib/bixby_common/util/hashify.rb +16 -0
  20. data/lib/bixby_common/util/http_client.rb +64 -0
  21. data/lib/bixby_common/util/jsonify.rb +27 -0
  22. data/lib/bixby_common/util/log.rb +50 -0
  23. data/lib/bixby_common.rb +24 -0
  24. data/test/bixby_common_test.rb +18 -0
  25. data/test/command_response_test.rb +48 -0
  26. data/test/command_spec_test.rb +98 -0
  27. data/test/helper.rb +34 -0
  28. data/test/support/repo/vendor/test_bundle/bin/cat +2 -0
  29. data/test/support/repo/vendor/test_bundle/bin/cat.json +3 -0
  30. data/test/support/repo/vendor/test_bundle/bin/echo +2 -0
  31. data/test/support/repo/vendor/test_bundle/digest +20 -0
  32. data/test/support/repo/vendor/test_bundle/manifest.json +0 -0
  33. data/test/util/http_client_test.rb +58 -0
  34. data/test/util/jsonify_test.rb +36 -0
  35. data/test/util/log_test.rb +26 -0
  36. metadata +450 -0
@@ -0,0 +1,138 @@
1
+
2
+ require 'digest'
3
+ require 'tempfile'
4
+
5
+ module Bixby
6
+
7
+ # Describes a Command execution request for the Agent
8
+ class CommandSpec
9
+
10
+ include Jsonify
11
+ include Hashify
12
+
13
+ attr_accessor :repo, :digest, :bundle, :command, :args, :stdin, :env
14
+
15
+ # Create new CommandSpec
16
+ #
17
+ # @params [Hash] params Hash of attributes to initialize with
18
+ def initialize(params = nil)
19
+ return if params.nil? or params.empty?
20
+ params.each{ |k,v| self.send("#{k}=", v) if self.respond_to? "#{k}=" }
21
+
22
+ digest = load_digest()
23
+ @digest = digest["digest"] if digest
24
+ end
25
+
26
+ # Validate the existence of this Command on the local system
27
+ # and compare digest to local version
28
+ #
29
+ # @param [String] expected_digest
30
+ # @return [Boolean] returns true if available, else raises error
31
+ # @raise [BundleNotFound] If bundle doesn't exist or digest does not match
32
+ # @raise [CommandNotFound] If command doesn't exist
33
+ def validate(expected_digest)
34
+ if not bundle_exists? then
35
+ raise BundleNotFound.new("repo = #{@repo}; bundle = #{@bundle}")
36
+ end
37
+
38
+ if not command_exists? then
39
+ raise CommandNotFound.new("repo = #{@repo}; bundle = #{@bundle}; command = #{@command}")
40
+ end
41
+ if self.digest != expected_digest then
42
+ raise BundleNotFound, "digest does not match ('#{self.digest}' != '#{expected_digest}'", caller
43
+ end
44
+ return true
45
+ end
46
+
47
+ # resolve the given bundle
48
+ def bundle_dir
49
+ File.join(Bixby.repo_path, self.relative_path)
50
+ end
51
+
52
+ # Return the relative path to the bundle (inside the repository)
53
+ #
54
+ # e.g., if Bixby.repo_path = /opt/bixby/repo then a relative path would
55
+ # look like:
56
+ #
57
+ # vendor/system/monitoring
58
+ # or
59
+ # megacorp/sysops/scripts
60
+ #
61
+ # @return [String]
62
+ def relative_path
63
+ File.join(@repo, @bundle)
64
+ end
65
+
66
+ def bundle_exists?
67
+ File.exists? self.bundle_dir
68
+ end
69
+
70
+ def command_file
71
+ File.join(self.bundle_dir, "bin", @command)
72
+ end
73
+
74
+ def command_exists?
75
+ File.exists? self.command_file
76
+ end
77
+
78
+ def config_file
79
+ command_file + ".json"
80
+ end
81
+
82
+ def load_config
83
+ if File.exists? config_file then
84
+ MultiJson.load(File.read(config_file))
85
+ else
86
+ {}
87
+ end
88
+ end
89
+
90
+ def digest_file
91
+ File.join(self.bundle_dir, "digest")
92
+ end
93
+
94
+ def load_digest
95
+ begin
96
+ return MultiJson.load(File.read(digest_file))
97
+ rescue => ex
98
+ end
99
+ nil
100
+ end
101
+
102
+ def update_digest
103
+
104
+ path = self.bundle_dir
105
+ sha = Digest::SHA2.new
106
+ bundle_sha = Digest::SHA2.new
107
+
108
+ digests = []
109
+ Dir.glob("#{path}/**/*").sort.each do |f|
110
+ next if File.directory? f or File.basename(f) == "digest"
111
+ bundle_sha.file(f)
112
+ sha.reset()
113
+ digests << { :file => f.gsub(/#{path}\//, ''), :digest => sha.file(f).hexdigest() }
114
+ end
115
+
116
+ @digest = { :digest => bundle_sha.hexdigest(), :files => digests }
117
+ File.open(path+"/digest", 'w'){ |f| f.write(MultiJson.dump(@digest, :pretty => true) + "\n") }
118
+
119
+ end
120
+
121
+ # Convert object to String, useful for debugging
122
+ #
123
+ # @return [String]
124
+ def to_s # :nocov:
125
+ s = []
126
+ s << "CommandSpec:#{self.object_id}"
127
+ s << " digest: #{self.digest}"
128
+ s << " repo: #{self.repo}"
129
+ s << " bundle: #{self.bundle}"
130
+ s << " command: #{self.command}"
131
+ s << " args: #{self.args}"
132
+ s << " env: " + MultiJson.dump(self.env)
133
+ s << " stdin: " + Debug.pretty_str(stdin)
134
+ s.join("\n")
135
+ end # :nocov:
136
+
137
+ end # CommandSpec
138
+ end # Bixby
@@ -0,0 +1,5 @@
1
+
2
+ module Bixby
3
+ class BundleNotFound < Exception
4
+ end
5
+ end
@@ -0,0 +1,13 @@
1
+
2
+ module Bixby
3
+ class CommandException < Exception
4
+
5
+ attr_accessor :response
6
+
7
+ def initialize(message, command_response)
8
+ super(message)
9
+ @response = command_response
10
+ end
11
+
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+
2
+ module Bixby
3
+ class CommandNotFound < Exception
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+
2
+ module Bixby
3
+ class EncryptionError < Exception
4
+ end
5
+ end
@@ -0,0 +1,130 @@
1
+
2
+ require 'base64'
3
+ require 'openssl'
4
+ require 'digest'
5
+
6
+ module Bixby
7
+ module CryptoUtil
8
+
9
+ class << self
10
+
11
+ # Encrypt the given payload for over-the-wire transmission
12
+ #
13
+ # @param [Object] data payload, usually a JSON-encoded String
14
+ # @param [String] uuid UUID of the sender
15
+ # @param [OpenSSL::PKey::RSA] key_pem Public key of the receiver
16
+ # @param [OpenSSL::PKey::RSA] iv_pem Private key of the sender
17
+ def encrypt(data, uuid, key_pem, iv_pem)
18
+ c = new_cipher()
19
+ c.encrypt
20
+ key = c.random_key
21
+ iv = c.random_iv
22
+
23
+ data = Time.new.to_i.to_s + "\n" + data # prepend timestamp
24
+ encrypted = c.update(data) + c.final
25
+
26
+ out = []
27
+ out << uuid
28
+ out << create_hmac(key, iv, encrypted)
29
+ out << w( key_pem.public_encrypt(key) )
30
+ out << w( iv_pem.private_encrypt(iv) )
31
+ out << e64(encrypted)
32
+
33
+ return out.join("\n")
34
+ end
35
+
36
+ # Decrypt the given payload from over-the-wire transmission
37
+ #
38
+ # @param [Object] data encrypted payload, usually a JSON-encoded String
39
+ # @param [OpenSSL::PKey::RSA] key_pem Private key of the receiver
40
+ # @param [OpenSSL::PKey::RSA] iv_pem Public key of the sender
41
+ def decrypt(data, key_pem, iv_pem)
42
+ data = StringIO.new(data, 'rb') if not data.kind_of? StringIO
43
+ hmac = data.readline.strip
44
+ key = key_pem.private_decrypt(read_next(data))
45
+ iv = iv_pem.public_decrypt(read_next(data))
46
+
47
+ c = new_cipher()
48
+ c.decrypt
49
+ c.key = key
50
+ c.iv = iv
51
+
52
+ payload = d64(data.read)
53
+
54
+ # very hmac of encrypted payload
55
+ if not verify_hmac(hmac, key, iv, payload) then
56
+ raise Bixby::EncryptionError, "hmac verification failed", caller
57
+ end
58
+
59
+ data = StringIO.new(c.update(payload) + c.final)
60
+
61
+ ts = data.readline.strip
62
+ if (Time.new.to_i - ts.to_i) > 60 then
63
+ raise Bixby::EncryptionError, "payload verification failed", caller
64
+ end
65
+
66
+ return data.read
67
+ end
68
+
69
+ def generate_access_key
70
+ Digest.hexencode(Digest::MD5.new.digest(OpenSSL::Random.random_bytes(512)))
71
+ end
72
+
73
+ def generate_secret_key
74
+ Digest.hexencode(Digest::SHA2.new(512).digest(OpenSSL::Random.random_bytes(512)))
75
+ end
76
+
77
+
78
+ private
79
+
80
+ # Compute an HMAC using SHA2-256
81
+ #
82
+ # @param [String] key
83
+ # @param [String] iv
84
+ # @param [String] payload encrypted payload
85
+ #
86
+ # @return [String] digest in hexadecimal format
87
+ def create_hmac(key, iv, payload)
88
+ d = Digest::SHA2.new(256)
89
+ d << key << iv << payload
90
+ return d.hexdigest()
91
+ end
92
+
93
+ # Verify the given HMAC of the incoming message
94
+ #
95
+ # @param [String] hmac
96
+ # @param [String] key
97
+ # @param [String] iv
98
+ # @param [String] payload encrypted payload
99
+ #
100
+ # @return [Boolean] true if hmac matches
101
+ def verify_hmac(hmac, key, iv, payload)
102
+ create_hmac(key, iv, payload) == hmac
103
+ end
104
+
105
+ def new_cipher
106
+ # TODO make this configurable? perhaps use CTR when available
107
+ # we can store a CTR support flag on the master
108
+ OpenSSL::Cipher.new("AES-256-CBC")
109
+ end
110
+
111
+ def w(s)
112
+ e64(s).gsub(/\n/, "\\n")
113
+ end
114
+
115
+ def read_next(data)
116
+ d64(data.readline.gsub(/\\n/, "\n"))
117
+ end
118
+
119
+ def e64(s)
120
+ Base64.encode64(s)
121
+ end
122
+
123
+ def d64(s)
124
+ Base64.decode64(s)
125
+ end
126
+
127
+ end
128
+
129
+ end # CryptoUtil
130
+ end # Bixby
@@ -0,0 +1,19 @@
1
+
2
+ module Bixby
3
+ module Debug
4
+
5
+ # Simple helper for use in to_s methods
6
+ def self.pretty_str(str) # :nocov:
7
+ if str.nil? then
8
+ "nil"
9
+ elsif str.empty? then
10
+ '""'
11
+ elsif str.include? "\n" then
12
+ "<<-EOF\n" + str + "\nEOF"
13
+ else
14
+ '"' + str + '"'
15
+ end
16
+ end # :nocov:
17
+
18
+ end # Debug
19
+ end # Bixby
@@ -0,0 +1,16 @@
1
+
2
+ module Bixby
3
+
4
+ # Adds to_hash method to an Object
5
+ module Hashify
6
+
7
+ # Creates a Hash representation of self
8
+ #
9
+ # @return [Hash]
10
+ def to_hash
11
+ self.instance_variables.inject({}) { |m,v| m[v[1,v.length].to_sym] = instance_variable_get(v); m }
12
+ end
13
+
14
+ end # Hashify
15
+
16
+ end # Bixby
@@ -0,0 +1,64 @@
1
+
2
+ require 'httpi'
3
+ require 'multi_json'
4
+
5
+ HTTPI.log = false
6
+
7
+ module Bixby
8
+
9
+ # Utilities for using HTTP Clients. Just a thin wrapper around httpi and JSON
10
+ # for common cases.
11
+ module HttpClient
12
+
13
+ # Execute an HTTP GET request to the given URL
14
+ #
15
+ # @param [String] url
16
+ # @return [String] Contents of the response's body
17
+ def http_get(url)
18
+ HTTPI.get(url).body
19
+ end
20
+
21
+ # Execute an HTTP GET request (see #http_get) and parse the JSON response
22
+ #
23
+ # @param [String] url
24
+ # @return [Object] Result of calling JSON.parse() on the response body
25
+ def http_get_json(url)
26
+ MultiJson.load(http_get(url))
27
+ end
28
+
29
+ # Execute an HTTP POST request to the given URL
30
+ #
31
+ # @param [String] url
32
+ # @param [Hash] data Key/Value pairs to POST
33
+ # @return [String] Contents of the response's body
34
+ def http_post(url, data)
35
+ req = HTTPI::Request.new(:url => url, :body => data)
36
+ return HTTPI.post(req).body
37
+ end
38
+
39
+ # Execute an HTTP POST request (see #http_get) and parse the JSON response
40
+ #
41
+ # @param [String] url
42
+ # @param [Hash] data Key/Value pairs to POST
43
+ # @return [Object] Result of calling JSON.parse() on the response body
44
+ def http_post_json(url, data)
45
+ MultiJson.load(http_post(url, data))
46
+ end
47
+
48
+ # Execute an HTTP post request and save the response body
49
+ #
50
+ # @param [String] url
51
+ # @param [Hash] data Key/Value pairs to POST
52
+ # @return [void]
53
+ def http_post_download(url, data, dest)
54
+ File.open(dest, "w") do |io|
55
+ req = HTTPI::Request.new(:url => url, :body => data)
56
+ req.on_body { |d| io << d; d.length }
57
+ HTTPI.post(req)
58
+ end
59
+ true
60
+ end
61
+
62
+ end # HttpClient
63
+
64
+ end # Bixby
@@ -0,0 +1,27 @@
1
+
2
+ require 'multi_json'
3
+
4
+ module Bixby
5
+ module Jsonify
6
+
7
+ include Hashify
8
+
9
+ def to_json
10
+ MultiJson.dump(self.to_hash)
11
+ end
12
+
13
+ module ClassMethods
14
+ def from_json(json)
15
+ json = MultiJson.load(json) if json.kind_of? String
16
+ obj = self.allocate
17
+ json.each{ |k,v| obj.send("#{k}=".to_sym, v) }
18
+ obj
19
+ end
20
+ end
21
+
22
+ def self.included(receiver)
23
+ receiver.extend(ClassMethods)
24
+ end
25
+
26
+ end # Jsonify
27
+ end # Bixby
@@ -0,0 +1,50 @@
1
+
2
+ require "logging"
3
+
4
+ module Bixby
5
+
6
+ # A simple logging mixin
7
+ module Log
8
+
9
+ # Get a log instance for this class
10
+ #
11
+ # @return [Logger]
12
+ def log
13
+ @log ||= Logging.logger[self]
14
+ end
15
+
16
+ # Create a method for each log level. Allows receiver to simply call
17
+ #
18
+ # warn "foo"
19
+ %w{debug warn info error fatal}.each do |level|
20
+ code = <<-EOF
21
+ def #{level}(data=nil, &block)
22
+ log.send(:#{level}, data, &block)
23
+ end
24
+ EOF
25
+ eval(code)
26
+ end
27
+
28
+ # Setup logging
29
+ #
30
+ # @param [Symbol] level Log level to use (default = :warn)
31
+ # @param [String] pattern Log pattern
32
+ def self.setup_logger(level=nil, pattern=nil)
33
+ # set level: ENV flag overrides; default to warn
34
+ level = :debug if ENV["BIXBY_DEBUG"]
35
+ level ||= :warn
36
+
37
+ pattern ||= '%.1l, [%d] %5l -- %c: %m\n'
38
+
39
+ # TODO always use stdout for now
40
+ Logging.appenders.stdout(
41
+ :level => level,
42
+ :layout => Logging.layouts.pattern(:pattern => pattern)
43
+ )
44
+ Logging::Logger.root.add_appenders(Logging.appenders.stdout)
45
+ Logging::Logger.root.level = level
46
+ end
47
+
48
+ end # Log
49
+
50
+ end
@@ -0,0 +1,24 @@
1
+
2
+ require "bixby_common/bixby"
3
+
4
+ module Bixby
5
+
6
+ autoload :CommandResponse, "bixby_common/command_response"
7
+ autoload :CommandSpec, "bixby_common/command_spec"
8
+
9
+ autoload :JsonRequest, "bixby_common/api/json_request"
10
+ autoload :JsonResponse, "bixby_common/api/json_response"
11
+
12
+ autoload :BundleNotFound, "bixby_common/exception/bundle_not_found"
13
+ autoload :CommandNotFound, "bixby_common/exception/command_not_found"
14
+ autoload :CommandException, "bixby_common/exception/command_exception"
15
+ autoload :EncryptionError, "bixby_common/exception/encryption_error"
16
+
17
+ autoload :CryptoUtil, "bixby_common/util/crypto_util"
18
+ autoload :HttpClient, "bixby_common/util/http_client"
19
+ autoload :Jsonify, "bixby_common/util/jsonify"
20
+ autoload :Hashify, "bixby_common/util/hashify"
21
+ autoload :Log, "bixby_common/util/log"
22
+ autoload :Debug, "bixby_common/util/debug"
23
+
24
+ end
@@ -0,0 +1,18 @@
1
+ require 'helper'
2
+
3
+ module Bixby
4
+ module Test
5
+
6
+ class TestBixbyCommon < MiniTest::Unit::TestCase
7
+
8
+ def test_autoloading
9
+ assert_equal(JsonRequest, JsonRequest.new(nil, nil).class)
10
+ assert_equal(BundleNotFound, BundleNotFound.new.class)
11
+ assert_equal(CommandNotFound, CommandNotFound.new.class)
12
+ assert_equal(CommandSpec, CommandSpec.new.class)
13
+ end
14
+
15
+ end
16
+
17
+ end # Test
18
+ end # Bixby
@@ -0,0 +1,48 @@
1
+
2
+ require 'helper'
3
+
4
+ module Bixby
5
+ module Test
6
+
7
+ class TestCommandResponse < MiniTest::Unit::TestCase
8
+
9
+ def test_from_json_response
10
+ res = JsonResponse.new("fail", "unknown")
11
+ assert res.fail?
12
+ refute res.success?
13
+ cr = CommandResponse.from_json_response(res)
14
+ assert_kind_of CommandResponse, cr
15
+ assert_equal 255, cr.status
16
+ assert_equal "unknown", cr.stderr
17
+ begin
18
+ cr.raise!
19
+ rescue CommandException => ex
20
+ assert_equal "unknown", ex.message
21
+ end
22
+
23
+ res = JsonResponse.new("success", nil, {:status => 0, :stdout => "foobar", :stderr => nil})
24
+ assert res.success?
25
+ refute res.fail?
26
+ cr = CommandResponse.from_json_response(res)
27
+ assert_kind_of CommandResponse, cr
28
+ assert_equal 0, cr.status
29
+ assert_equal "foobar", cr.stdout
30
+ assert_nil cr.stderr
31
+ end
32
+
33
+ def test_status
34
+ cr = CommandResponse.new
35
+ cr.status = 0
36
+ assert cr.success?
37
+ refute cr.fail?
38
+ refute cr.error?
39
+
40
+ cr.status = "255"
41
+ refute cr.success?
42
+ assert cr.fail?
43
+ end
44
+
45
+ end # TestCommandResponse
46
+
47
+ end # Test
48
+ end # Bixby
@@ -0,0 +1,98 @@
1
+
2
+
3
+ require 'helper'
4
+
5
+ module Bixby
6
+ module Test
7
+
8
+ class TestCommandSpec < MiniTest::Unit::TestCase
9
+
10
+ def setup
11
+ ENV["BIXBY_HOME"] = File.join(File.expand_path(File.dirname(__FILE__)), "support")
12
+ h = { :repo => "vendor", :bundle => "test_bundle", 'command' => "echo", :foobar => "baz" }
13
+ @c = CommandSpec.new(h)
14
+ end
15
+
16
+ def teardown
17
+ super
18
+ system("rm -rf /tmp/_test_bixby_home")
19
+ end
20
+
21
+
22
+ def test_init_with_hash
23
+ assert(@c)
24
+ assert_equal("vendor", @c.repo)
25
+ assert_equal("test_bundle", @c.bundle)
26
+ assert_equal("echo", @c.command)
27
+ end
28
+
29
+ def test_to_hash
30
+ assert_equal("vendor", @c.to_hash[:repo])
31
+ assert_equal("test_bundle", @c.to_hash[:bundle])
32
+ assert_equal("echo", @c.to_hash[:command])
33
+ end
34
+
35
+ def test_load_config
36
+ config = @c.load_config
37
+ assert config
38
+ assert_kind_of Hash, config
39
+ assert_empty config
40
+
41
+ @c.command = "cat"
42
+ config = @c.load_config
43
+ assert config
44
+ assert_equal "cat", config["name"]
45
+ end
46
+
47
+ def test_validate_failures
48
+ assert_throws(BundleNotFound) do
49
+ CommandSpec.new(:repo => "vendor", :bundle => "foobar").validate(nil)
50
+ end
51
+ assert_throws(CommandNotFound) do
52
+ CommandSpec.new(:repo => "vendor", :bundle => "test_bundle", :command => "foobar").validate(nil)
53
+ end
54
+ assert_throws(BundleNotFound) do
55
+ @c.validate(nil)
56
+ end
57
+ end
58
+
59
+ def test_digest
60
+ expected_digest = "8980372485fc6bcd287e481ab1e15710e2b63c68db75085c2d24386ced272ca4"
61
+ assert_equal expected_digest, @c.digest
62
+ assert @c.validate(expected_digest)
63
+ end
64
+
65
+ def test_digest_no_err
66
+ c = CommandSpec.new({ :repo => "vendor", :bundle => "test_bundle", 'command' => "echofoo" })
67
+ end
68
+
69
+ def test_exec_digest_changed_throws_error
70
+ assert_throws(BundleNotFound) do
71
+ @c.validate("alkjasdfasd")
72
+ end
73
+ end
74
+
75
+ def test_update_digest
76
+ expected = MultiJson.load(File.read(Bixby.repo_path + "/vendor/test_bundle/digest"))
77
+
78
+ t = "/tmp/_test_bixby_home"
79
+ d = "#{t}/repo/vendor/test_bundle/digest"
80
+ `mkdir -p #{t}`
81
+ `cp -a #{Bixby.repo_path}/ #{t}/`
82
+ `rm #{d}`
83
+ ENV["BIXBY_HOME"] = t
84
+
85
+ refute File.exist? d
86
+ @c.update_digest
87
+ assert File.exist? d
88
+ assert_equal MultiJson.dump(expected), MultiJson.dump(MultiJson.load(File.read(d)))
89
+
90
+ @c.update_digest
91
+ assert File.exist? d
92
+ assert_equal MultiJson.dump(expected), MultiJson.dump(MultiJson.load(File.read(d)))
93
+ end
94
+
95
+ end # TestCommandSpec
96
+
97
+ end # Test
98
+ end # Bixby