From f72ce3d983d5e10e29d63f532e967cefb3fd7062 Mon Sep 17 00:00:00 2001 From: David Librera Date: Sat, 11 Mar 2017 01:02:56 +0100 Subject: [PATCH 1/5] Set missing days from expire date for renew --- README.md | 33 +++++++++-------- lib/letsencrypt-rails-heroku/letsencrypt.rb | 12 +++++-- lib/tasks/letsencrypt.rake | 40 +++++++++++++-------- 3 files changed, 52 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 4cc988d..3549592 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,15 @@ which you should set. * `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. + * `ACME_EXPIRE_WINDOW`: Optional: certificate will be renewed when there + are less then this value from the expire date, default to 30 The gem itself will temporarily create additional environment variables during 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. + * `ACME_EXPIRE_ON`: The date when the certificate will expire ## Creating a Heroku token @@ -103,8 +106,20 @@ Use the output of that to set the token (`HEROKU_TOKEN`). ## Using for the first time -After deploying, run `heroku run rake letsencrypt:renew`. Ensure that the -output looks good: +After deploy, you can run + +on Rails 3.2, Rails 4 + +``` +bundle exec rake letsencrypt:renew +``` +on Rails 5+ + +``` +bundle exec rails letsencrypt:renew +``` + +Ensure that the output looks good: ``` $ heroku run rake letsencrypt:renew @@ -129,19 +144,7 @@ You can see these details by typing `heroku domains`. You should add a scheduled task on Heroku to renew the certificate. The scheduled task should be configured to run `rake letsencrypt:renew` as often as you want to renew your certificate. Letsencrypt certificates are valid for -90 days, but there's no harm renewing them more frequently than that. - -Heroku Scheduler only lets you run a task as infrequently as once a day, but -you don't want to renew your SSL certificate every day (you will hit -[the rate limit](https://letsencrypt.org/docs/rate-limits/)). You can make it -run less frequently using a shell control statement. For example to renew your -certificate on the 1st day of every month: - -``` -if [ "$(date +%d)" = 01 ]; then rake letsencrypt:renew; fi -``` - -Source: [blog.dbrgn.ch](https://blog.dbrgn.ch/2013/10/4/heroku-schedule-weekly-monthly-tasks/) +90 days, and usually it will renewed when there are less then 30 from the expire date. ## Security considerations diff --git a/lib/letsencrypt-rails-heroku/letsencrypt.rb b/lib/letsencrypt-rails-heroku/letsencrypt.rb index 4fa916a..23883bf 100644 --- a/lib/letsencrypt-rails-heroku/letsencrypt.rb +++ b/lib/letsencrypt-rails-heroku/letsencrypt.rb @@ -9,15 +9,15 @@ def self.configure end def self.challenge_configured? - configuration.acme_challenge_filename && + configuration.acme_challenge_filename && configuration.acme_challenge_filename.start_with?(".well-known/") && configuration.acme_challenge_file_content end class Configuration attr_accessor :heroku_token, :heroku_app, :acme_email, :acme_domain, - :acme_endpoint, :ssl_type - + :acme_endpoint, :ssl_type, :acme_expire_on, :acme_renew_window + # Not settable by user; part of the gem's behaviour. attr_reader :acme_challenge_filename, :acme_challenge_file_content @@ -30,10 +30,16 @@ def initialize @ssl_type = ENV["SSL_TYPE"] || 'sni' @acme_challenge_filename = ENV["ACME_CHALLENGE_FILENAME"] @acme_challenge_file_content = ENV["ACME_CHALLENGE_FILE_CONTENT"] + @acme_expire_on = Date.parse(ENV["ACME_EXPIRE_ON"] || Date.today.to_s) + @acme_renew_window = ENV["ACME_RENEW_WINDOW"] || 30 end def valid? heroku_token && heroku_app && acme_email end + + def need_renew? + (acme_expire_on - acme_renew_window) <= Time.now + end end end diff --git a/lib/tasks/letsencrypt.rake b/lib/tasks/letsencrypt.rake index 56db067..59990b0 100644 --- a/lib/tasks/letsencrypt.rake +++ b/lib/tasks/letsencrypt.rake @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- require 'open-uri' require 'openssl' require 'acme-client' @@ -7,9 +8,14 @@ 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? + unless Letsencrypt.configuration.valid? + 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." + end + unless Letsencrypt.configuration.need_renew? + puts "The current certificate will expire in more than #{Letsencrypt.configuration.acme_renew_window} days" + exit + end # Set up Heroku client heroku = PlatformAPI.connect_oauth Letsencrypt.configuration.heroku_token heroku_app = Letsencrypt.configuration.heroku_app @@ -44,9 +50,9 @@ namespace :letsencrypt do print "Setting config vars on Heroku..." heroku.config_var.update(heroku_app, { - 'ACME_CHALLENGE_FILENAME' => challenge.filename, - 'ACME_CHALLENGE_FILE_CONTENT' => challenge.file_content - }) + 'ACME_CHALLENGE_FILENAME' => challenge.filename, + 'ACME_CHALLENGE_FILE_CONTENT' => challenge.file_content + }) puts "Done!" # Wait for app to come up @@ -54,7 +60,7 @@ namespace :letsencrypt do # 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) @@ -105,9 +111,9 @@ namespace :letsencrypt do # 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 - }) + 'ACME_CHALLENGE_FILENAME' => nil, + 'ACME_CHALLENGE_FILE_CONTENT' => nil + }) # Create CSR csr = Acme::Client::CertificateRequest.new(names: domains) @@ -131,16 +137,16 @@ namespace :letsencrypt do 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 - }) + certificate_chain: certificate.fullchain_to_pem, + private_key: certificate.request.private_key.to_pem + }) 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 - }) + certificate_chain: certificate.fullchain_to_pem, + private_key: certificate.request.private_key.to_pem + }) puts "Done!" end rescue Excon::Error::UnprocessableEntity => e @@ -148,6 +154,10 @@ namespace :letsencrypt do raise Letsencrypt::Error::HerokuCertificateError, e.response.body end + heroku.config_var.update(heroku_app, { + 'ACME_EXPIRE_ON' => 90.days.since.strftime("%Y-%m-%d") + }) + end end From 1e8441b56d86a52def11e82247ecbe1d1cfa94c5 Mon Sep 17 00:00:00 2001 From: David Librera Date: Sat, 11 Mar 2017 01:08:04 +0100 Subject: [PATCH 2/5] Updated version --- VERSION | 2 +- letsencrypt-rails-heroku.gemspec | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/VERSION b/VERSION index 867e524..cb174d5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.0 \ No newline at end of file +1.2.1 \ No newline at end of file diff --git a/letsencrypt-rails-heroku.gemspec b/letsencrypt-rails-heroku.gemspec index 2d3954d..3efcd33 100644 --- a/letsencrypt-rails-heroku.gemspec +++ b/letsencrypt-rails-heroku.gemspec @@ -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.0 ruby lib +# stub: letsencrypt-rails-heroku 1.2.1 ruby lib Gem::Specification.new do |s| s.name = "letsencrypt-rails-heroku" - s.version = "1.2.0" + s.version = "1.2.1" s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= s.require_paths = ["lib"] s.authors = ["Pixie Labs", "David Somers", "Abigail McPhillips"] - s.date = "2017-03-03" + s.date = "2017-03-11" s.description = "This gem automatically handles creation, renewal, and applying SSL certificates from LetsEncrypt to your Heroku account." s.email = "team@pixielabs.io" s.extra_rdoc_files = [ From 01584ad719575fa7c47be8863fe1edf5b890da57 Mon Sep 17 00:00:00 2001 From: David Librera Date: Tue, 14 Mar 2017 23:06:39 +0100 Subject: [PATCH 3/5] Check for certificate directly without passing from ENV variables --- README.md | 17 ++++--- lib/letsencrypt-rails-heroku/letsencrypt.rb | 5 +- lib/tasks/letsencrypt.rake | 51 +++++++++++++-------- 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 3549592..8b4553b 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ redirects on their verification requests: ```ruby Rails.application.configure do # <...> - + config.middleware.insert_before ActionDispatch::SSL, Letsencrypt::Middleware # <...> @@ -68,7 +68,7 @@ which you should set. configured to answer to all these domains, because LetsEncrypt will make a request to verify ownership. - If you leave this blank, the gem will try and use the Heroku API to get a + 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 @@ -76,7 +76,7 @@ which you should set. * `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. - * `ACME_EXPIRE_WINDOW`: Optional: certificate will be renewed when there + * `ACME_EXPIRE_WINDOW`: Optional: certificate will be renewed when there are less then this value from the expire date, default to 30 The gem itself will temporarily create additional environment variables during @@ -84,7 +84,6 @@ 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. - * `ACME_EXPIRE_ON`: The date when the certificate will expire ## Creating a Heroku token @@ -130,7 +129,7 @@ Setting config vars on Heroku...Done! Giving config vars time to change...Done! Testing filename works (to bring up app)...done! Adding new certificate...Done! -$ +$ ``` If this is the first time you have used an SNI-based SSL certificate on your @@ -156,7 +155,7 @@ following security considerations: token to impersonate the account it was created with when accessing the Heroku API. This is important if your account has access to other apps that your collaborators don’t. Additionally, if your application environment was - leaked this would give the attacker access to the Heroku API as your user account. + leaked this would give the attacker access to the Heroku API as your user account. [More information about Heroku’s API and oAuth](https://devcenter.heroku.com/articles/oauth#direct-authorization). You should create the API token from a suitably locked-down account. @@ -168,7 +167,7 @@ following security considerations: The gem performs some cursory checks to make sure the filename is roughly what is expected to try and mitigate this. - + ## Troubleshooting ### Common name invalid errors (security certificate is from *.herokuapp.com) @@ -183,7 +182,7 @@ Your domain is still configured as a CNAME or ALIAS to `your-app.herokuapp.com`. - Stop using a fork of the `platform-api` gem once it supports the SNI endpoint API calls. [See issue #49 of the platform-api gem](https://github.com/heroku/platform-api/issues/49). -- Provide instructions for running the gem decoupled from the app it is +- Provide instructions for running the gem decoupled from the app it is securing, for the paranoid. ## Contributing @@ -200,7 +199,7 @@ Your domain is still configured as a CNAME or ALIAS to `your-app.herokuapp.com`. - Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it. - + ### Generating a new release 1. Bump the version: `rake version:bump:{major,minor,patch}`. diff --git a/lib/letsencrypt-rails-heroku/letsencrypt.rb b/lib/letsencrypt-rails-heroku/letsencrypt.rb index 23883bf..08b6790 100644 --- a/lib/letsencrypt-rails-heroku/letsencrypt.rb +++ b/lib/letsencrypt-rails-heroku/letsencrypt.rb @@ -16,7 +16,7 @@ def self.challenge_configured? class Configuration attr_accessor :heroku_token, :heroku_app, :acme_email, :acme_domain, - :acme_endpoint, :ssl_type, :acme_expire_on, :acme_renew_window + :acme_endpoint, :ssl_type, :acme_renew_window # Not settable by user; part of the gem's behaviour. attr_reader :acme_challenge_filename, :acme_challenge_file_content @@ -30,8 +30,7 @@ def initialize @ssl_type = ENV["SSL_TYPE"] || 'sni' @acme_challenge_filename = ENV["ACME_CHALLENGE_FILENAME"] @acme_challenge_file_content = ENV["ACME_CHALLENGE_FILE_CONTENT"] - @acme_expire_on = Date.parse(ENV["ACME_EXPIRE_ON"] || Date.today.to_s) - @acme_renew_window = ENV["ACME_RENEW_WINDOW"] || 30 + @acme_renew_window = (ENV["ACME_RENEW_WINDOW"] || 30).to_i end def valid? diff --git a/lib/tasks/letsencrypt.rake b/lib/tasks/letsencrypt.rake index 59990b0..1352b81 100644 --- a/lib/tasks/letsencrypt.rake +++ b/lib/tasks/letsencrypt.rake @@ -12,14 +12,42 @@ namespace :letsencrypt do 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." end - unless Letsencrypt.configuration.need_renew? - puts "The current certificate will expire in more than #{Letsencrypt.configuration.acme_renew_window} days" - exit - end # Set up Heroku client heroku = PlatformAPI.connect_oauth Letsencrypt.configuration.heroku_token heroku_app = Letsencrypt.configuration.heroku_app + 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 + + print "Verify if a valid certificate already exists ... " + uri = URI.parse("https://#{domains.first}") + http = Net::HTTP.new(uri.host,uri.port) + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + begin + http.start do |h| + @cert = h.peer_cert + end + rescue OpenSSL::SSL::SSLError => e + # in this case a certificate doesn't exists, so a do nothing + puts "No valid certicates found!" + else + print "Found " + # if a valid certificate exists check if is still expiring + if @cert.not_after >= Date.today + Letsencrypt.configuration.acme_renew_window.days + puts " but certificate is not due to expire. Skipping renew!" + exit 0 + else + puts " but is due to expire!" + end + end + # Create a private key print "Creating account key..." private_key = OpenSSL::PKey::RSA.new(4096) @@ -33,15 +61,6 @@ namespace :letsencrypt do registration.agree_terms puts "Done!" - 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 - domains.each do |domain| puts "Performing verification for #{domain}:" @@ -153,11 +172,5 @@ namespace :letsencrypt do warn "Error adding certificate to Heroku. Response from Heroku’s API follows:" raise Letsencrypt::Error::HerokuCertificateError, e.response.body end - - heroku.config_var.update(heroku_app, { - 'ACME_EXPIRE_ON' => 90.days.since.strftime("%Y-%m-%d") - }) - end - end From d4623eff3df87aa34f3cffbd50d831322437d633 Mon Sep 17 00:00:00 2001 From: David Librera Date: Wed, 15 Mar 2017 11:53:41 +0100 Subject: [PATCH 4/5] Expire window of 30 days and possibility of force renew --- README.md | 11 ++++- lib/letsencrypt-rails-heroku/letsencrypt.rb | 3 +- lib/tasks/letsencrypt.rake | 50 ++++++++++++--------- 3 files changed, 40 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 8b4553b..376f747 100644 --- a/README.md +++ b/README.md @@ -110,12 +110,12 @@ After deploy, you can run on Rails 3.2, Rails 4 ``` -bundle exec rake letsencrypt:renew +heroku run bundle exec rake letsencrypt:renew ``` on Rails 5+ ``` -bundle exec rails letsencrypt:renew +heroku run bundle exec rails letsencrypt:renew ``` Ensure that the output looks good: @@ -138,6 +138,13 @@ app, you may need to alter your DNS configuration as per You can see these details by typing `heroku domains`. +Sometime you have to add a new domain to the heroku application. In that case you may want +to force generation of a new certificate. In order to perform this, run + +``` +heroku run bundle exec rails 'letsencrypt:renew[:force_renew]' +``` + ## Adding a scheduled task You should add a scheduled task on Heroku to renew the certificate. The diff --git a/lib/letsencrypt-rails-heroku/letsencrypt.rb b/lib/letsencrypt-rails-heroku/letsencrypt.rb index 08b6790..417bb8e 100644 --- a/lib/letsencrypt-rails-heroku/letsencrypt.rb +++ b/lib/letsencrypt-rails-heroku/letsencrypt.rb @@ -16,7 +16,7 @@ def self.challenge_configured? class Configuration attr_accessor :heroku_token, :heroku_app, :acme_email, :acme_domain, - :acme_endpoint, :ssl_type, :acme_renew_window + :acme_endpoint, :ssl_type, :acme_renew_window, :acme_force_renew # Not settable by user; part of the gem's behaviour. attr_reader :acme_challenge_filename, :acme_challenge_file_content @@ -30,7 +30,6 @@ def initialize @ssl_type = ENV["SSL_TYPE"] || 'sni' @acme_challenge_filename = ENV["ACME_CHALLENGE_FILENAME"] @acme_challenge_file_content = ENV["ACME_CHALLENGE_FILE_CONTENT"] - @acme_renew_window = (ENV["ACME_RENEW_WINDOW"] || 30).to_i end def valid? diff --git a/lib/tasks/letsencrypt.rake b/lib/tasks/letsencrypt.rake index 1352b81..ab3b057 100644 --- a/lib/tasks/letsencrypt.rake +++ b/lib/tasks/letsencrypt.rake @@ -7,7 +7,9 @@ require 'platform-api' namespace :letsencrypt do desc 'Renew your LetsEncrypt certificate' - task :renew do + task :renew, [:policy] do |_t, attributes| + policy = attributes.fetch(:policy, :keep_until_expiring).to_sym + unless Letsencrypt.configuration.valid? 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." end @@ -25,29 +27,37 @@ namespace :letsencrypt do puts "Using #{domains.length} configured Heroku domain(s) for this app..." end - print "Verify if a valid certificate already exists ... " - uri = URI.parse("https://#{domains.first}") - http = Net::HTTP.new(uri.host,uri.port) - http.use_ssl = true - http.verify_mode = OpenSSL::SSL::VERIFY_NONE - begin - http.start do |h| - @cert = h.peer_cert - end - rescue OpenSSL::SSL::SSLError => e - # in this case a certificate doesn't exists, so a do nothing - puts "No valid certicates found!" - else - print "Found " - # if a valid certificate exists check if is still expiring - if @cert.not_after >= Date.today + Letsencrypt.configuration.acme_renew_window.days - puts " but certificate is not due to expire. Skipping renew!" - exit 0 + if policy == :keep_until_expiring + print "Verify if a valid certificate already exists ... " + uri = URI.parse("https://#{domains.first}") + http = Net::HTTP.new(uri.host,uri.port) + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + begin + http.start do |h| + @cert = h.peer_cert + end + rescue OpenSSL::SSL::SSLError => e + # in this case a certificate doesn't exists, so a do nothing + puts "No valid certicates found!" else - puts " but is due to expire!" + print "Found " + # if a valid certificate exists check if is still expiring + if @cert.not_after >= Date.today + 30.days + puts " but certificate is not due to expire. Skipping renew!" + exit 0 + else + puts " but is due to expire!" + end end + elsif policy == :force_renew + puts "Forcing renew!" + else + puts "Renew policy not valid" + exit end + # Create a private key print "Creating account key..." private_key = OpenSSL::PKey::RSA.new(4096) From 17fe339c381990ae17fba2ac3c36e9bb6f3f92f0 Mon Sep 17 00:00:00 2001 From: David Librera Date: Wed, 15 Mar 2017 12:02:52 +0100 Subject: [PATCH 5/5] Updated README --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 376f747..e424fd7 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,6 @@ which you should set. * `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. - * `ACME_EXPIRE_WINDOW`: Optional: certificate will be renewed when there - are less then this value from the expire date, default to 30 The gem itself will temporarily create additional environment variables during the challenge / validation process: