diff --git a/lib/puppet/network/http/pool.rb b/lib/puppet/network/http/pool.rb index d6ba1d2fb..b817b472b 100644 --- a/lib/puppet/network/http/pool.rb +++ b/lib/puppet/network/http/pool.rb @@ -1,113 +1,120 @@ # A pool for persistent Net::HTTP connections. Connections are # stored in the pool indexed by their {Puppet::Network::HTTP::Site Site}. # Connections are borrowed from the pool, yielded to the caller, and # released back into the pool. If a connection is expired, it will be # closed either when a connection to that site is requested, or when # the pool is closed. The pool can store multiple connections to the # same site, and will be reused in MRU order. # # @api private # class Puppet::Network::HTTP::Pool FIFTEEN_SECONDS = 15 attr_reader :factory def initialize(keepalive_timeout = FIFTEEN_SECONDS) @pool = {} @factory = Puppet::Network::HTTP::Factory.new @keepalive_timeout = keepalive_timeout end def with_connection(site, verify, &block) reuse = true http = borrow(site, verify) begin + if http.use_ssl? && http.verify_mode != OpenSSL::SSL::VERIFY_PEER + reuse = false + end + yield http rescue => detail reuse = false - close_connection(site, http) raise detail ensure - release(site, http) if reuse + if reuse + release(site, http) + else + close_connection(site, http) + end end end def close @pool.each_pair do |site, sessions| sessions.each do |session| close_connection(site, session.connection) end end @pool.clear end # @api private def pool @pool end # Safely close a persistent connection. # # @api private def close_connection(site, http) Puppet.debug("Closing connection for #{site}") http.finish rescue => detail Puppet.log_exception(detail, "Failed to close connection for #{site}: #{detail}") end # Borrow and take ownership of a persistent connection. If a new # connection is created, it will be started prior to being returned. # # @api private def borrow(site, verify) @pool[site] = active_sessions(site) session = @pool[site].shift if session Puppet.debug("Using cached connection for #{site}") session.connection else http = @factory.create_connection(site) verify.setup_connection(http) Puppet.debug("Starting connection for #{site}") http.start http end end # Release a connection back into the pool. # # @api private def release(site, http) expiration = Time.now + @keepalive_timeout session = Puppet::Network::HTTP::Session.new(http, expiration) Puppet.debug("Caching connection for #{site}") sessions = @pool[site] if sessions sessions.unshift(session) else @pool[site] = [session] end end # Returns an Array of sessions whose connections are not expired. # # @api private def active_sessions(site) now = Time.now sessions = @pool[site] || [] sessions.select do |session| if session.expired?(now) close_connection(site, session.connection) false else true end end end end diff --git a/spec/unit/network/http/pool_spec.rb b/spec/unit/network/http/pool_spec.rb index 30776cebb..aef100953 100755 --- a/spec/unit/network/http/pool_spec.rb +++ b/spec/unit/network/http/pool_spec.rb @@ -1,233 +1,269 @@ #! /usr/bin/env ruby require 'spec_helper' +require 'openssl' require 'puppet/network/http' require 'puppet/network/http_pool' describe Puppet::Network::HTTP::Pool do before :each do Puppet::SSL::Key.indirection.terminus_class = :memory Puppet::SSL::CertificateRequest.indirection.terminus_class = :memory end let(:site) do Puppet::Network::HTTP::Site.new('https', 'rubygems.org', 443) end let(:different_site) do Puppet::Network::HTTP::Site.new('https', 'github.com', 443) end let(:verify) do stub('verify', :setup_connection => nil) end def create_pool Puppet::Network::HTTP::Pool.new end def create_pool_with_connections(site, *connections) pool = Puppet::Network::HTTP::Pool.new connections.each do |conn| pool.release(site, conn) end pool end def create_pool_with_expired_connections(site, *connections) # setting keepalive timeout to -1 ensures any newly added # connections have already expired pool = Puppet::Network::HTTP::Pool.new(-1) connections.each do |conn| pool.release(site, conn) end pool end def create_connection(site) - stub(site.addr, :started? => false, :start => nil, :finish => nil) + stub(site.addr, :started? => false, :start => nil, :finish => nil, :use_ssl? => true, :verify_mode => OpenSSL::SSL::VERIFY_PEER) end context 'when yielding a connection' do it 'yields a connection' do conn = create_connection(site) pool = create_pool_with_connections(site, conn) expect { |b| pool.with_connection(site, verify, &b) }.to yield_with_args(conn) end it 'returns the connection to the pool' do conn = create_connection(site) pool = create_pool pool.release(site, conn) pool.with_connection(site, verify) { |c| } expect(pool.pool[site].first.connection).to eq(conn) end it 'can yield multiple connections to the same site' do lru_conn = create_connection(site) mru_conn = create_connection(site) pool = create_pool_with_connections(site, lru_conn, mru_conn) pool.with_connection(site, verify) do |a| expect(a).to eq(mru_conn) pool.with_connection(site, verify) do |b| expect(b).to eq(lru_conn) end end end it 'propagates exceptions' do conn = create_connection(site) pool = create_pool pool.release(site, conn) expect { pool.with_connection(site, verify) do |c| raise IOError, 'connection reset' end }.to raise_error(IOError, 'connection reset') end it 'does not re-cache connections when an error occurs' do # we're not distinguishing between network errors that would # suggest we close the socket, and other errors conn = create_connection(site) pool = create_pool pool.release(site, conn) pool.expects(:release).with(site, conn).never pool.with_connection(site, verify) do |c| raise IOError, 'connection reset' end rescue nil end + + context 'when releasing connections' do + it 'releases HTTP connections' do + conn = create_connection(site) + conn.expects(:use_ssl?).returns(false) + + pool = create_pool_with_connections(site, conn) + pool.expects(:release).with(site, conn) + + pool.with_connection(site, verify) {|c| } + end + + it 'releases secure HTTPS connections' do + conn = create_connection(site) + conn.expects(:use_ssl?).returns(true) + conn.expects(:verify_mode).returns(OpenSSL::SSL::VERIFY_PEER) + + pool = create_pool_with_connections(site, conn) + pool.expects(:release).with(site, conn) + + pool.with_connection(site, verify) {|c| } + end + + it 'closes insecure HTTPS connections' do + conn = create_connection(site) + conn.expects(:use_ssl?).returns(true) + conn.expects(:verify_mode).returns(OpenSSL::SSL::VERIFY_NONE) + + pool = create_pool_with_connections(site, conn) + + pool.expects(:release).with(site, conn).never + + pool.with_connection(site, verify) {|c| } + end + end end context 'when borrowing' do it 'returns a new connection if the pool is empty' do conn = create_connection(site) pool = create_pool pool.factory.expects(:create_connection).with(site).returns(conn) expect(pool.borrow(site, verify)).to eq(conn) end it 'returns a matching connection' do conn = create_connection(site) pool = create_pool_with_connections(site, conn) pool.factory.expects(:create_connection).never expect(pool.borrow(site, verify)).to eq(conn) end it 'returns a new connection if there are no matching sites' do different_conn = create_connection(different_site) pool = create_pool_with_connections(different_site, different_conn) conn = create_connection(site) pool.factory.expects(:create_connection).with(site).returns(conn) expect(pool.borrow(site, verify)).to eq(conn) end it 'returns started connections' do conn = create_connection(site) conn.expects(:start) pool = create_pool pool.factory.expects(:create_connection).with(site).returns(conn) expect(pool.borrow(site, verify)).to eq(conn) end it "doesn't start a cached connection" do conn = create_connection(site) conn.expects(:start).never pool = create_pool_with_connections(site, conn) pool.borrow(site, verify) end it 'returns the most recently used connection from the pool' do least_recently_used = create_connection(site) most_recently_used = create_connection(site) pool = create_pool_with_connections(site, least_recently_used, most_recently_used) expect(pool.borrow(site, verify)).to eq(most_recently_used) end it 'finishes expired connections' do conn = create_connection(site) conn.expects(:finish) pool = create_pool_with_expired_connections(site, conn) pool.factory.expects(:create_connection => stub('conn', :start => nil)) pool.borrow(site, verify) end it 'logs an exception if it fails to close an expired connection' do Puppet.expects(:log_exception).with(is_a(IOError), "Failed to close connection for #{site}: read timeout") conn = create_connection(site) conn.expects(:finish).raises(IOError, 'read timeout') pool = create_pool_with_expired_connections(site, conn) pool.factory.expects(:create_connection => stub('open_conn', :start => nil)) pool.borrow(site, verify) end end context 'when releasing a connection' do it 'adds the connection to an empty pool' do conn = create_connection(site) pool = create_pool pool.release(site, conn) expect(pool.pool[site].first.connection).to eq(conn) end it 'adds the connection to a pool with a connection for the same site' do pool = create_pool pool.release(site, create_connection(site)) pool.release(site, create_connection(site)) expect(pool.pool[site].count).to eq(2) end it 'adds the connection to a pool with a connection for a different site' do pool = create_pool pool.release(site, create_connection(site)) pool.release(different_site, create_connection(different_site)) expect(pool.pool[site].count).to eq(1) expect(pool.pool[different_site].count).to eq(1) end end context 'when closing' do it 'clears the pool' do pool = create_pool pool.close expect(pool.pool).to be_empty end it 'closes all cached connections' do conn = create_connection(site) conn.expects(:finish) pool = create_pool_with_connections(site, conn) pool.close end end end