require 'openssl' require 'yaml' require 'hashlib' require 'deep_merge' require 'addressable/uri' require 'httparty' require 'onering/config' 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_BASE="https://onering" DEFAULT_PATH="/api" 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={}) @_plugins = {} options = {} if options.nil? @_connection_options = options # load and merge all config file sources Onering::Config.load(@_connection_options[:configfile], @_connection_options.get(:config, {})) if options.get('config.nosslverify', false) == true # deliberately break SSL verification Onering::Logger.warn("Disabling SSL peer verification for #{options.get('config.url')}") OpenSSL::SSL.send(:const_set, :OLD_VERIFY_PEER, OpenSSL::SSL::VERIFY_PEER) OpenSSL::SSL.send(:remove_const, :VERIFY_PEER) OpenSSL::SSL.send(:const_set, :VERIFY_PEER, OpenSSL::SSL::VERIFY_NONE) else # restore SSL verification if it's currently broken if defined?(OpenSSL::SSL::OLD_VERIFY_PEER) if OpenSSL::SSL::VERIFY_PEER == OpenSSL::SSL::VERIFY_NONE and OpenSSL::SSL::OLD_VERIFY_PEER != OpenSSL::SSL::VERIFY_NONE OpenSSL::SSL.send(:remove_const, :VERIFY_PEER) OpenSSL::SSL.send(:const_set, :VERIFY_PEER, OpenSSL::SSL::OLD_VERIFY_PEER) end end end if OpenSSL::SSL::VERIFY_PEER == OpenSSL::SSL::VERIFY_NONE Onering::Logger.warn("Disabling SSL peer verification for #{options.get('config.url')}") end # 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') # sound the siren Onering::Logger.info("Using local interface #{options.get('config.source')} to connect", "Onering::API") else raise "Invalid source IP address #{options.get('config.source')}" end end # set API connectivity details Onering::API.base_uri(options.get('config.url', Onering::Config.get(:url, DEFAULT_BASE))) Onering::Logger.info("Server URL is #{Onering::API.base_uri}", "Onering::API") # add default parameters options.get('config.params',{}).each do |k,v| _default_param(k,v) end connect(options) if options.get(:autoconnect, true) end def connect(options={}) # setup authentication _setup_auth() Onering::Logger.debug("Connection setup complete", "Onering::API") return self end def request(method, endpoint, options={}) endpoint = [Onering::Config.get(:path, DEFAULT_PATH).strip, endpoint.sub(/^\//,'')].join('/') Onering::Logger.debug("#{method.to_s.upcase} #{endpoint}#{(options[:query] || {}).empty? ? '' : '?'+options[:query].join('=', '&')}", "Onering::API") options.get(:headers,[]).each do |name, value| next if name == 'Content-Type' and value == 'application/json' Onering::Logger.debug("+#{name}: #{value}", "Onering::API") end begin 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 rescue SocketError => e Onering::Logger.fatal!("Unable to connect to #{Onering::API.base_uri}", "Onering::API") 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 status() Onering::API.get("/").parsed_response end # ----------------------------------------------------------------------------- def _setup_auth() type = Onering::Config.get('authentication.type', :auto) _setup_auth_token() end # ----------------------------------------------------------------------------- def _default_param(key, value) @_default_params ||= {} @_default_params[key] = value Onering::API.default_params(@_default_params) end # ----------------------------------------------------------------------------- def _setup_auth_token() Onering::Logger.info("Using token authentication mechanism", "Onering::API") # get first keyfile found key = Onering::Config.get('authentication.key', Onering::Config.get('authentication.keyfile')) if key.nil? if Onering::Config.get('authentication.bootstrap.enabled', true) Onering::Logger.warn("Authentication token not found, attempting to autoregister client", "Onering::API") if not (bootstrap = Onering::Config.get('authentication.bootstrap.key')).nil? if bootstrap.to_s =~ /[0-9a-f]{32,64}/ # attempt to create key.yml 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], 'key.yml') # 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) self.class.headers({ 'X-Auth-Bootstrap-Token' => bootstrap }) # attempt to create/download the keyfile Onering::Logger.debug("Requesting authentication token for #{client[:name].strip}; #{bootstrap}", "Onering::API") response = self.class.get("/api/users/#{client[:name].strip}/tokens/#{client[:keyname]}") # if successful, write the file if response.code < 400 and response.body File.open(keyfile, 'w').puts(YAML.dump({ 'authentication' => { 'key' => response.body.strip.chomp } })) key = response.body.strip.chomp else # all errors are fatal at this stage Onering::Logger.fatal!("Cannot autoregister client: HTTP #{response.code} - #{(response.parsed_response || {}).get('error.message', 'Unknown error')}", "Onering::API") end self.class.headers({}) # we're done here... break end else raise Errors::AuthenticationMissing.new("Autoregistration failed: invalid bootstrap token specified") end else raise Errors::AuthenticationMissing.new("Autoregistration failed: no bootstrap token specified") end else raise Errors::AuthenticationMissing.new("Authentication token not found, and autoregistration disabled") end end raise Errors::AuthenticationMissing.new("Token authentication specified, but cannot find a token config or as a command line argument") if key.nil? # set auth mechanism Onering::API.headers({ 'X-Auth-Mechanism' => 'token' }) # set default parameters _default_param(:token, key) end end end