diff options
Diffstat (limited to 'lib/chef/http')
-rw-r--r-- | lib/chef/http/auth_credentials.rb | 57 | ||||
-rw-r--r-- | lib/chef/http/authenticator.rb | 85 | ||||
-rw-r--r-- | lib/chef/http/cookie_jar.rb | 31 | ||||
-rw-r--r-- | lib/chef/http/decompressor.rb | 122 | ||||
-rw-r--r-- | lib/chef/http/http_request.rb | 233 | ||||
-rw-r--r-- | lib/chef/http/json_to_model_inflater.rb | 56 |
6 files changed, 584 insertions, 0 deletions
diff --git a/lib/chef/http/auth_credentials.rb b/lib/chef/http/auth_credentials.rb new file mode 100644 index 0000000000..bd73524b1f --- /dev/null +++ b/lib/chef/http/auth_credentials.rb @@ -0,0 +1,57 @@ +# +# Author:: Adam Jacob (<adam@opscode.com>) +# Author:: Thom May (<thom@clearairturbulence.org>) +# Author:: Nuo Yan (<nuo@opscode.com>) +# Author:: Christopher Brown (<cb@opscode.com>) +# Author:: Christopher Walters (<cw@opscode.com>) +# Author:: Daniel DeLeo (<dan@opscode.com>) +# Copyright:: Copyright (c) 2009, 2010 Opscode, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +require 'chef/log' +require 'mixlib/authentication/signedheaderauth' + +class Chef + class HTTP + class AuthCredentials + attr_reader :client_name, :key + + def initialize(client_name=nil, key=nil) + @client_name, @key = client_name, key + end + + def sign_requests? + !!key + end + + def signature_headers(request_params={}) + raise ArgumentError, "Cannot sign the request without a client name, check that :node_name is assigned" if client_name.nil? + Chef::Log.debug("Signing the request as #{client_name}") + + # params_in = {:http_method => :GET, :path => "/clients", :body => "", :host => "localhost"} + request_params = request_params.dup + request_params[:timestamp] = Time.now.utc.iso8601 + request_params[:user_id] = client_name + request_params[:proto_version] = Chef::Config[:authentication_protocol_version] + host = request_params.delete(:host) || "localhost" + + sign_obj = Mixlib::Authentication::SignedHeaderAuth.signing_object(request_params) + signed = sign_obj.sign(key).merge({:host => host}) + signed.inject({}){|memo, kv| memo["#{kv[0].to_s.upcase}"] = kv[1];memo} + end + + end + end +end diff --git a/lib/chef/http/authenticator.rb b/lib/chef/http/authenticator.rb new file mode 100644 index 0000000000..006b5d890a --- /dev/null +++ b/lib/chef/http/authenticator.rb @@ -0,0 +1,85 @@ +#-- +# Author:: Daniel DeLeo (<dan@opscode.com>) +# Copyright:: Copyright (c) 2013 Opscode, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require 'chef/http/auth_credentials' +require 'chef/exceptions' +require 'openssl' + +class Chef + class HTTP + class Authenticator + + attr_reader :signing_key_filename + attr_reader :raw_key + attr_reader :attr_names + attr_reader :auth_credentials + + attr_accessor :sign_request + + def initialize(opts={}) + @raw_key = nil + @sign_request = true + @signing_key_filename = opts[:signing_key_filename] + @key = load_signing_key(opts[:signing_key_filename], opts[:raw_key]) + @auth_credentials = AuthCredentials.new(opts[:client_name], @key) + end + + def handle_request(method, url, headers={}, data=false) + headers.merge!(authentication_headers(method, url, data)) if sign_requests? + [method, url, headers, data] + end + + def handle_response(http_response, rest_request, return_value) + [http_response, rest_request, return_value] + end + + def sign_requests? + auth_credentials.sign_requests? && @sign_request + end + + def client_name + @auth_credentials.client_name + end + + def load_signing_key(key_file, raw_key = nil) + if (!!key_file) + @raw_key = IO.read(key_file).strip + elsif (!!raw_key) + @raw_key = raw_key.strip + else + return nil + end + @key = OpenSSL::PKey::RSA.new(@raw_key) + rescue SystemCallError, IOError => e + Chef::Log.warn "Failed to read the private key #{key_file}: #{e.inspect}" + raise Chef::Exceptions::PrivateKeyMissing, "I cannot read #{key_file}, which you told me to use to sign requests!" + rescue OpenSSL::PKey::RSAError + msg = "The file #{key_file} or :raw_key option does not contain a correctly formatted private key.\n" + msg << "The key file should begin with '-----BEGIN RSA PRIVATE KEY-----' and end with '-----END RSA PRIVATE KEY-----'" + raise Chef::Exceptions::InvalidPrivateKey, msg + end + + def authentication_headers(method, url, json_body=nil) + request_params = {:http_method => method, :path => url.path, :body => json_body, :host => "#{url.host}:#{url.port}"} + request_params[:body] ||= "" + auth_credentials.signature_headers(request_params) + end + + end + end +end diff --git a/lib/chef/http/cookie_jar.rb b/lib/chef/http/cookie_jar.rb new file mode 100644 index 0000000000..418fb1d352 --- /dev/null +++ b/lib/chef/http/cookie_jar.rb @@ -0,0 +1,31 @@ +# +# Author:: Adam Jacob (<adam@opscode.com>) +# Author:: Thom May (<thom@clearairturbulence.org>) +# Author:: Nuo Yan (<nuo@opscode.com>) +# Author:: Christopher Brown (<cb@opscode.com>) +# Author:: Christopher Walters (<cw@opscode.com>) +# Author:: Daniel DeLeo (<dan@opscode.com>) +# Copyright:: Copyright (c) 2009, 2010 Opscode, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +require 'singleton' + +class Chef + class HTTP + class CookieJar < Hash + include Singleton + end + end +end diff --git a/lib/chef/http/decompressor.rb b/lib/chef/http/decompressor.rb new file mode 100644 index 0000000000..846a29db0a --- /dev/null +++ b/lib/chef/http/decompressor.rb @@ -0,0 +1,122 @@ +#-- +# Author:: Daniel DeLeo (<dan@opscode.com>) +# Copyright:: Copyright (c) 2013 Opscode, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require 'zlib' +require 'chef/http/http_request' + +class Chef + class HTTP + + # Middleware-esque class for handling compression in HTTP responses. + class Decompressor + class NoopInflater + def inflate(chunk) + chunk + end + end + + CONTENT_ENCODING = "content-encoding".freeze + GZIP = "gzip".freeze + DEFLATE = "deflate".freeze + IDENTITY = "identity".freeze + + def initialize(opts={}) + @disable_gzip = false + handle_options(opts) + end + + def handle_request(method, url, headers={}, data=false) + headers[HTTPRequest::ACCEPT_ENCODING] = HTTPRequest::ENCODING_GZIP_DEFLATE unless gzip_disabled? + [method, url, headers, data] + end + + def handle_response(http_response, rest_request, return_value) + # temporary hack, skip processing if return_value is false + # needed to keep conditional get stuff working correctly. + return [http_response, rest_request, return_value] if return_value == false + response_body = decompress_body(http_response) + http_response.body.replace(response_body) if http_response.body.respond_to?(:replace) + [http_response, rest_request, return_value] + end + + def decompress_body(response) + if gzip_disabled? || response.body.nil? + response.body + else + case response[CONTENT_ENCODING] + when GZIP + Chef::Log.debug "decompressing gzip response" + Zlib::Inflate.new(Zlib::MAX_WBITS + 16).inflate(response.body) + when DEFLATE + Chef::Log.debug "decompressing deflate response" + Zlib::Inflate.inflate(response.body) + else + response.body + end + end + end + + # This isn't used when this class is used as middleware; it returns an + # object you can use to unzip/inflate a streaming response. + def stream_decompressor_for(response) + if gzip_disabled? + NoopInflater.new + else + case response[CONTENT_ENCODING] + when GZIP + Chef::Log.debug "decompressing gzip stream" + Zlib::Inflate.new(Zlib::MAX_WBITS + 16) + when DEFLATE + Chef::Log.debug "decompressing inflate stream" + Zlib::Inflate.new + else + NoopInflater.new + end + end + end + + + # gzip is disabled using the disable_gzip => true option in the + # constructor. When gzip is disabled, no 'Accept-Encoding' header will be + # set, and the response will not be decompressed, no matter what the + # Content-Encoding header of the response is. The intended use case for + # this is to work around situations where you request +file.tar.gz+, but + # the server responds with a content type of tar and a content encoding of + # gzip, tricking the client into decompressing the response so you end up + # with a tar archive (no gzip) named file.tar.gz + def gzip_disabled? + @disable_gzip + end + + private + + def handle_options(opts) + opts.each do |name, value| + case name.to_s + when 'disable_gzip' + @disable_gzip = value + end + end + end + + + end + end +end + + diff --git a/lib/chef/http/http_request.rb b/lib/chef/http/http_request.rb new file mode 100644 index 0000000000..e92d834b9e --- /dev/null +++ b/lib/chef/http/http_request.rb @@ -0,0 +1,233 @@ +#-- +# Author:: Adam Jacob (<adam@opscode.com>) +# Author:: Thom May (<thom@clearairturbulence.org>) +# Author:: Nuo Yan (<nuo@opscode.com>) +# Author:: Christopher Brown (<cb@opscode.com>) +# Author:: Christopher Walters (<cw@opscode.com>) +# Author:: Daniel DeLeo (<dan@opscode.com>) +# Copyright:: Copyright (c) 2009, 2010 Opscode, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +require 'uri' +require 'net/http' +require 'chef/http/cookie_jar' + +# To load faster, we only want ohai's version string. +# However, in ohai before 0.6.0, the version is defined +# in ohai, not ohai/version +begin + require 'ohai/version' #used in user agent string. +rescue LoadError + require 'ohai' +end + +require 'chef/version' + +class Chef + class HTTP + class HTTPRequest + + engine = defined?(RUBY_ENGINE) ? RUBY_ENGINE : "ruby" + + UA_COMMON = "/#{::Chef::VERSION} (#{engine}-#{RUBY_VERSION}-p#{RUBY_PATCHLEVEL}; ohai-#{Ohai::VERSION}; #{RUBY_PLATFORM}; +http://opscode.com)" + DEFAULT_UA = "Chef Client" << UA_COMMON + + USER_AGENT = "User-Agent".freeze + + ACCEPT_ENCODING = "Accept-Encoding".freeze + ENCODING_GZIP_DEFLATE = "gzip;q=1.0,deflate;q=0.6,identity;q=0.3".freeze + + GET = "get".freeze + PUT = "put".freeze + POST = "post".freeze + DELETE = "delete".freeze + HEAD = "head".freeze + + HTTPS = "https".freeze + + SLASH = "/".freeze + + def self.user_agent=(ua) + @user_agent = ua + end + + def self.user_agent + @user_agent ||= DEFAULT_UA + end + + attr_reader :method, :url, :headers, :http_client, :http_request + + def initialize(method, url, req_body, base_headers={}) + @method, @url = method, url + @request_body = nil + @cookies = CookieJar.instance + configure_http_client + build_headers(base_headers) + configure_http_request(req_body) + end + + def host + @url.host + end + + def port + @url.port + end + + def query + @url.query + end + + def path + @url.path.empty? ? SLASH : @url.path + end + + def call + hide_net_http_bug do + http_client.request(http_request) do |response| + store_cookie(response) + yield response if block_given? + response + end + end + end + + def config + Chef::Config + end + + private + + def hide_net_http_bug + yield + rescue NoMethodError => e + # http://redmine.ruby-lang.org/issues/show/2708 + # http://redmine.ruby-lang.org/issues/show/2758 + if e.to_s =~ /#{Regexp.escape(%q|undefined method `closed?' for nil:NilClass|)}/ + Chef::Log.debug("Rescued error in http connect, re-raising as Errno::ECONNREFUSED to hide bug in net/http") + Chef::Log.debug("#{e.class.name}: #{e.to_s}") + Chef::Log.debug(e.backtrace.join("\n")) + raise Errno::ECONNREFUSED, "Connection refused attempting to contact #{url.scheme}://#{host}:#{port}" + else + raise + end + end + + def store_cookie(response) + if response['set-cookie'] + @cookies["#{host}:#{port}"] = response['set-cookie'] + end + end + + def build_headers(headers) + @headers = headers.dup + # TODO: need to set accept somewhere else + # headers.merge!('Accept' => "application/json") unless raw + @headers['X-Chef-Version'] = ::Chef::VERSION + @headers[ACCEPT_ENCODING] = ENCODING_GZIP_DEFLATE + + if @cookies.has_key?("#{host}:#{port}") + @headers['Cookie'] = @cookies["#{host}:#{port}"] + end + end + + #adapted from buildr/lib/buildr/core/transports.rb + def proxy_uri + proxy = Chef::Config["#{url.scheme}_proxy"] + proxy = URI.parse(proxy) if String === proxy + excludes = Chef::Config[:no_proxy].to_s.split(/\s*,\s*/).compact + excludes = excludes.map { |exclude| exclude =~ /:\d+$/ ? exclude : "#{exclude}:*" } + return proxy unless excludes.any? { |exclude| File.fnmatch(exclude, "#{host}:#{port}") } + end + + def configure_http_client + http_proxy = proxy_uri + if http_proxy.nil? + @http_client = Net::HTTP.new(host, port) + else + Chef::Log.debug("Using #{http_proxy.host}:#{http_proxy.port} for proxy") + user = Chef::Config["#{url.scheme}_proxy_user"] + pass = Chef::Config["#{url.scheme}_proxy_pass"] + @http_client = Net::HTTP.Proxy(http_proxy.host, http_proxy.port, user, pass).new(host, port) + end + if url.scheme == HTTPS + @http_client.use_ssl = true + if config[:ssl_verify_mode] == :verify_none + @http_client.verify_mode = OpenSSL::SSL::VERIFY_NONE + elsif config[:ssl_verify_mode] == :verify_peer + @http_client.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + if config[:ssl_ca_path] + unless ::File.exist?(config[:ssl_ca_path]) + raise Chef::Exceptions::ConfigurationError, "The configured ssl_ca_path #{config[:ssl_ca_path]} does not exist" + end + @http_client.ca_path = config[:ssl_ca_path] + elsif config[:ssl_ca_file] + unless ::File.exist?(config[:ssl_ca_file]) + raise Chef::Exceptions::ConfigurationError, "The configured ssl_ca_file #{config[:ssl_ca_file]} does not exist" + end + @http_client.ca_file = config[:ssl_ca_file] + end + if (config[:ssl_client_cert] || config[:ssl_client_key]) + unless (config[:ssl_client_cert] && config[:ssl_client_key]) + raise Chef::Exceptions::ConfigurationError, "You must configure ssl_client_cert and ssl_client_key together" + end + unless ::File.exists?(config[:ssl_client_cert]) + raise Chef::Exceptions::ConfigurationError, "The configured ssl_client_cert #{config[:ssl_client_cert]} does not exist" + end + unless ::File.exists?(config[:ssl_client_key]) + raise Chef::Exceptions::ConfigurationError, "The configured ssl_client_key #{config[:ssl_client_key]} does not exist" + end + @http_client.cert = OpenSSL::X509::Certificate.new(::File.read(config[:ssl_client_cert])) + @http_client.key = OpenSSL::PKey::RSA.new(::File.read(config[:ssl_client_key])) + end + end + + @http_client.read_timeout = config[:rest_timeout] + end + + + def configure_http_request(request_body=nil) + req_path = "#{path}" + req_path << "?#{query}" if query + + @http_request = case method.to_s.downcase + when GET + Net::HTTP::Get.new(req_path, headers) + when POST + Net::HTTP::Post.new(req_path, headers) + when PUT + Net::HTTP::Put.new(req_path, headers) + when DELETE + Net::HTTP::Delete.new(req_path, headers) + when HEAD + Net::HTTP::Head.new(req_path, headers) + else + raise ArgumentError, "You must provide :GET, :PUT, :POST, :DELETE or :HEAD as the method" + end + + @http_request.body = request_body if (request_body && @http_request.request_body_permitted?) + # Optionally handle HTTP Basic Authentication + if url.user + user = URI.unescape(url.user) + password = URI.unescape(url.password) if url.password + @http_request.basic_auth(user, password) + end + @http_request[USER_AGENT] = self.class.user_agent + end + + end + end +end diff --git a/lib/chef/http/json_to_model_inflater.rb b/lib/chef/http/json_to_model_inflater.rb new file mode 100644 index 0000000000..bcdc5647aa --- /dev/null +++ b/lib/chef/http/json_to_model_inflater.rb @@ -0,0 +1,56 @@ +#-- +# Author:: Daniel DeLeo (<dan@opscode.com>) +# Copyright:: Copyright (c) 2013 Opscode, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require 'chef/json_compat' +class Chef + class HTTP + + # A Middleware-ish thing that takes an HTTP response, parses it as JSON if + # possible, and converts it into an appropriate model object if it contains + # a `json_class` key. + class JSONToModelInflater + + def initialize(opts={}) + end + + def handle_request(method, url, headers={}, data=false) + headers['Accept'] = "application/json" + headers["Content-Type"] = 'application/json' if data + json_body = data ? Chef::JSONCompat.to_json(data) : nil + # Force encoding to binary to fix SSL related EOFErrors + # cf. http://tickets.opscode.com/browse/CHEF-2363 + # http://redmine.ruby-lang.org/issues/5233 + json_body.force_encoding(Encoding::BINARY) if json_body.respond_to?(:force_encoding) + [method, url, headers, json_body] + end + + def handle_response(http_response, rest_request, return_value) + # temporary hack, skip processing if return_value is false + # needed to keep conditional get stuff working correctly. + return [http_response, rest_request, return_value] if return_value == false + if http_response['content-type'] =~ /json/ + [http_response, rest_request, Chef::JSONCompat.from_json(http_response.body.chomp)] + else + Chef::Log.warn("Expected JSON response, but got content-type '#{http_response['content-type']}'") + return [http_response, rest_request, http_response.body.to_s] + end + end + + end + end +end |