bixby-common 0.3.8

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