diff --git a/lib/puppet/network/authentication.rb b/lib/puppet/network/authentication.rb deleted file mode 100644 index c7f9ace87..000000000 --- a/lib/puppet/network/authentication.rb +++ /dev/null @@ -1,35 +0,0 @@ -require 'puppet/ssl/certificate_authority' -require 'puppet/util/log/rate_limited_logger' - -# Place for any authentication related bits -module Puppet::Network::Authentication - # Create a rate-limited logger for the expiration warning that uses the run interval - # as the minimum amount of time before a warning about the same cert can be logged again. - # This is a class variable so that all classes that include the module share the same logger. - @@logger = Puppet::Util::Log::RateLimitedLogger.new(Puppet[:runinterval]) - - # Check the expiration of known certificates and optionally any that are specified as part of a request - def warn_if_near_expiration(*certs) - # Check CA cert if we're functioning as a CA - certs << Puppet::SSL::CertificateAuthority.instance.host.certificate if Puppet::SSL::CertificateAuthority.ca? - - # Depending on the run mode, the localhost certificate will be for the - # master or the agent. Don't load the certificate if the CA cert is not - # present: infinite recursion will occur as another authenticated request - # will be spawned to download the CA cert. - if [Puppet[:hostcert], Puppet[:localcacert]].all? {|path| Puppet::FileSystem.exist?(path) } - certs << Puppet::SSL::Host.localhost.certificate - end - - # Remove nil values for caller convenience - certs.compact.each do |cert| - # Allow raw OpenSSL certificate instances or Puppet certificate wrappers to be specified - cert = Puppet::SSL::Certificate.from_instance(cert) if cert.is_a?(OpenSSL::X509::Certificate) - raise ArgumentError, "Invalid certificate '#{cert.inspect}'" unless cert.is_a?(Puppet::SSL::Certificate) - - if cert.near_expiration? - @@logger.warning("Certificate '#{cert.unmunged_name}' will expire on #{cert.expiration.strftime('%Y-%m-%dT%H:%M:%S%Z')}") - end - end - end -end diff --git a/lib/puppet/network/http/connection.rb b/lib/puppet/network/http/connection.rb index 8ffb1dda1..ff4baf533 100644 --- a/lib/puppet/network/http/connection.rb +++ b/lib/puppet/network/http/connection.rb @@ -1,240 +1,233 @@ require 'net/https' require 'puppet/ssl/host' require 'puppet/ssl/configuration' require 'puppet/ssl/validator' -require 'puppet/network/authentication' require 'puppet/network/http' require 'uri' module Puppet::Network::HTTP # This will be raised if too many redirects happen for a given HTTP request class RedirectionLimitExceededException < Puppet::Error ; end # This class provides simple methods for issuing various types of HTTP # requests. It's interface is intended to mirror Ruby's Net::HTTP # object, but it provides a few important bits of additional # functionality. Notably: # # * Any HTTPS requests made using this class will use Puppet's SSL # certificate configuration for their authentication, and # * Provides some useful error handling for any SSL errors that occur # during a request. # @api public class Connection - include Puppet::Network::Authentication OPTION_DEFAULTS = { :use_ssl => true, :verify => nil, :redirect_limit => 10, } # Creates a new HTTP client connection to `host`:`port`. # @param host [String] the host to which this client will connect to # @param port [Fixnum] the port to which this client will connect to # @param options [Hash] options influencing the properties of the created # connection, # @option options [Boolean] :use_ssl true to connect with SSL, false # otherwise, defaults to true # @option options [#setup_connection] :verify An object that will configure # any verification to do on the connection # @option options [Fixnum] :redirect_limit the number of allowed # redirections, defaults to 10 passing any other option in the options # hash results in a Puppet::Error exception # # @note the HTTP connection itself happens lazily only when {#request}, or # one of the {#get}, {#post}, {#delete}, {#head} or {#put} is called # @note The correct way to obtain a connection is to use one of the factory # methods on {Puppet::Network::HttpPool} # @api private def initialize(host, port, options = {}) @host = host @port = port unknown_options = options.keys - OPTION_DEFAULTS.keys raise Puppet::Error, "Unrecognized option(s): #{unknown_options.map(&:inspect).sort.join(', ')}" unless unknown_options.empty? options = OPTION_DEFAULTS.merge(options) @use_ssl = options[:use_ssl] @verify = options[:verify] @redirect_limit = options[:redirect_limit] @site = Puppet::Network::HTTP::Site.new(@use_ssl ? 'https' : 'http', host, port) @pool = Puppet.lookup(:http_pool) end # @!macro [new] common_options # @param options [Hash] options influencing the request made # @option options [Hash{Symbol => String}] :basic_auth The basic auth # :username and :password to use for the request # @param path [String] # @param headers [Hash{String => String}] # @!macro common_options # @api public def get(path, headers = {}, options = {}) request_with_redirects(Net::HTTP::Get.new(path, headers), options) end # @param path [String] # @param data [String] # @param headers [Hash{String => String}] # @!macro common_options # @api public def post(path, data, headers = nil, options = {}) request = Net::HTTP::Post.new(path, headers) request.body = data request_with_redirects(request, options) end # @param path [String] # @param headers [Hash{String => String}] # @!macro common_options # @api public def head(path, headers = {}, options = {}) request_with_redirects(Net::HTTP::Head.new(path, headers), options) end # @param path [String] # @param headers [Hash{String => String}] # @!macro common_options # @api public def delete(path, headers = {'Depth' => 'Infinity'}, options = {}) request_with_redirects(Net::HTTP::Delete.new(path, headers), options) end # @param path [String] # @param data [String] # @param headers [Hash{String => String}] # @!macro common_options # @api public def put(path, data, headers = nil, options = {}) request = Net::HTTP::Put.new(path, headers) request.body = data request_with_redirects(request, options) end def request(method, *args) self.send(method, *args) end # TODO: These are proxies for the Net::HTTP#request_* methods, which are # almost the same as the "get", "post", etc. methods that we've ported above, # but they are able to accept a code block and will yield to it, which is # necessary to stream responses, e.g. file content. For now # we're not funneling these proxy implementations through our #request # method above, so they will not inherit the same error handling. In the # future we may want to refactor these so that they are funneled through # that method and do inherit the error handling. def request_get(*args, &block) with_connection(@site) do |connection| connection.request_get(*args, &block) end end def request_head(*args, &block) with_connection(@site) do |connection| connection.request_head(*args, &block) end end def request_post(*args, &block) with_connection(@site) do |connection| connection.request_post(*args, &block) end end # end of Net::HTTP#request_* proxies # The address to connect to. def address @site.host end # The port to connect to. def port @site.port end # Whether to use ssl def use_ssl? @site.use_ssl? end private def request_with_redirects(request, options) current_request = request current_site = @site response = nil 0.upto(@redirect_limit) do |redirection| return response if response with_connection(current_site) do |connection| apply_options_to(current_request, options) current_response = execute_request(connection, current_request) if [301, 302, 307].include?(current_response.code.to_i) # handle the redirection location = URI.parse(current_response['location']) current_site = current_site.move_to(location) # update to the current request path current_request = current_request.class.new(location.path) current_request.body = request.body request.each do |header, value| current_request[header] = value end else response = current_response end end # and try again... end raise RedirectionLimitExceededException, "Too many HTTP redirections for #{@host}:#{@port}" end def apply_options_to(request, options) if options[:basic_auth] request.basic_auth(options[:basic_auth][:user], options[:basic_auth][:password]) end end def execute_request(connection, request) - response = connection.request(request) - - # Check the peer certs and warn if they're nearing expiration. - warn_if_near_expiration(*@verify.peer_certs) - - response + connection.request(request) end def with_connection(site, &block) response = nil @pool.with_connection(site, @verify) do |conn| response = yield conn end response rescue OpenSSL::SSL::SSLError => error if error.message.include? "certificate verify failed" msg = error.message msg << ": [" + @verify.verify_errors.join('; ') + "]" raise Puppet::Error, msg, error.backtrace elsif error.message =~ /hostname.*not match.*server certificate/ leaf_ssl_cert = @verify.peer_certs.last valid_certnames = [leaf_ssl_cert.name, *leaf_ssl_cert.subject_alt_names].uniq msg = valid_certnames.length > 1 ? "one of #{valid_certnames.join(', ')}" : valid_certnames.first msg = "Server hostname '#{site.host}' did not match server certificate; expected #{msg}" raise Puppet::Error, msg, error.backtrace else raise end end end end diff --git a/lib/puppet/network/http/handler.rb b/lib/puppet/network/http/handler.rb index a8aa1aaeb..01fea73e7 100644 --- a/lib/puppet/network/http/handler.rb +++ b/lib/puppet/network/http/handler.rb @@ -1,186 +1,184 @@ module Puppet::Network::HTTP end require 'puppet/network/http' require 'puppet/network/http/api/v1' -require 'puppet/network/authentication' require 'puppet/network/rights' require 'puppet/util/profiler' require 'puppet/util/profiler/aggregate' require 'resolv' module Puppet::Network::HTTP::Handler - include Puppet::Network::Authentication include Puppet::Network::HTTP::Issues # These shouldn't be allowed to be set by clients # in the query string, for security reasons. DISALLOWED_KEYS = ["node", "ip"] def register(routes) # There's got to be a simpler way to do this, right? dupes = {} routes.each { |r| dupes[r.path_matcher] = (dupes[r.path_matcher] || 0) + 1 } dupes = dupes.collect { |pm, count| pm if count > 1 }.compact if dupes.count > 0 raise ArgumentError, "Given multiple routes with identical path regexes: #{dupes.map{ |rgx| rgx.inspect }.join(', ')}" end @routes = routes Puppet.debug("Routes Registered:") @routes.each do |route| Puppet.debug(route.inspect) end end # Retrieve all headers from the http request, as a hash with the header names # (lower-cased) as the keys def headers(request) raise NotImplementedError end def format_to_mime(format) format.is_a?(Puppet::Network::Format) ? format.mime : format end # handle an HTTP request def process(request, response) new_response = Puppet::Network::HTTP::Response.new(self, response) request_headers = headers(request) request_params = params(request) request_method = http_method(request) request_path = path(request) new_request = Puppet::Network::HTTP::Request.new(request_headers, request_params, request_method, request_path, request_path, client_cert(request), body(request)) response[Puppet::Network::HTTP::HEADER_PUPPET_VERSION] = Puppet.version profiler = configure_profiler(request_headers, request_params) Puppet::Util::Profiler.profile("Processed request #{request_method} #{request_path}", [:http, request_method, request_path]) do if route = @routes.find { |route| route.matches?(new_request) } route.process(new_request, new_response) else raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("No route for #{new_request.method} #{new_request.path}", HANDLER_NOT_FOUND) end end rescue Puppet::Network::HTTP::Error::HTTPError => e Puppet.info(e.message) new_response.respond_with(e.status, "application/json", e.to_json) rescue Exception => e http_e = Puppet::Network::HTTP::Error::HTTPServerError.new(e) Puppet.err(http_e.message) new_response.respond_with(http_e.status, "application/json", http_e.to_json) ensure if profiler remove_profiler(profiler) end cleanup(request) end # Set the response up, with the body and status. def set_response(response, body, status = 200) raise NotImplementedError end # Set the specified format as the content type of the response. def set_content_type(response, format) raise NotImplementedError end # resolve node name from peer's ip address # this is used when the request is unauthenticated def resolve_node(result) begin return Resolv.getname(result[:ip]) rescue => detail Puppet.err "Could not resolve #{result[:ip]}: #{detail}" end result[:ip] end private # methods to be overridden by the including web server class def http_method(request) raise NotImplementedError end def path(request) raise NotImplementedError end def request_key(request) raise NotImplementedError end def body(request) raise NotImplementedError end def params(request) raise NotImplementedError end def client_cert(request) raise NotImplementedError end def cleanup(request) # By default, there is nothing to cleanup. end def decode_params(params) params.select { |key, _| allowed_parameter?(key) }.inject({}) do |result, ary| param, value = ary result[param.to_sym] = parse_parameter_value(param, value) result end end def allowed_parameter?(name) not (name.nil? || name.empty? || DISALLOWED_KEYS.include?(name)) end def parse_parameter_value(param, value) case value when /^---/ Puppet.debug("Found YAML while processing request parameter #{param} (value: <#{value}>)") Puppet.deprecation_warning("YAML in network requests is deprecated and will be removed in a future version. See http://links.puppetlabs.com/deprecate_yaml_on_network") YAML.load(value, :safe => true, :deserialize_symbols => true) when Array value.collect { |v| parse_primitive_parameter_value(v) } else parse_primitive_parameter_value(value) end end def parse_primitive_parameter_value(value) case value when "true" true when "false" false when /^\d+$/ Integer(value) when /^\d+\.\d+$/ value.to_f else value end end def configure_profiler(request_headers, request_params) if (request_headers.has_key?(Puppet::Network::HTTP::HEADER_ENABLE_PROFILING.downcase) or Puppet[:profile]) Puppet::Util::Profiler.add_profiler(Puppet::Util::Profiler::Aggregate.new(Puppet.method(:debug), request_params.object_id)) end end def remove_profiler(profiler) profiler.shutdown Puppet::Util::Profiler.remove_profiler(profiler) end end diff --git a/lib/puppet/network/http/rack/rest.rb b/lib/puppet/network/http/rack/rest.rb index 3ca79c8ae..23d73bf93 100644 --- a/lib/puppet/network/http/rack/rest.rb +++ b/lib/puppet/network/http/rack/rest.rb @@ -1,138 +1,136 @@ require 'openssl' require 'cgi' require 'puppet/network/http/handler' require 'puppet/util/ssl' class Puppet::Network::HTTP::RackREST include Puppet::Network::HTTP::Handler ContentType = 'Content-Type'.freeze CHUNK_SIZE = 8192 class RackFile def initialize(file) @file = file end def each while chunk = @file.read(CHUNK_SIZE) yield chunk end end def close @file.close end end def initialize(args={}) super() register([Puppet::Network::HTTP::API::V2.routes, Puppet::Network::HTTP::API::V1.routes]) end def set_content_type(response, format) response[ContentType] = format_to_mime(format) end # produce the body of the response def set_response(response, result, status = 200) response.status = status unless result.is_a?(File) response.write result else response["Content-Length"] = result.stat.size.to_s response.body = RackFile.new(result) end end # Retrieve all headers from the http request, as a map. def headers(request) headers = request.env.select {|k,v| k.start_with? 'HTTP_'}.inject({}) do |m, (k,v)| m[k.sub(/^HTTP_/, '').gsub('_','-').downcase] = v m end headers['content-type'] = request.content_type headers end # Return which HTTP verb was used in this request. def http_method(request) request.request_method end # Return the query params for this request. def params(request) if request.post? params = request.params else # rack doesn't support multi-valued query parameters, # e.g. ignore, so parse them ourselves params = CGI.parse(request.query_string) convert_singular_arrays_to_value(params) end result = decode_params(params) result.merge(extract_client_info(request)) end # what path was requested? (this is, without any query parameters) def path(request) request.path end # return the request body def body(request) request.body.read end def client_cert(request) # This environment variable is set by mod_ssl, note that it # requires the `+ExportCertData` option in the `SSLOptions` directive cert = request.env['SSL_CLIENT_CERT'] # NOTE: The SSL_CLIENT_CERT environment variable will be the empty string # when Puppet agent nodes have not yet obtained a signed certificate. if cert.nil? || cert.empty? nil else - cert = Puppet::SSL::Certificate.from_instance(OpenSSL::X509::Certificate.new(cert)) - warn_if_near_expiration(cert) - cert + Puppet::SSL::Certificate.from_instance(OpenSSL::X509::Certificate.new(cert)) end end # Passenger freaks out if we finish handling the request without reading any # part of the body, so make sure we have. def cleanup(request) request.body.read(1) nil end def extract_client_info(request) result = {} result[:ip] = request.ip # if we find SSL info in the headers, use them to get a hostname from the CN. # try this with :ssl_client_header, which defaults should work for # Apache with StdEnvVars. subj_str = request.env[Puppet[:ssl_client_header]] subject = Puppet::Util::SSL.subject_from_dn(subj_str || "") if cn = Puppet::Util::SSL.cn_from_subject(subject) result[:node] = cn result[:authenticated] = (request.env[Puppet[:ssl_client_verify_header]] == 'SUCCESS') else result[:node] = resolve_node(result) result[:authenticated] = false end result end def convert_singular_arrays_to_value(hash) hash.each do |key, value| if value.size == 1 hash[key] = value.first end end end end diff --git a/lib/puppet/network/http/webrick/rest.rb b/lib/puppet/network/http/webrick/rest.rb index ae0867dfb..64c859a10 100644 --- a/lib/puppet/network/http/webrick/rest.rb +++ b/lib/puppet/network/http/webrick/rest.rb @@ -1,104 +1,102 @@ require 'puppet/network/http/handler' require 'resolv' require 'webrick' require 'puppet/util/ssl' class Puppet::Network::HTTP::WEBrickREST < WEBrick::HTTPServlet::AbstractServlet include Puppet::Network::HTTP::Handler def self.mutex @mutex ||= Mutex.new end def initialize(server) raise ArgumentError, "server is required" unless server register([Puppet::Network::HTTP::API::V2.routes, Puppet::Network::HTTP::API::V1.routes]) super(server) end # Retrieve the request parameters, including authentication information. def params(request) params = request.query || {} params = Hash[params.collect do |key, value| all_values = value.list [key, all_values.length == 1 ? value : all_values] end] params = decode_params(params) params.merge(client_information(request)) end # WEBrick uses a service method to respond to requests. Simply delegate to # the handler response method. def service(request, response) self.class.mutex.synchronize do process(request, response) end end def headers(request) result = {} request.each do |k, v| result[k.downcase] = v end result end def http_method(request) request.request_method end def path(request) request.path end def body(request) request.body end def client_cert(request) if cert = request.client_cert - cert = Puppet::SSL::Certificate.from_instance(cert) - warn_if_near_expiration(cert) - cert + Puppet::SSL::Certificate.from_instance(cert) else nil end end # Set the specified format as the content type of the response. def set_content_type(response, format) response["content-type"] = format_to_mime(format) end def set_response(response, result, status = 200) response.status = status if status >= 200 and status != 304 response.body = result response["content-length"] = result.stat.size if result.is_a?(File) end end # Retrieve node/cert/ip information from the request object. def client_information(request) result = {} if peer = request.peeraddr and ip = peer[3] result[:ip] = ip end # If they have a certificate (which will almost always be true) # then we get the hostname from the cert, instead of via IP # info result[:authenticated] = false if cert = request.client_cert and cn = Puppet::Util::SSL.cn_from_subject(cert.subject) result[:node] = cn result[:authenticated] = true else result[:node] = resolve_node(result) end result end end diff --git a/lib/puppet/util/log/rate_limited_logger.rb b/lib/puppet/util/log/rate_limited_logger.rb deleted file mode 100644 index 73ebc536b..000000000 --- a/lib/puppet/util/log/rate_limited_logger.rb +++ /dev/null @@ -1,40 +0,0 @@ -require 'puppet/util/logging' - -# Logging utility class that limits the frequency of identical log messages -class Puppet::Util::Log::RateLimitedLogger - include Puppet::Util::Logging - - def initialize(interval) - raise ArgumentError, "Logging rate-limit interval must be an integer" unless interval.is_a?(Integer) - @interval = interval - @log_record = {} - end - - # Override the logging entry point to rate-limit it - def send_log(level, message) - Puppet::Util::Log.create({:level => level, :message => message}) if should_log?(level, message) - end - - private - - def should_log?(level, message) - # Initialize separate records for different levels, and only when needed - record = (@log_record[level] ||= {}) - last_log = record[message] - - # Skip logging if the time interval since the last logging hasn't elapsed yet - return false if last_log and within_interval?(last_log) - - # Purge stale entries; do this after the interval check to reduce passes through the cache - record.delete_if { |key, time| !within_interval?(time) } - - # Reset the beginning of the interval to the current time - record[message] = Time.now - - true - end - - def within_interval?(time) - time + @interval > Time.now - end -end diff --git a/spec/unit/network/authentication_spec.rb b/spec/unit/network/authentication_spec.rb deleted file mode 100755 index 5e2f2de87..000000000 --- a/spec/unit/network/authentication_spec.rb +++ /dev/null @@ -1,104 +0,0 @@ -#! /usr/bin/env ruby -require 'spec_helper' -load 'puppet/network/authentication.rb' - -class AuthenticationTest - include Puppet::Network::Authentication -end - -describe Puppet::Network::Authentication do - subject { AuthenticationTest.new } - let(:now) { Time.now } - let(:cert) { Puppet::SSL::Certificate.new('cert') } - let(:host) { stub 'host', :certificate => cert } - - # this is necessary since the logger is a class variable, and it needs to be stubbed - def reload_module - load 'puppet/network/authentication.rb' - end - - describe "when warning about upcoming expirations" do - before do - Puppet::SSL::CertificateAuthority.stubs(:ca?).returns(false) - Puppet::FileSystem.stubs(:exist?).returns(false) - end - - it "should check the expiration of the CA certificate" do - ca = stub 'ca', :host => host - Puppet::SSL::CertificateAuthority.stubs(:ca?).returns(true) - Puppet::SSL::CertificateAuthority.stubs(:instance).returns(ca) - cert.expects(:near_expiration?).returns(false) - subject.warn_if_near_expiration - end - - context "when examining the local host" do - before do - Puppet::SSL::Host.stubs(:localhost).returns(host) - Puppet::FileSystem.stubs(:exist?).with(Puppet[:hostcert]).returns(true) - end - - it "should not load the localhost certificate if the local CA certificate is missing" do - # Redmine-21869: Infinite recursion occurs if CA cert is missing. - Puppet::FileSystem.stubs(:exist?).with(Puppet[:localcacert]).returns(false) - host.unstub(:certificate) - host.expects(:certificate).never - subject.warn_if_near_expiration - end - - it "should check the expiration of the localhost certificate if the local CA certificate is present" do - Puppet::FileSystem.stubs(:exist?).with(Puppet[:localcacert]).returns(true) - cert.expects(:near_expiration?).returns(false) - subject.warn_if_near_expiration - end - end - - it "should check the expiration of any certificates passed in as arguments" do - cert.expects(:near_expiration?).twice.returns(false) - subject.warn_if_near_expiration(cert, cert) - end - - it "should accept instances of OpenSSL::X509::Certificate" do - raw_cert = stub 'cert' - raw_cert.stubs(:is_a?).with(OpenSSL::X509::Certificate).returns(true) - Puppet::SSL::Certificate.stubs(:from_instance).with(raw_cert).returns(cert) - cert.expects(:near_expiration?).returns(false) - subject.warn_if_near_expiration(raw_cert) - end - - it "should use a rate-limited logger for expiration warnings that uses `runinterval` as its interval" do - Puppet::Util::Log::RateLimitedLogger.expects(:new).with(Puppet[:runinterval]) - reload_module - end - - context "in the logs" do - let(:logger) { stub 'logger' } - - before do - Puppet::Util::Log::RateLimitedLogger.stubs(:new).returns(logger) - reload_module - cert.stubs(:near_expiration?).returns(true) - cert.stubs(:expiration).returns(now) - cert.stubs(:unmunged_name).returns('foo') - end - - after(:all) do - reload_module - end - - it "should log a warning if a certificate's expiration is near" do - logger.expects(:warning) - subject.warn_if_near_expiration(cert) - end - - it "should use the certificate's unmunged name in the message" do - logger.expects(:warning).with { |message| message.include? 'foo' } - subject.warn_if_near_expiration(cert) - end - - it "should show certificate's expiration date in the message using ISO 8601 format" do - logger.expects(:warning).with { |message| message.include? now.strftime('%Y-%m-%dT%H:%M:%S%Z') } - subject.warn_if_near_expiration(cert) - end - end - end -end diff --git a/spec/unit/network/http/connection_spec.rb b/spec/unit/network/http/connection_spec.rb index ef6ca65d6..79956d7e7 100755 --- a/spec/unit/network/http/connection_spec.rb +++ b/spec/unit/network/http/connection_spec.rb @@ -1,306 +1,303 @@ #! /usr/bin/env ruby require 'spec_helper' require 'puppet/network/http/connection' -require 'puppet/network/authentication' describe Puppet::Network::HTTP::Connection do let (:host) { "me" } let (:port) { 54321 } subject { Puppet::Network::HTTP::Connection.new(host, port, :verify => Puppet::SSL::Validator.no_validator) } let (:httpok) { Net::HTTPOK.new('1.1', 200, '') } context "when providing HTTP connections" do context "when initializing http instances" do it "should return an http instance created with the passed host and port" do conn = Puppet::Network::HTTP::Connection.new(host, port, :verify => Puppet::SSL::Validator.no_validator) expect(conn.address).to eq(host) expect(conn.port).to eq(port) end it "should enable ssl on the http instance by default" do conn = Puppet::Network::HTTP::Connection.new(host, port, :verify => Puppet::SSL::Validator.no_validator) expect(conn).to be_use_ssl end it "can disable ssl using an option" do conn = Puppet::Network::HTTP::Connection.new(host, port, :use_ssl => false, :verify => Puppet::SSL::Validator.no_validator) expect(conn).to_not be_use_ssl end it "can enable ssl using an option" do conn = Puppet::Network::HTTP::Connection.new(host, port, :use_ssl => true, :verify => Puppet::SSL::Validator.no_validator) expect(conn).to be_use_ssl end it "should raise Puppet::Error when invalid options are specified" do expect { Puppet::Network::HTTP::Connection.new(host, port, :invalid_option => nil) }.to raise_error(Puppet::Error, 'Unrecognized option(s): :invalid_option') end end end context "when methods that accept a block are called with a block" do let (:host) { "my_server" } let (:port) { 8140 } let (:subject) { Puppet::Network::HTTP::Connection.new(host, port, :use_ssl => false, :verify => Puppet::SSL::Validator.no_validator) } before :each do httpok.stubs(:body).returns "" # This stubbing relies a bit more on knowledge of the internals of Net::HTTP # than I would prefer, but it works on ruby 1.8.7 and 1.9.3, and it seems # valuable enough to have tests for blocks that this is probably warranted. socket = stub_everything("socket") TCPSocket.stubs(:open).returns(socket) Net::HTTP::Post.any_instance.stubs(:exec).returns("") Net::HTTP::Head.any_instance.stubs(:exec).returns("") Net::HTTP::Get.any_instance.stubs(:exec).returns("") Net::HTTPResponse.stubs(:read_new).returns(httpok) end [:request_get, :request_head, :request_post].each do |method| context "##{method}" do it "should yield to the block" do block_executed = false subject.send(method, "/foo", {}) do |response| block_executed = true end block_executed.should == true end end end end class ConstantErrorValidator def initialize(args) @fails_with = args[:fails_with] @error_string = args[:error_string] || "" @peer_certs = args[:peer_certs] || [] end def setup_connection(connection) connection.stubs(:start).raises(OpenSSL::SSL::SSLError.new(@fails_with)) end def peer_certs @peer_certs end def verify_errors [@error_string] end end class NoProblemsValidator def initialize(cert) @cert = cert end def setup_connection(connection) end def peer_certs [@cert] end def verify_errors [] end end shared_examples_for 'ssl verifier' do include PuppetSpec::Files let (:host) { "my_server" } let (:port) { 8140 } it "should provide a useful error message when one is available and certificate validation fails", :unless => Puppet.features.microsoft_windows? do connection = Puppet::Network::HTTP::Connection.new( host, port, :verify => ConstantErrorValidator.new(:fails_with => 'certificate verify failed', :error_string => 'shady looking signature')) expect do connection.get('request') end.to raise_error(Puppet::Error, "certificate verify failed: [shady looking signature]") end it "should provide a helpful error message when hostname was not match with server certificate", :unless => Puppet.features.microsoft_windows? do Puppet[:confdir] = tmpdir('conf') connection = Puppet::Network::HTTP::Connection.new( host, port, :verify => ConstantErrorValidator.new( :fails_with => 'hostname was not match with server certificate', :peer_certs => [Puppet::SSL::CertificateAuthority.new.generate( 'not_my_server', :dns_alt_names => 'foo,bar,baz')])) expect do connection.get('request') end.to raise_error(Puppet::Error) do |error| error.message =~ /Server hostname 'my_server' did not match server certificate; expected one of (.+)/ $1.split(', ').should =~ %w[DNS:foo DNS:bar DNS:baz DNS:not_my_server not_my_server] end end it "should pass along the error message otherwise" do connection = Puppet::Network::HTTP::Connection.new( host, port, :verify => ConstantErrorValidator.new(:fails_with => 'some other message')) expect do connection.get('request') end.to raise_error(/some other message/) end it "should check all peer certificates for upcoming expiration", :unless => Puppet.features.microsoft_windows? do Puppet[:confdir] = tmpdir('conf') cert = Puppet::SSL::CertificateAuthority.new.generate( 'server', :dns_alt_names => 'foo,bar,baz') connection = Puppet::Network::HTTP::Connection.new( host, port, :verify => NoProblemsValidator.new(cert)) Net::HTTP.any_instance.stubs(:start) Net::HTTP.any_instance.stubs(:request).returns(httpok) - connection.expects(:warn_if_near_expiration).with(cert) - connection.get('request') end end context "when using single use HTTPS connections" do it_behaves_like 'ssl verifier' do end end context "when using persistent HTTPS connections" do around :each do |example| pool = Puppet::Network::HTTP::Pool.new Puppet.override(:http_pool => pool) do example.run end pool.close end it_behaves_like 'ssl verifier' do end end context "when response is a redirect" do let (:site) { Puppet::Network::HTTP::Site.new('http', 'my_server', 8140) } let (:other_site) { Puppet::Network::HTTP::Site.new('http', 'redirected', 9292) } let (:other_path) { "other-path" } let (:verify) { Puppet::SSL::Validator.no_validator } let (:subject) { Puppet::Network::HTTP::Connection.new(site.host, site.port, :use_ssl => false, :verify => verify) } let (:httpredirection) do response = Net::HTTPFound.new('1.1', 302, 'Moved Temporarily') response['location'] = "#{other_site.addr}/#{other_path}" response.stubs(:read_body).returns("This resource has moved") response end def create_connection(site, options) options[:use_ssl] = site.use_ssl? Puppet::Network::HTTP::Connection.new(site.host, site.port, options) end it "should redirect to the final resource location" do http = stub('http') http.stubs(:request).returns(httpredirection).then.returns(httpok) seq = sequence('redirection') pool = Puppet.lookup(:http_pool) pool.expects(:with_connection).with(site, anything).yields(http).in_sequence(seq) pool.expects(:with_connection).with(other_site, anything).yields(http).in_sequence(seq) conn = create_connection(site, :verify => verify) conn.get('/foo') end def expects_redirection(conn, &block) http = stub('http') http.stubs(:request).returns(httpredirection) pool = Puppet.lookup(:http_pool) pool.expects(:with_connection).with(site, anything).yields(http) pool end def expects_limit_exceeded(conn) expect { conn.get('/') }.to raise_error(Puppet::Network::HTTP::RedirectionLimitExceededException) end it "should not redirect when the limit is 0" do conn = create_connection(site, :verify => verify, :redirect_limit => 0) pool = expects_redirection(conn) pool.expects(:with_connection).with(other_site, anything).never expects_limit_exceeded(conn) end it "should redirect only once" do conn = create_connection(site, :verify => verify, :redirect_limit => 1) pool = expects_redirection(conn) pool.expects(:with_connection).with(other_site, anything).once expects_limit_exceeded(conn) end it "should raise an exception when the redirect limit is exceeded" do conn = create_connection(site, :verify => verify, :redirect_limit => 3) pool = expects_redirection(conn) pool.expects(:with_connection).with(other_site, anything).times(3) expects_limit_exceeded(conn) end end it "allows setting basic auth on get requests" do expect_request_with_basic_auth subject.get('/path', nil, :basic_auth => { :user => 'user', :password => 'password' }) end it "allows setting basic auth on post requests" do expect_request_with_basic_auth subject.post('/path', 'data', nil, :basic_auth => { :user => 'user', :password => 'password' }) end it "allows setting basic auth on head requests" do expect_request_with_basic_auth subject.head('/path', nil, :basic_auth => { :user => 'user', :password => 'password' }) end it "allows setting basic auth on delete requests" do expect_request_with_basic_auth subject.delete('/path', nil, :basic_auth => { :user => 'user', :password => 'password' }) end it "allows setting basic auth on put requests" do expect_request_with_basic_auth subject.put('/path', 'data', nil, :basic_auth => { :user => 'user', :password => 'password' }) end def expect_request_with_basic_auth Net::HTTP.any_instance.expects(:request).with do |request| expect(request['authorization']).to match(/^Basic/) end.returns(httpok) end end diff --git a/spec/unit/network/http/handler_spec.rb b/spec/unit/network/http/handler_spec.rb index 25df9d270..8a114942e 100755 --- a/spec/unit/network/http/handler_spec.rb +++ b/spec/unit/network/http/handler_spec.rb @@ -1,232 +1,231 @@ #! /usr/bin/env ruby require 'spec_helper' require 'puppet/indirector_testing' require 'puppet/network/authorization' -require 'puppet/network/authentication' require 'puppet/network/http' describe Puppet::Network::HTTP::Handler do before :each do Puppet::IndirectorTesting.indirection.terminus_class = :memory end let(:indirection) { Puppet::IndirectorTesting.indirection } def a_request(method = "HEAD", path = "/production/#{indirection.name}/unknown") { :accept_header => "pson", :content_type_header => "text/yaml", :http_method => method, :path => path, :params => {}, :client_cert => nil, :headers => {}, :body => nil } end let(:handler) { TestingHandler.new() } describe "the HTTP Handler" do def respond(text) lambda { |req, res| res.respond_with(200, "text/plain", text) } end it "hands the request to the first route that matches the request path" do handler = TestingHandler.new( Puppet::Network::HTTP::Route.path(%r{^/foo}).get(respond("skipped")), Puppet::Network::HTTP::Route.path(%r{^/vtest}).get(respond("used")), Puppet::Network::HTTP::Route.path(%r{^/vtest/foo}).get(respond("ignored"))) req = a_request("GET", "/vtest/foo") res = {} handler.process(req, res) expect(res[:body]).to eq("used") end it "raises an error if multiple routes with the same path regex are registered" do expect do handler = TestingHandler.new( Puppet::Network::HTTP::Route.path(%r{^/foo}).get(respond("ignored")), Puppet::Network::HTTP::Route.path(%r{^/foo}).post(respond("also ignored"))) end.to raise_error(ArgumentError) end it "raises an HTTP not found error if no routes match" do handler = TestingHandler.new req = a_request("GET", "/vtest/foo") res = {} handler.process(req, res) res_body = JSON(res[:body]) expect(res[:content_type_header]).to eq("application/json") expect(res_body["issue_kind"]).to eq("HANDLER_NOT_FOUND") expect(res_body["message"]).to eq("Not Found: No route for GET /vtest/foo") expect(res[:status]).to eq(404) end it "returns a structured error response with a stacktrace when the server encounters an internal error" do handler = TestingHandler.new( Puppet::Network::HTTP::Route.path(/.*/).get(lambda { |_, _| raise Exception.new("the sky is falling!")})) req = a_request("GET", "/vtest/foo") res = {} handler.process(req, res) res_body = JSON(res[:body]) expect(res[:content_type_header]).to eq("application/json") expect(res_body["issue_kind"]).to eq(Puppet::Network::HTTP::Issues::RUNTIME_ERROR.to_s) expect(res_body["message"]).to eq("Server Error: the sky is falling!") expect(res_body["stacktrace"].is_a?(Array) && !res_body["stacktrace"].empty?).to be_true expect(res_body["stacktrace"][0]).to match("spec/unit/network/http/handler_spec.rb") expect(res[:status]).to eq(500) end end describe "when processing a request" do let(:response) do { :status => 200 } end before do handler.stubs(:check_authorization) handler.stubs(:warn_if_near_expiration) end it "should setup a profiler when the puppet-profiling header exists" do request = a_request request[:headers][Puppet::Network::HTTP::HEADER_ENABLE_PROFILING.downcase] = "true" p = HandlerTestProfiler.new Puppet::Util::Profiler.expects(:add_profiler).with { |profiler| profiler.is_a? Puppet::Util::Profiler::WallClock }.returns(p) Puppet::Util::Profiler.expects(:remove_profiler).with { |profiler| profiler == p } handler.process(request, response) end it "should not setup profiler when the profile parameter is missing" do request = a_request request[:params] = { } Puppet::Util::Profiler.expects(:add_profiler).never handler.process(request, response) end it "should raise an error if the request is formatted in an unknown format" do handler.stubs(:content_type_header).returns "unknown format" lambda { handler.request_format(request) }.should raise_error end it "should still find the correct format if content type contains charset information" do request = Puppet::Network::HTTP::Request.new({ 'content-type' => "text/plain; charset=UTF-8" }, {}, 'GET', '/', nil) request.format.should == "s" end it "should deserialize YAML parameters" do params = {'my_param' => [1,2,3].to_yaml} decoded_params = handler.send(:decode_params, params) decoded_params.should == {:my_param => [1,2,3]} end it "should ignore tags on YAML parameters" do params = {'my_param' => "--- !ruby/object:Array {}"} decoded_params = handler.send(:decode_params, params) decoded_params[:my_param].should be_a(Hash) end end describe "when resolving node" do it "should use a look-up from the ip address" do Resolv.expects(:getname).with("1.2.3.4").returns("host.domain.com") handler.resolve_node(:ip => "1.2.3.4") end it "should return the look-up result" do Resolv.stubs(:getname).with("1.2.3.4").returns("host.domain.com") handler.resolve_node(:ip => "1.2.3.4").should == "host.domain.com" end it "should return the ip address if resolving fails" do Resolv.stubs(:getname).with("1.2.3.4").raises(RuntimeError, "no such host") handler.resolve_node(:ip => "1.2.3.4").should == "1.2.3.4" end end class TestingHandler include Puppet::Network::HTTP::Handler def initialize(* routes) register(routes) end def set_content_type(response, format) response[:content_type_header] = format end def set_response(response, body, status = 200) response[:body] = body response[:status] = status end def http_method(request) request[:http_method] end def path(request) request[:path] end def params(request) request[:params] end def client_cert(request) request[:client_cert] end def body(request) request[:body] end def headers(request) request[:headers] || {} end end class HandlerTestProfiler def start(metric, description) end def finish(context, metric, description) end def shutdown() end end end diff --git a/spec/unit/util/log/rate_limited_logger_spec.rb b/spec/unit/util/log/rate_limited_logger_spec.rb deleted file mode 100755 index fa21146a2..000000000 --- a/spec/unit/util/log/rate_limited_logger_spec.rb +++ /dev/null @@ -1,51 +0,0 @@ -#! /usr/bin/env ruby -require 'spec_helper' -require 'puppet/util/log/rate_limited_logger' - -describe Puppet::Util::Log::RateLimitedLogger do - - subject { Puppet::Util::Log::RateLimitedLogger.new(60) } - - before do - Time.stubs(:now).returns(0) - end - - it "should be able to log all levels" do - Puppet::Util::Log.eachlevel do |level| - subject.should respond_to(level) - end - end - - it "should fail if given an invalid time interval" do - expect { Puppet::Util::Log::RateLimitedLogger.new('foo') }.to raise_error(ArgumentError) - end - - it "should not log the same message more than once within the given interval" do - Puppet::Util::Log.expects(:create).once - subject.info('foo') - subject.info('foo') - end - - it "should allow the same message to be logged after the given interval has passed" do - Puppet::Util::Log.expects(:create).twice - subject.info('foo') - Time.stubs(:now).returns(60) - subject.info('foo') - end - - it "should rate-limit different message strings separately" do - Puppet::Util::Log.expects(:create).times(3) - subject.info('foo') - subject.info('bar') - subject.info('baz') - subject.info('foo') - subject.info('bar') - subject.info('baz') - end - - it "should limit the same message in different log levels independently" do - Puppet::Util::Log.expects(:create).twice - subject.info('foo') - subject.warning('foo') - end -end