Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ACME v2 & saving registration #62

Merged
merged 8 commits into from
May 17, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
# 2.0.0 (unreleased)

Thanks to [@mashedkeyboard](https://github.com/mashedkeyboard) for their
work on ACME v2, saving registration, and DNS-based validation.

- *BREAKING* You must indicate your acceptance of Let's Encrypt's terms
and conditions by setting the `ACME_TERMS_AGREED` configuration variable.
- *BREAKING* Removed `ACME_ENDPOINT` environment variable reference. We never
documented that we support alternative endpoints, and we never tested it,
and the gem is called *letsencrypt*-rails-heroku, so let's not pretend.
Please get in touch if you were using this configuration variable, we'd
like to hear from you! Psst; you can still set `acme_directory` when
configuring the gem in an initializer.
- Use version 2 of the ACME API, paving the way for DNS validation.
- Save private key & key ID variables after registering with Let's Encrypt.
This will create two new permanent environment variables, `ACME_PRIVATE_KEY`
and `ACME_KEY_ID`.

# 1.2.1

- Update `rack` and `nokogiri` dependencies due to reported vulnerabilities
Expand Down
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
source "https://rubygems.org"

gem 'acme-client', '~> 0.4.0'
gem 'acme-client', '~> 2.0'
gem 'platform-api', '~> 2.2'

group :development do
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
GEM
remote: https://rubygems.org/
specs:
acme-client (0.4.1)
acme-client (2.0.3)
faraday (~> 0.9, >= 0.9.1)
activesupport (5.0.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
Expand Down Expand Up @@ -86,7 +86,7 @@ PLATFORMS
ruby

DEPENDENCIES
acme-client (~> 0.4.0)
acme-client (~> 2.0)
bundler (~> 1.0)
juwelier (~> 2.1.0)
platform-api (~> 2.2)
Expand Down
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,24 @@ end

## Configuring

By default the gem will try to use the following set of configuration variables,
which you should set.
By default the gem will try to use the following set of configuration
variables. You must set:

* `ACME_EMAIL`: Your email address, should be valid.
* `ACME_TERMS_AGREED`: Existence of this environment variable represents your
agreement to [Let's Encrypt's terms of service](https://letsencrypt.org/repository/).
* `HEROKU_TOKEN`: An API token for this app. See below
* `HEROKU_APP`: Name of Heroku app e.g. bottomless-cavern-7173

You can also set:

* `ACME_DOMAIN`: Comma separated list of domains for which you want
certificates, e.g. `example.com,www.example.com`. Your Heroku app should be
configured to answer to all these domains, because LetsEncrypt will make a
configured to answer to all these domains, because Let's Encrypt will make a
request to verify ownership.

If you leave this blank, the gem will try and use the Heroku API to get a
list of configured domains for your app, and verify all of them.
* `ACME_EMAIL`: Your email address, should be valid.
* `HEROKU_TOKEN`: An API token for this app. See below
* `HEROKU_APP`: Name of Heroku app e.g. bottomless-cavern-7173
* `SSL_TYPE`: Optional: One of `sni` or `endpoint`, defaults to `sni`.
`endpoint` requires your app to have an
[SSL endpoint addon](https://elements.heroku.com/addons/ssl) configured.
Expand All @@ -84,6 +89,14 @@ the challenge / validation process:
* `ACME_CHALLENGE_FILENAME`: The path of the file LetsEncrypt will request.
* `ACME_CHALLENGE_FILE_CONTENT`: The content of that challenge file.

It will also create two permanent environment variables after the first run:

* `ACME_PRIVATE_KEY`: Private key used to create requests for certificates.
* `ACME_KEY_ID`: Key ID assigned to your private key by Let's Encrypt.

If you remove these, a new account will be created and new environment
variables will be set.

## Creating a Heroku token

Use the `heroku-oauth` toolbelt plugin to generate an access token suitable
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.2.1
2.0.0
12 changes: 6 additions & 6 deletions letsencrypt-rails-heroku.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@
# DO NOT EDIT THIS FILE DIRECTLY
# Instead, edit Juwelier::Tasks in Rakefile, and run 'rake gemspec'
# -*- encoding: utf-8 -*-
# stub: letsencrypt-rails-heroku 1.2.1 ruby lib
# stub: letsencrypt-rails-heroku 2.0.0 ruby lib

Gem::Specification.new do |s|
s.name = "letsencrypt-rails-heroku".freeze
s.version = "1.2.1"
s.version = "2.0.0"

s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
s.require_paths = ["lib".freeze]
s.authors = ["Pixie Labs".freeze, "David Somers".freeze, "Abigail McPhillips".freeze]
s.date = "2019-04-12"
s.date = "2019-05-10"
s.description = "This gem automatically handles creation, renewal, and applying SSL certificates from LetsEncrypt to your Heroku account.".freeze
s.email = "team@pixielabs.io".freeze
s.extra_rdoc_files = [
Expand Down Expand Up @@ -44,15 +44,15 @@ Gem::Specification.new do |s|
s.specification_version = 4

if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
s.add_runtime_dependency(%q<acme-client>.freeze, ["~> 0.4.0"])
s.add_runtime_dependency(%q<acme-client>.freeze, ["~> 2.0"])
s.add_runtime_dependency(%q<platform-api>.freeze, ["~> 2.2"])
s.add_development_dependency(%q<shoulda>.freeze, [">= 0"])
s.add_development_dependency(%q<rdoc>.freeze, ["~> 3.12"])
s.add_development_dependency(%q<bundler>.freeze, ["~> 1.0"])
s.add_development_dependency(%q<juwelier>.freeze, ["~> 2.1.0"])
s.add_development_dependency(%q<simplecov>.freeze, [">= 0"])
else
s.add_dependency(%q<acme-client>.freeze, ["~> 0.4.0"])
s.add_dependency(%q<acme-client>.freeze, ["~> 2.0"])
s.add_dependency(%q<platform-api>.freeze, ["~> 2.2"])
s.add_dependency(%q<shoulda>.freeze, [">= 0"])
s.add_dependency(%q<rdoc>.freeze, ["~> 3.12"])
Expand All @@ -61,7 +61,7 @@ Gem::Specification.new do |s|
s.add_dependency(%q<simplecov>.freeze, [">= 0"])
end
else
s.add_dependency(%q<acme-client>.freeze, ["~> 0.4.0"])
s.add_dependency(%q<acme-client>.freeze, ["~> 2.0"])
s.add_dependency(%q<platform-api>.freeze, ["~> 2.2"])
s.add_dependency(%q<shoulda>.freeze, [">= 0"])
s.add_dependency(%q<rdoc>.freeze, ["~> 3.12"])
Expand Down
12 changes: 8 additions & 4 deletions lib/letsencrypt-rails-heroku/exceptions.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
module Letsencrypt
module Error
# Exception raised when LetsEncrypt encounters an issue verifying the challenge.
# LetsEncrypt encountered an issue verifying the challenge.
class VerificationError < StandardError; end
# Exception raised when challenge URL is not available.
# LetsEncrypt encountered an issue finalizing the order.
class FinalizationError < StandardError; end
# Challenge URL is not available.
class ChallengeUrlError < StandardError; end
# Exception raised on timeout of challenge verification.
# Domain verification took longer than we'd like.
class VerificationTimeoutError < StandardError; end
# Exception raised when an error occurs adding the certificate to Heroku.
# Order finalization took longer than we'd like.
class FinalizationTimeoutError < StandardError; end
# Error adding the certificate to Heroku.
class HerokuCertificateError < StandardError; end
end
end
18 changes: 14 additions & 4 deletions lib/letsencrypt-rails-heroku/letsencrypt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,36 @@ def self.challenge_configured?
configuration.acme_challenge_file_content
end

def self.registered?
configuration.acme_private_key && configuration.acme_key_id
end

class Configuration
attr_accessor :heroku_token, :heroku_app, :acme_email, :acme_domain,
:acme_endpoint, :ssl_type
:acme_directory, :ssl_type, :acme_terms_agreed

# Not settable by user; part of the gem's behaviour.
attr_reader :acme_challenge_filename, :acme_challenge_file_content
attr_reader :acme_challenge_filename, :acme_challenge_file_content,
:acme_private_key, :acme_key_id

def initialize
@heroku_token = ENV["HEROKU_TOKEN"]
@heroku_app = ENV["HEROKU_APP"]
@acme_email = ENV["ACME_EMAIL"]
@acme_domain = ENV["ACME_DOMAIN"]
@acme_endpoint = ENV["ACME_ENDPOINT"] || 'https://acme-v01.api.letsencrypt.org/'
@acme_directory = 'https://acme-v02.api.letsencrypt.org/directory'
@acme_terms_agreed = ENV["ACME_TERMS_AGREED"]
@ssl_type = ENV["SSL_TYPE"] || 'sni'

@acme_challenge_filename = ENV["ACME_CHALLENGE_FILENAME"]
@acme_challenge_file_content = ENV["ACME_CHALLENGE_FILE_CONTENT"]

@acme_private_key = ENV["ACME_PRIVATE_KEY"]
@acme_key_id = ENV["ACME_KEY_ID"]
end

def valid?
heroku_token && heroku_app && acme_email
heroku_token && heroku_app && acme_email && acme_terms_agreed
end
end
end
118 changes: 83 additions & 35 deletions lib/tasks/letsencrypt.rake
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,48 @@ 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 a heroku_token, heroku_app and acme_email configured either via a `Letsencrypt.configure` block in an initializer or as environment variables." unless Letsencrypt.configuration.valid?
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?
jalada marked this conversation as resolved.
Show resolved Hide resolved

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

# Create a private key
print "Creating account key..."
private_key = OpenSSL::PKey::RSA.new(4096)
puts "Done!"
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, endpoint: Letsencrypt.configuration.acme_endpoint, connection_options: { request: { open_timeout: 5, timeout: 5 } })
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..."
registration = client.register(contact: "mailto:#{Letsencrypt.configuration.acme_email}")
print "Registering with LetsEncrypt..."
account = client.new_account(contact: "mailto:#{Letsencrypt.configuration.acme_email}",
terms_of_service_agreed: true)

registration.agree_terms
puts "Done!"
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,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a comment here; why are we remaking the client?

directory: Letsencrypt.configuration.acme_directory,
kid: key_id)

domains = []
if Letsencrypt.configuration.acme_domain
Expand All @@ -36,11 +60,12 @@ namespace :letsencrypt do
puts "Using #{domains.length} configured Heroku domain(s) for this app..."
end

domains.each do |domain|
puts "Performing verification for #{domain}:"
order = client.new_order(identifiers: domains)

authorization = client.authorize(domain: domain)
challenge = authorization.http01
order.authorizations.each do |authorization|
puts "Performing verification for #{authorization.domain}:"

challenge = authorization.http

print "Setting config vars on Heroku..."
heroku.config_var.update(heroku_app, {
Expand Down Expand Up @@ -78,24 +103,23 @@ namespace :letsencrypt do

print "Giving LetsEncrypt some time to verify..."
# Once you are ready to serve the confirmation request you can proceed.
challenge.request_verification # => true
challenge.verify_status # => 'pending'
challenge.request_validation

start_time = Time.now

while challenge.verify_status == 'pending'
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(3)
sleep(2)
challenge.reload
end

puts "Done!"

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

Expand All @@ -110,10 +134,33 @@ namespace :letsencrypt do
})

# Create CSR
csr = Acme::Client::CertificateRequest.new(names: domains)
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
certificate = client.new_certificate(csr) # => #<Acme::Client::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

Expand All @@ -124,23 +171,24 @@ namespace :letsencrypt do
heroku.ssl_endpoint
end

# First check for existing certificates:
certificates = endpoint.list(heroku_app)
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 certificates.any?
print "Updating existing certificate #{certificates[0]['name']}..."
endpoint.update(heroku_app, certificates[0]['name'], {
certificate_chain: certificate.fullchain_to_pem,
private_key: certificate.request.private_key.to_pem
})
if existing_certificate
print "Updating existing certificate #{existing_certificate['name']}..."
endpoint.update(heroku_app, existing_certificates['name'], certificate_info)
puts "Done!"
else
print "Adding new certificate..."
endpoint.create(heroku_app, {
certificate_chain: certificate.fullchain_to_pem,
private_key: certificate.request.private_key.to_pem
})
endpoint.create(heroku_app, certificate_info)
puts "Done!"
end
rescue Excon::Error::UnprocessableEntity => e
Expand Down