require 'open-uri' require 'openssl' require 'acme-client' require 'platform-api'

namespace :letsencrypt do

desc 'Renew your LetsEncrypt certificate'
task :renew do
  # Check configuration looks OK
  abort "letsencrypt-rails-heroku is configured incorrectly. Are you missing an environment variable or other configuration? You should have heroku_token, heroku_app, acme_email and acme_terms_agreed configured either via a `Letsencrypt.configure` block in an initializer or as environment variables." unless Letsencrypt.configuration.valid?

  # Set up Heroku client
  heroku = PlatformAPI.connect_oauth Letsencrypt.configuration.heroku_token
  heroku_app = Letsencrypt.configuration.heroku_app

  if Letsencrypt.registered?
    puts "Using existing registration details"
    private_key = OpenSSL::PKey::RSA.new(Letsencrypt.configuration.acme_private_key)
    key_id = Letsencrypt.configuration.acme_key_id
  else
    # Create a private key
    print "Creating account key..."
    private_key = OpenSSL::PKey::RSA.new(4096)
    puts "Done!"

    client = Acme::Client.new(private_key: private_key,
                              directory: Letsencrypt.configuration.acme_directory,
                              connection_options: { 
                                request: { 
                                  open_timeout: 5,
                                  timeout: 5
                                }
                              })

    print "Registering with LetsEncrypt..."
    account = client.new_account(contact: "mailto:#{Letsencrypt.configuration.acme_email}",
                                 terms_of_service_agreed: true)

    key_id = account.kid
    puts "Done!"
    print "Saving account details as configuration variables..."
    heroku.config_var.update(heroku_app,
                             'ACME_PRIVATE_KEY' => private_key.to_pem,
                             'ACME_KEY_ID' => account.kid)
    puts "Done!"
  end

  # Make a new Acme::Client with whichever private_key & key_id we ended up with.
  client = Acme::Client.new(private_key: private_key,
                            directory: Letsencrypt.configuration.acme_directory,
                            kid: key_id)

  domains = []
  if Letsencrypt.configuration.acme_domain
    puts "Using ACME_DOMAIN configuration variable..."
    domains = Letsencrypt.configuration.acme_domain.split(',').map(&:strip)
  else
    domains = heroku.domain.list(heroku_app).map{|domain| domain['hostname']}
    puts "Using #{domains.length} configured Heroku domain(s) for this app..."
  end

  order = client.new_order(identifiers: domains)

  order.authorizations.each do |authorization|
    puts "Performing verification for #{authorization.domain}:"

    challenge = authorization.http

    raise Letsencrypt::Error::NoHTTPChallengeError, "No HTTP challenge was given by Let's Encrypt for #{authorization.domain}, and letsencrypt-rails-heroku does not currently support other challenge types." unless challenge

    print "Setting config vars on Heroku..."
    heroku.config_var.update(heroku_app, {
      'ACME_CHALLENGE_FILENAME' => challenge.filename,
      'ACME_CHALLENGE_FILE_CONTENT' => challenge.file_content
    })
    puts "Done!"

    # Wait for app to come up
    print "Testing filename works (to bring up app)..."

    # Get the domain name from Heroku
    hostname = heroku.domain.list(heroku_app).first['hostname']

    # Wait at least a little bit, otherwise the first request will almost always fail.
    sleep(2)

    start_time = Time.now

    begin
      open("http://#{hostname}/#{challenge.filename}").read
    rescue OpenSSL::SSL::SSLError, OpenURI::HTTPError, RuntimeError => e
      raise e if e.is_a?(RuntimeError) && !e.message.include?("redirection forbidden")
      if Time.now - start_time <= 60
        puts "Error fetching challenge, retrying... #{e.message}"
        sleep(5)
        retry
      else
        failure_message = "Error waiting for response from http://#{hostname}/#{challenge.filename}, Error: #{e.message}"
        raise Letsencrypt::Error::ChallengeUrlError, failure_message
      end
    end

    puts "Done!"

    print "Giving LetsEncrypt some time to verify..."
    # Once you are ready to serve the confirmation request you can proceed.
    challenge.request_validation

    start_time = Time.now
    while challenge.status == 'pending'
      if Time.now - start_time >= 30
        failure_message = "Failed - timed out waiting for challenge verification."
        raise Letsencrypt::Error::VerificationTimeoutError, failure_message
      end
      sleep(2)
      challenge.reload
    end

    puts "Done!"

    unless challenge.status == 'valid'
      puts "Problem verifying challenge."
      failure_message = "Status: #{challenge.status}, Error: #{challenge.error}"
      raise Letsencrypt::Error::VerificationError, failure_message
    end

    puts ""
  end

  # Unset temporary config vars. We don't care about waiting for this to
  # restart
  heroku.config_var.update(heroku_app, {
    'ACME_CHALLENGE_FILENAME' => nil,
    'ACME_CHALLENGE_FILE_CONTENT' => nil
  })

  # Create CSR
  csr_private_key = OpenSSL::PKey::RSA.new 4096
  csr = Acme::Client::CertificateRequest.new(names: domains,
                                             private_key: csr_private_key)

  print "Asking LetsEncrypt to finalize our certificate order..."
  # Get certificate
  order.finalize(csr: csr)

  # Wait for order to process
  start_time = Time.now
  while order.status == 'processing'
    if Time.now - start_time >= 30
      failure_message = "Failed - timed out waiting for order finalization"
      raise Letsencrypt::Error::FinalizationTimeoutError, failure_message
    end
    sleep(2)
    order.reload
  end

  puts "Done!"

  unless order.status == 'valid'
    failure_message = "Problem finalizing order - status: #{order.status}"
    raise Letsencrypt::Error::FinalizationError, failure_message
  end

  certificate = order.certificate # => PEM-formatted certificate

  # Send certificates to Heroku via API

  endpoint = case Letsencrypt.configuration.ssl_type
             when 'sni'
               heroku.sni_endpoint
             when 'endpoint'
               heroku.ssl_endpoint
             end

  certificate_info = {
    certificate_chain: certificate,
    private_key: csr_private_key.to_pem
  }

  # Fetch existing certificate from Heroku (if any). We just use the first
  # one; if someone has more than one, they're probably not actually using
  # this gem. Could also be an error?
  existing_certificate = endpoint.list(heroku_app)[0]

  begin
    if existing_certificate
      print "Updating existing certificate #{existing_certificate['name']}..."
      endpoint.update(heroku_app, existing_certificate['name'], certificate_info)
      puts "Done!"
    else
      print "Adding new certificate..."
      endpoint.create(heroku_app, certificate_info)
      puts "Done!"
    end
  rescue Excon::Error::UnprocessableEntity => e
    warn "Error adding certificate to Heroku. Response from Heroku’s API follows:"
    raise Letsencrypt::Error::HerokuCertificateError, e.response.body
  rescue Excon::Error::Forbidden => e
    warn "Error adding certificate to Heroku, expected an OK response status, got a '403 Forbidden'. Response follows:"
    puts e.response.body
    # Re-raise for now.
    raise e
  end

end

end