require 'openssl' # This is perhaps the saddest thing I have ever written... # # Don't cry for me...I'm already dead. # module OpenSSL module SSL remove_const :VERIFY_PEER VERIFY_PEER = VERIFY_NONE end end require 'yaml' require 'hashlib' require 'deep_merge' require 'addressable/uri' require 'httparty' module Onering class API module Actions class Retry < ::Exception; end end module Errors class Exception < ::Exception; end class NotConnected < Exception; end class ClientError < Exception; end class Unauthorized < ClientError; end class Forbidden < ClientError; end class NotFound < ClientError; end class ServerError < Exception; end class ConnectionTimeout < Exception; end class AuthenticationMissing < Exception; end end include Onering::Util include ::HTTParty attr_accessor :url format :json DEFAULT_CONFIG={} DEFAULT_BASE="https://onering" DEFAULT_PATH="/api" DEFAULT_OPTIONS_FILE=["~/.onering/cli.yml", "/etc/onering/cli.yml"] DEFAULT_CLIENT_PEM=["~/.onering/client.pem", "/etc/onering/client.pem"] DEFAULT_CLIENT_KEY=["~/.onering/client.key", "/etc/onering/client.key"] DEFAULT_VALIDATION_PEM="/etc/onering/validation.pem" def initialize(options={}) @_config = {} @_plugins = {} @_connection_options = options # load and merge all config file sources _load_config(@_connection_options[:configfile], @_connection_options.get(:config, {})) # source interface specified # !! HAX !! HAX !! HAX !! HAX !! HAX !! HAX !! HAX !! HAX !! HAX !! HAX !! # Due to certain versions of Ruby's Net::HTTP not allowing you explicitly # specify the source IP/interface to use, this horrific monkey patch is # necessary, if not right. # # If at least some of your code doesn't make you feel bottomless shame # then you aren't coding hard enough. # if options.get('config.source').is_a?(String) if options.get('config.source') =~ /^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/ # insert firing pin into the hack TCPSocket.instance_eval do (class << self; self; end).instance_eval do alias_method :_stock_open, :open attr_writer :_hack_local_ip define_method(:open) do |conn_address, conn_port| _stock_open(conn_address, conn_port, @_hack_local_ip) end end end # arm the hack TCPSocket._hack_local_ip = options.get('config.source') else raise "Invalid source IP address #{options.get('config.source')}" end end # set API connectivity details Onering::API.base_uri @_config.get(:url, DEFAULT_BASE) # add default parameters options.get('config.params',{}).each do |k,v| _default_param(k,v) end Onering::Reporter.setup() connect(options) if options.get(:autoconnect, true) end def connect(options={}) # setup authentication _setup_auth() return self end def request(method, endpoint, options={}) endpoint = [@_config.get(:path, DEFAULT_PATH).strip, endpoint.sub(/^\//,'')].join('/') case (method.to_sym rescue method) when :post rv = Onering::API.post(endpoint, options) when :put rv = Onering::API.put(endpoint, options) when :delete rv = Onering::API.delete(endpoint, options) when :head rv = Onering::API.head(endpoint, options) else rv = Onering::API.get(endpoint, options) end if rv.code >= 500 raise Errors::ServerError.new("HTTP #{rv.code} - #{Onering::Util.http_status(rv.code)} #{rv.parsed_response.get('error.message','') rescue ''}") elsif rv.code >= 400 message = "HTTP #{rv.code} - #{Onering::Util.http_status(rv.code)} #{rv.parsed_response.get('error.message', '') rescue ''}" case rv.code when 401 raise Errors::Unauthorized.new(message) when 403 raise Errors::Forbidden.new(message) when 404 raise Errors::NotFound.new(message) else raise Errors::ClientError.new(message) end else rv end end def get(endpoint, options={}) request(:get, endpoint, options) end def post(endpoint, options={}, &block) if block_given? request(:post, endpoint, options.merge({ :body => yield })) else request(:post, endpoint, options) end end def put(endpoint, options={}, &block) if block_given? request(:put, endpoint, options.merge({ :body => yield })) else request(:put, endpoint, options) end end def delete(endpoint, options={}) request(:delete, endpoint, options) end # I'm not a huge fan of what's happening here, but metaprogramming is hard... # # "Don't let the perfect be the enemy of the good." # def method_missing(method, *args, &block) modname = method.to_s.split('_').map(&:capitalize).join if not (plugin = (Onering::API.const_get(modname) rescue nil)).nil? @_plugins[method] ||= plugin.new.connect(@_connection_options) return @_plugins[method] else super end end def opt(name, default=nil) @_config.get(name, default) end def status() Onering::API.get("/").parsed_response end private # ----------------------------------------------------------------------------- def _load_config(configfile, config={}) if configfile.nil? configfile = [] else # recursively grab all .yml files if directory is specified configfile = (File.directory?(configfile) ? Dir.glob(File.join(configfile, "**", "*.yml")).sort : [configfile]) end # list all existing config files from least specific to most @_configfiles = (configfile + DEFAULT_OPTIONS_FILE).compact.select{|i| (File.exists?(File.expand_path(i)) rescue false) }.reverse # merge all config files with more-specific values overriding less-specific ones @_config = DEFAULT_CONFIG @_configfiles.each do |i| c = YAML.load(File.read(File.expand_path(i))) rescue {} @_config.deep_merge!(c) end # settings specified in the library override everything @_config.deep_merge!(config.compact) unless config.empty? end # ----------------------------------------------------------------------------- def _setup_auth() type = @_config.get('authentication.type', :auto) case type.to_sym when :token _setup_auth_token() else _setup_auth_ssl() end end # ----------------------------------------------------------------------------- def _default_param(key, value) @_default_params ||= {} @_default_params[key] = value Onering::API.default_params(@_default_params) end # ----------------------------------------------------------------------------- def _setup_auth_ssl() begin # get first keyfile found key = (([@_config.get('authentication.keyfile')] + DEFAULT_CLIENT_PEM).compact.select{|i| (File.exists?(File.expand_path(i)) rescue false) }).first # SSL client key not found, attempt autoregistration... if key.nil? if @_config.get('authentication.autoregister', true) validation_key = @_config.get('authentication.validation_keyfile', DEFAULT_VALIDATION_PEM) validation_key = (File.expand_path(validation_key) rescue validation_key) # if validation key exists, autoregister if File.size?(validation_key) # set the authentication PEM to validation.pem Onering::API.pem(File.read(validation_key)) # attempt to create client.pem from least-specific to most, first writable path wins clients = [{ :path => "/etc/onering", :name => fact('hardwareid'), :keyname => 'system', :autodelete => true },{ :path => "~/.onering", :name => ENV['USER'], :keyname => 'cli', :autodelete => false }] # for each client attempt... clients.each do |client| # expand and assemble path client[:path] = (File.expand_path(client[:path]) rescue client[:path]) keyfile = File.join(client[:path], 'client.pem') # skip this if we can't write to the parent directory next unless File.writable?(client[:path]) Dir.mkdir(client[:path]) unless File.directory?(client[:path]) next if File.exists?(keyfile) # attempt to create/download the keyfile response = self.class.get("/api/users/#{client[:name].strip}/keys/#{client[:keyname]}") # if successful, write the file if response.code < 400 and response.body File.open(keyfile, 'w').puts(response.body) raise Actions::Retry.new else # all errors are fatal at this stage raise Errors::ClientError.new("Cannot autoregister client: HTTP #{response.code} - #{(response.parsed_response || {}).get('error.message', 'Unknown error')}") end end # it is an error to not have created a client.pem by now raise Errors::AuthenticationMissing.new("Cannot autoregister client: keyfile not created") else # cannot autoregister without a validation.pem raise Errors::AuthenticationMissing.new("Cannot autoregister client: validation keyfile is missing") end else raise Errors::AuthenticationMissing.new("Cannot find SSL key and autoregistration is disabled") end else Onering::API.pem(File.read((File.expand_path(key) rescue key))) end rescue Actions::Retry retry end end # ----------------------------------------------------------------------------- def _setup_auth_token() # get first keyfile found key = @_config.get('authentication.key') raise Errors::AuthenticationMissing.new("Cannot find an API token") if key.nil? # set auth mechanism Onering::API.headers({ 'X-Auth-Mechanism' => 'token' }) # set default parameters _default_param(:token, key) end end end