lib/ftw/agent.rb in ftw-0.0.16 vs lib/ftw/agent.rb in ftw-0.0.17

- old
+ new

@@ -62,18 +62,110 @@ @logger = Cabin::Channel.get configuration[REDIRECTION_LIMIT] = 20 @certificate_store = OpenSSL::X509::Store.new - @certificate_store.add_file("/etc/ssl/certs/ca-bundle.trust.crt") - @certificate_store.verify_callback = proc do |*args| - p :verify_callback => args - true + if File.readable?(OpenSSL::X509::DEFAULT_CERT_FILE) + @logger.debug("Adding default certificate file", + :path => OpenSSL::X509::DEFAULT_CERT_FILE) + @certificate_store.add_file(OpenSSL::X509::DEFAULT_CERT_FILE) end + # Handle the local user/app trust store as well. + if File.directory?(configuration[SSL_TRUST_STORE]) + # This is a directory, so use add_path + @logger.debug("Adding SSL_TRUST_STORE", + :path => configuration[SSL_TRUST_STORE]) + @certificate_store.add_path(configuration[SSL_TRUST_STORE]) + end + + # TODO(sissel): Add custom paths for ssl certs end # def initialize + # Verify a certificate. + # + # host => the host (string) + # port => the port (number) + # verified => true/false, was this cert verified by our certificate store? + # context => an OpenSSL::SSL::StoreContext + def certificate_verify(host, port, verified, context) + # Now verify the entire chain. + begin + @logger.debug("Verify peer via OpenSSL::X509::Store", + :verified => verified, :chain => context.chain.collect { |c| c.subject }, + :context => context, :depth => context.error_depth, + :error => context.error, :string => context.error_string) + # Untrusted certificate; prompt to accept if possible. + if !verified and STDOUT.tty? + # TODO(sissel): Factor this out into a verify callback where this + # happens to be the default. + + puts "Untrusted certificate found; here's what I know:" + puts " Why it's untrusted: (#{context.error}) #{context.error_string}" + puts " What you think it's for: #{host} (port #{port})" + cn = context.chain[0].subject.to_s.split("/").grep(/^CN=/).first.split("=",2).last rescue "<unknown, no CN?>" + puts " What it's actually for: #{cn}" + puts " Full chain:" + context.chain.each_with_index do |cert, i| + puts " Subject(#{i}): #{cert.subject}" + end + print "Trust? [(N)o/(Y)es/(P)ersistent] " + + system("stty raw") + answer = $stdin.getc.downcase + system("stty sane") + puts + + if ["y", "p"].include?(answer) + # TODO(sissel): Factor this out into Agent::Trust or somesuch + context.chain.each do |cert| + # For each certificate, add it to the in-process certificate store. + begin + @certificate_store.add_cert(cert) + rescue OpenSSL::X509::StoreError => e + # If the cert is already trusted, move along. + if e.to_s != "cert already in hash table" + raise # this is a real error, reraise. + end + end + + # TODO(sissel): Factor this out into Agent::Trust or somesuch + # For each certificate, if persistence is requested, write the cert to + # the configured ssl trust store (usually ~/.ftw/ssl-trust.db/) + if answer == "p" # persist this trusted cert + require "fileutils" + if !File.directory?(configuration[SSL_TRUST_STORE]) + FileUtils.mkdir_p(configuration[SSL_TRUST_STORE]) + end + + # openssl verify recommends the 'ca path' have files named by the + # hashed subject name. Turns out openssl really expects the + # hexadecimal version of this. + name = File.join(configuration[SSL_TRUST_STORE], cert.subject.hash.to_s(16)) + # Find a filename that doesn't exist. + num = 0 + num += 1 while File.exists?("#{name}.#{num}") + + # Write it out + path = "#{name}.#{num}" + @logger.info("Persisting certificate", :subject => cert.subject, :path => path) + File.write(path, cert.to_pem) + end # if answer == "p" + end # context.chain.each + return true + end # if answer was "y" or "p" + end # if !verified and stdout is a tty + + return verified + rescue => e + # We have to rescue all and emit because openssl verify_callback ignores + # exceptions silently + @logger.error(e) + return verified + end + end # def certificate_verify + # Define all the standard HTTP methods (Per RFC2616) # As an example, for "get" method, this will define these methods: # # * FTW::Agent#get(uri, options={}) # * FTW::Agent#get!(uri, options={}) @@ -178,19 +270,15 @@ # @param [FTW::Request] # @return [FTW::Response] the response for this request. def execute(request) # TODO(sissel): Make redirection-following optional, but default. - connection, error = connect(request.headers["Host"], request.port) + connection, error = connect(request.headers["Host"], request.port, request.protocol == "https") if !error.nil? p :error => error raise error end - - if request.protocol == "https" - connection.secure(:certificate_store => @certificate_store) - end response = request.execute(connection) redirects = 0 # Follow redirects while response.redirect? and response.headers.include?("Location") @@ -220,19 +308,16 @@ # should return (object, error), and if there's an error end @logger.debug("Redirecting", :location => response.headers["Location"]) request.use_uri(response.headers["Location"]) - connection, error = connect(request.headers["Host"], request.port) + connection, error = connect(request.headers["Host"], request.port, request.protocol == "https") # TODO(sissel): Do better error handling than raising. if !error.nil? p :error => error raise error end - if request.protocol == "https" - connection.secure(:certificate_store => @certificate_store) - end response = request.execute(connection) end # while being redirected # RFC 2616 section 9.4, HEAD requests MUST NOT have a message body. if request.method != "HEAD" @@ -256,14 +341,15 @@ end end end # def shutdown # Returns a FTW::Connection connected to this host:port. - def connect(host, port) + def connect(host, port, secure=false) address = "#{host}:#{port}" @logger.debug("Fetching from pool", :address => address) error = nil + connection = @pool.fetch(address) do @logger.info("New connection to #{address}") connection = FTW::Connection.new(address) error = connection.connect if !error.nil? @@ -280,9 +366,23 @@ return nil, error end @logger.debug("Pool fetched a connection", :connection => connection) connection.mark + + if secure + # Curry a certificate_verify callback for this connection. + verify_callback = proc do |verified, context| + begin + certificate_verify(host, port, verified, context) + rescue => e + @logger.error("Error in certificate_verify call", :exception => e) + end + end + connection.secure(:certificate_store => @certificate_store, + :verify_callback => verify_callback) + end # if secure + return connection, nil end # def connect # TODO(sissel): Implement methods for managing the certificate store # TODO(sissel): Implement methods for managing the cookie store