summaryrefslogtreecommitdiff
path: root/lib/chef/http
diff options
context:
space:
mode:
Diffstat (limited to 'lib/chef/http')
-rw-r--r--lib/chef/http/auth_credentials.rb57
-rw-r--r--lib/chef/http/authenticator.rb85
-rw-r--r--lib/chef/http/cookie_jar.rb31
-rw-r--r--lib/chef/http/decompressor.rb122
-rw-r--r--lib/chef/http/http_request.rb233
-rw-r--r--lib/chef/http/json_to_model_inflater.rb56
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