├── .document ├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── VERSION ├── letsencrypt-rails-heroku.gemspec └── lib ├── letsencrypt-rails-heroku.rb ├── letsencrypt-rails-heroku ├── exceptions.rb ├── letsencrypt.rb ├── middleware.rb └── railtie.rb └── tasks └── letsencrypt.rake /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | bin/* 3 | - 4 | features/**/*.feature 5 | LICENSE.txt 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # rcov generated 2 | coverage 3 | coverage.data 4 | 5 | # rdoc generated 6 | rdoc 7 | 8 | # yard generated 9 | doc 10 | .yardoc 11 | 12 | # bundler 13 | .bundle 14 | 15 | # juwelier generated 16 | pkg 17 | 18 | # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: 19 | # 20 | # * Create a file at ~/.gitignore 21 | # * Include files you want ignored 22 | # * Run: git config --global core.excludesfile ~/.gitignore 23 | # 24 | # After doing this, these files will be ignored in all your git projects, 25 | # saving you from having to 'pollute' every project you touch with them 26 | # 27 | # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line) 28 | # 29 | # For MacOS: 30 | # 31 | #.DS_Store 32 | 33 | # For TextMate 34 | #*.tmproj 35 | #tmtags 36 | 37 | # For emacs: 38 | #*~ 39 | #\#* 40 | #.\#* 41 | 42 | # For vim: 43 | #*.swp 44 | 45 | # For redcar: 46 | #.redcar 47 | 48 | # For rubinius: 49 | #*.rbc 50 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.0.3 2 | 3 | - Raise an error when the HTTP challenge is nil (#71). Thanks 4 | [@mashedkeyboard](https://github.com/mashedkeyboard)! 5 | - Return the response body on a 403 Forbidden from Heroku, not that it's 6 | that useful. 7 | - Relax `platform-api` version requirements, as it works OK with v3 too. 8 | 9 | # 2.0.2 10 | 11 | - Include OpenSSL::SSL::SSLError as a valid error we retry for when waiting 12 | for the app to come back up. 13 | 14 | # 2.0.1 15 | 16 | - Fixed a typo that broke renewals with existing certificates. 17 | 18 | # 2.0.0 19 | 20 | Thanks to [@mashedkeyboard](https://github.com/mashedkeyboard) for their 21 | work on ACME v2, saving registration, and DNS-based validation. 22 | 23 | - *BREAKING* You must indicate your acceptance of Let's Encrypt's terms 24 | and conditions by setting the `ACME_TERMS_AGREED` configuration variable. 25 | - *BREAKING* Removed `ACME_ENDPOINT` environment variable reference. We never 26 | documented that we support alternative endpoints, and we never tested it, 27 | and the gem is called *letsencrypt*-rails-heroku, so let's not pretend. 28 | Please get in touch if you were using this configuration variable, we'd 29 | like to hear from you! Psst; you can still set `acme_directory` when 30 | configuring the gem in an initializer. 31 | - Use version 2 of the ACME API, paving the way for DNS validation. 32 | - Save private key & key ID variables after registering with Let's Encrypt. 33 | This will create two new permanent environment variables, `ACME_PRIVATE_KEY` 34 | and `ACME_KEY_ID`. 35 | 36 | # 1.2.1 37 | 38 | - Update `rack` and `nokogiri` dependencies due to reported vulnerabilities 39 | in those libraries. Note that these don't affect letsencrypt-rails-heroku 40 | directly. 41 | [CVE-2018-16471](https://nvd.nist.gov/vuln/detail/CVE-2018-16471), 42 | [CVE-2016-4658](https://nvd.nist.gov/vuln/detail/CVE-2016-4658), 43 | [CVE-2017-5029](https://nvd.nist.gov/vuln/detail/CVE-2017-5029), 44 | [CVE-2018-14404](https://nvd.nist.gov/vuln/detail/CVE-2018-14404), 45 | [CVE-2017-18258](https://nvd.nist.gov/vuln/detail/CVE-2017-18258), 46 | [CVE-2017-9050](https://nvd.nist.gov/vuln/detail/CVE-2017-9050). 47 | 48 | - Stop using [jalada/platform-api](https://github.com/jalada/platform-api) 49 | because the newer version of the official version supports the API endpoints 50 | we need now. 51 | 52 | # 1.2.0 53 | 54 | - Support SSL Endpoint configuration, as well as the default SNI. 55 | 56 | # 1.1.3 57 | 58 | - 1.1.1 wasn't a correct fix for catching redirects during polling, this 59 | should work better! 60 | 61 | # 1.1.2 62 | 63 | - Increase challenge file poll wait time to 60 seconds to match 64 | [Heroku's limit](https://devcenter.heroku.com/articles/limits). 65 | 66 | # 1.1.1 67 | 68 | - Capture `OpenURI::HTTPRedirect` exceptions when polling for challenge 69 | filename. Heroku apps configured for zero downtime will be able to respond 70 | straight away to the request, but will probably respond with a redirect if 71 | configured with `force_ssl`. Closes issue #41. 72 | 73 | # 1.1.0 74 | 75 | - Make `ACME_DOMAIN` optional by using the Heroku API to get a full list of 76 | configured domains for the app. Useful for apps with lots of domains. 77 | Configuring `ACME_DOMAIN` is still supported. 78 | 79 | # 1.0.0 80 | 81 | The major version bump reflects the backwards-incompatible change around how 82 | errors are handled; `abort` vs. custom exception types. 83 | 84 | Huge thanks to everyone that contributed to this release, either via raising 85 | issues or submitting pull requests. 86 | 87 | - Raise exceptions on errors, instead of just `abort`ing. This should help 88 | you catch when your certificate renewal fails, before it expires completely. 89 | Closes issue #21 and pull request #28. Thanks @abigailmcp! 90 | 91 | - Wait up to 30 seconds for LetsEncrypt to verify a domain challenge. Closes 92 | issue #6 and pull requests #30, #25 and #7. Thanks @abigailmcp! 93 | 94 | - Attempt to fetch the challenge URL for up to 30 seconds before giving up. 95 | Closes issue #9 and pull request #28. Thanks @abigailmcp! 96 | 97 | # 0.3.0 98 | 99 | - Remove some Rails-specific methods and code to allow the gem to be used 100 | (with some extra steps) by non-Rails applications like Sinatra. Closes issue 101 | #14 and pull request #15, thanks @cbetta! 102 | 103 | # 0.2.6 104 | 105 | - Add more details of the error returned by LetsEncrypt when a challenge fails. 106 | Closes pull request #2, thanks @fjg! 107 | 108 | # 0.2.5 109 | 110 | - Verify multiple domains individually, fixing support for multiple domains. 111 | Closes issue #1, thanks @richardvenneman! 112 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'acme-client', '~> 2.0' 4 | gem 'platform-api', '>= 2.2', '< 4' 5 | 6 | group :development do 7 | gem "shoulda", ">= 0" 8 | gem "rdoc", "~> 3.12" 9 | gem "bundler", "~> 1.0" 10 | gem "juwelier", "~> 2.1.0" 11 | gem "simplecov", ">= 0" 12 | end 13 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | acme-client (2.0.7) 5 | faraday (>= 0.17, < 2.0.0) 6 | activesupport (6.0.3.3) 7 | concurrent-ruby (~> 1.0, >= 1.0.2) 8 | i18n (>= 0.7, < 2) 9 | minitest (~> 5.1) 10 | tzinfo (~> 1.1) 11 | zeitwerk (~> 2.2, >= 2.2.2) 12 | addressable (2.7.0) 13 | public_suffix (>= 2.0.2, < 5.0) 14 | builder (3.2.4) 15 | concurrent-ruby (1.1.7) 16 | descendants_tracker (0.0.4) 17 | thread_safe (~> 0.3, >= 0.3.1) 18 | docile (1.3.2) 19 | erubis (2.7.0) 20 | excon (0.76.0) 21 | faraday (1.0.1) 22 | multipart-post (>= 1.2, < 3) 23 | git (1.7.0) 24 | rchardet (~> 1.8) 25 | github_api (0.19.0) 26 | addressable (~> 2.4) 27 | descendants_tracker (~> 0.0.4) 28 | faraday (>= 0.8, < 2) 29 | hashie (~> 3.5, >= 3.5.2) 30 | oauth2 (~> 1.0) 31 | hashie (3.6.0) 32 | heroics (0.1.1) 33 | erubis (~> 2.0) 34 | excon 35 | moneta 36 | multi_json (>= 1.9.2) 37 | highline (2.0.3) 38 | i18n (1.8.5) 39 | concurrent-ruby (~> 1.0) 40 | json (1.8.6) 41 | juwelier (2.1.3) 42 | builder 43 | bundler (>= 1.13) 44 | git (>= 1.2.5) 45 | github_api 46 | highline (>= 1.6.15) 47 | nokogiri (>= 1.5.10) 48 | rake 49 | rdoc 50 | semver 51 | jwt (2.2.2) 52 | mini_portile2 (2.4.0) 53 | minitest (5.14.2) 54 | moneta (1.0.0) 55 | multi_json (1.15.0) 56 | multi_xml (0.6.0) 57 | multipart-post (2.1.1) 58 | nokogiri (1.10.10) 59 | mini_portile2 (~> 2.4.0) 60 | oauth2 (1.4.4) 61 | faraday (>= 0.8, < 2.0) 62 | jwt (>= 1.0, < 3.0) 63 | multi_json (~> 1.3) 64 | multi_xml (~> 0.5) 65 | rack (>= 1.2, < 3) 66 | platform-api (3.0.0) 67 | heroics (~> 0.1.1) 68 | moneta (~> 1.0.0) 69 | rate_throttle_client (~> 0.1.0) 70 | public_suffix (4.0.6) 71 | rack (2.2.3) 72 | rake (13.0.1) 73 | rate_throttle_client (0.1.2) 74 | rchardet (1.8.0) 75 | rdoc (3.12.2) 76 | json (~> 1.4) 77 | semver (1.0.1) 78 | shoulda (4.0.0) 79 | shoulda-context (~> 2.0) 80 | shoulda-matchers (~> 4.0) 81 | shoulda-context (2.0.0) 82 | shoulda-matchers (4.4.1) 83 | activesupport (>= 4.2.0) 84 | simplecov (0.19.0) 85 | docile (~> 1.1) 86 | simplecov-html (~> 0.11) 87 | simplecov-html (0.12.3) 88 | thread_safe (0.3.6) 89 | tzinfo (1.2.7) 90 | thread_safe (~> 0.1) 91 | zeitwerk (2.4.0) 92 | 93 | PLATFORMS 94 | ruby 95 | 96 | DEPENDENCIES 97 | acme-client (~> 2.0) 98 | bundler (~> 1.0) 99 | juwelier (~> 2.1.0) 100 | platform-api (>= 2.2, < 4) 101 | rdoc (~> 3.12) 102 | shoulda 103 | simplecov 104 | 105 | BUNDLED WITH 106 | 1.17.3 107 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Pixie Labs 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LetsEncrypt & Rails & Heroku 2 | 3 | ### WATCH OUT! This gem is deprecated 4 | 5 | Since this gem was created, Heroku have added support for [free automated SSL certificates for paid dynos](https://devcenter.heroku.com/articles/automated-certificate-management) (ACM). You should use ACM instead of this gem unless your situation is covered by the [known limitations](https://devcenter.heroku.com/articles/automated-certificate-management#known-limitations) of ACM, e.g. your app runs in Heroku Private Spaces. When we've had issues with ACM, we've had success with the [Expedited WAF](https://elements.heroku.com/addons/expeditedwaf) addon, and you might too. 6 | 7 | --- 8 | 9 | [![Gem Version](https://badge.fury.io/rb/letsencrypt-rails-heroku.svg)](https://badge.fury.io/rb/letsencrypt-rails-heroku) 10 | 11 | This gem is a complete solution for securing your Ruby on Rails application 12 | on Heroku using their free SNI-based SSL and LetsEncrypt. It will automatically 13 | handle renewals and keeping your certificate up to date. 14 | 15 | With some extra steps, this gem can also be used with Sinatra. For an example 16 | of how to do this, see the 17 | [letsencrypt-rails-heroku-sinatra-example](https://github.com/pixielabs/letsencrypt-rails-heroku-sinatra-example) 18 | repository. 19 | 20 | 21 | ## Requirements 22 | 23 | - You must be using hobby or professional dynos to use free SNI-based SSL. 24 | Find out more on [Heroku's documentation page about 25 | SSL](https://devcenter.heroku.com/articles/ssl). 26 | 27 | - You should have already configured your app DNS as per [Heroku's 28 | documentation](https://devcenter.heroku.com/articles/custom-domains). 29 | 30 | ## Installation 31 | 32 | Add the gem to your Gemfile: 33 | 34 | ``` 35 | gem 'letsencrypt-rails-heroku', group: 'production' 36 | ``` 37 | 38 | And add it as middleware in your `config/environments/production.rb`: 39 | 40 | ``` 41 | Rails.application.configure do 42 | <...> 43 | 44 | config.middleware.use Letsencrypt::Middleware 45 | 46 | <...> 47 | end 48 | ``` 49 | 50 | If you have configured your app to enforce SSL with the configuration option 51 | `config.force_ssl = true` you will need to insert the middleware in front of 52 | the middleware performing that enforcement instead, as LetsEncrypt do not allow 53 | redirects on their verification requests: 54 | 55 | ```ruby 56 | Rails.application.configure do 57 | # <...> 58 | 59 | config.middleware.insert_before ActionDispatch::SSL, Letsencrypt::Middleware 60 | 61 | # <...> 62 | end 63 | ``` 64 | 65 | ## Configuring 66 | 67 | By default the gem will try to use the following set of configuration 68 | variables. You must set: 69 | 70 | * `ACME_EMAIL`: Your email address, should be valid. 71 | * `ACME_TERMS_AGREED`: Existence of this environment variable represents your 72 | agreement to [Let's Encrypt's terms of service](https://letsencrypt.org/repository/). 73 | * `HEROKU_TOKEN`: An API token for this app. See below 74 | * `HEROKU_APP`: Name of Heroku app e.g. bottomless-cavern-7173 75 | 76 | You can also set: 77 | 78 | * `ACME_DOMAIN`: Comma separated list of domains for which you want 79 | certificates, e.g. `example.com,www.example.com`. Your Heroku app should be 80 | configured to answer to all these domains, because Let's Encrypt will make a 81 | request to verify ownership. 82 | 83 | If you leave this blank, the gem will try and use the Heroku API to get a 84 | list of configured domains for your app, and verify all of them. 85 | * `SSL_TYPE`: Optional: One of `sni` or `endpoint`, defaults to `sni`. 86 | `endpoint` requires your app to have an 87 | [SSL endpoint addon](https://elements.heroku.com/addons/ssl) configured. 88 | 89 | The gem itself will temporarily create additional environment variables during 90 | the challenge / validation process: 91 | 92 | * `ACME_CHALLENGE_FILENAME`: The path of the file LetsEncrypt will request. 93 | * `ACME_CHALLENGE_FILE_CONTENT`: The content of that challenge file. 94 | 95 | It will also create two permanent environment variables after the first run: 96 | 97 | * `ACME_PRIVATE_KEY`: Private key used to create requests for certificates. 98 | * `ACME_KEY_ID`: Key ID assigned to your private key by Let's Encrypt. 99 | 100 | If you remove these, a new account will be created and new environment 101 | variables will be set. 102 | 103 | ## Creating a Heroku token 104 | 105 | Use the `heroku-oauth` toolbelt plugin to generate an access token suitable 106 | for accessing the Heroku API to update the certificates. From within your 107 | project directory: 108 | 109 | ``` 110 | > heroku plugins:install heroku-cli-oauth 111 | > heroku authorizations:create -d "LetsEncrypt" 112 | Created OAuth authorization. 113 | ID: 114 | Description: LetsEncrypt 115 | Scope: global 116 | Token: 117 | ``` 118 | 119 | Use the output of that to set the token (`HEROKU_TOKEN`). 120 | 121 | ## Using for the first time 122 | 123 | After deploying, run `heroku run rake letsencrypt:renew`. Ensure that the 124 | output looks good: 125 | 126 | ``` 127 | $ heroku run rake letsencrypt:renew 128 | Running rake letsencrypt:renew on ⬢ yourapp... ⣷ connecting, run.1234 129 | Creating account key...Done! 130 | Registering with LetsEncrypt...Done! 131 | Setting config vars on Heroku...Done! 132 | Giving config vars time to change...Done! 133 | Testing filename works (to bring up app)...done! 134 | Adding new certificate...Done! 135 | $ 136 | ``` 137 | 138 | If this is the first time you have used an SNI-based SSL certificate on your 139 | app, you may need to alter your DNS configuration as per 140 | [Heroku's instructions](https://devcenter.heroku.com/articles/ssl-beta#change-your-dns-for-all-domains-on-your-app). 141 | 142 | You can see these details by typing `heroku domains`. 143 | 144 | ## Adding a scheduled task 145 | 146 | You should add a scheduled task on Heroku to renew the certificate. If you 147 | are unfamiliar with how to do this, take a look at [Heroku's documentation 148 | on their scheduler addon](https://devcenter.heroku.com/articles/scheduler). 149 | 150 | The scheduled task should be configured to run `rake letsencrypt:renew` as 151 | often as you want to renew your certificate. Letsencrypt certificates are valid 152 | for 90 days, but there's no harm renewing them more frequently than that. 153 | 154 | Heroku Scheduler only lets you run a task as infrequently as once a day, but 155 | you don't want to renew your SSL certificate every day (you will hit 156 | [the rate limit](https://letsencrypt.org/docs/rate-limits/)). You can make it 157 | run less frequently using a shell control statement. For example to renew your 158 | certificate on the 1st day of every month: 159 | 160 | ``` 161 | if [ "$(date +%d)" = 01 ]; then bundle exec rake letsencrypt:renew; fi 162 | ``` 163 | 164 | Source: [blog.dbrgn.ch](https://blog.dbrgn.ch/2013/10/4/heroku-schedule-weekly-monthly-tasks/) 165 | 166 | ## Security considerations 167 | 168 | Suggestions and pull requests are welcome in improving the situation with the 169 | following security considerations: 170 | 171 | - When configuring this gem you must add a non-expiring Heroku API token into 172 | your application environment. Your collaborators could use this token to 173 | impersonate the account it was created with when accessing the Heroku API. 174 | This is important if your account has access to other apps that your 175 | collaborators don’t. Additionally, if your application environment was 176 | leaked this would give the attacker access to the Heroku API as your user 177 | account. 178 | [More information about Heroku’s API and oAuth](https://devcenter.heroku.com/articles/oauth#direct-authorization). 179 | 180 | You should create the API token from a suitably locked-down account. 181 | 182 | - This gem uses two environment variables (`ACME_CHALLENGE_FILENAME` and 183 | `ACME_CHALLENGE_FILE_CONTENT`) to construct routes and responses in your 184 | app. These environment variables could be manipulated to spoof URLs on your 185 | application. 186 | 187 | The gem performs some cursory checks to make sure the filename is roughly 188 | what is expected to try and mitigate this. 189 | 190 | ## Troubleshooting 191 | 192 | ### Common name invalid errors (security certificate is from *.herokuapp.com) 193 | 194 | Your domain is still configured as a CNAME or ALIAS to 195 | `your-app.herokuapp.com`. Check the output of `heroku domains` matches your DNS 196 | configuration. When you add an SNI cert to an app for the first time 197 | [the DNS target changes](https://devcenter.heroku.com/articles/custom-domains#view-existing-domains). 198 | 199 | ## To-do list 200 | 201 | - Persist account key, or at least give the option of using an existing one, so 202 | we don’t register with LetsEncrypt over and over. 203 | 204 | - Provide instructions for running the gem decoupled from the app it is 205 | securing, for the paranoid. 206 | 207 | ## Contributing 208 | 209 | - Check out the latest master to make sure the feature hasn't been implemented 210 | or the bug hasn't been fixed yet. 211 | - Check out the issue tracker to make sure someone already hasn't requested it 212 | and/or contributed it. 213 | - Fork the project. 214 | - Start a feature/bugfix branch. 215 | - Commit and push until you are happy with your contribution. 216 | - Make sure to add tests for it. This is important so I don't break it in a 217 | future version unintentionally. 218 | - Please try not to mess with the Rakefile, version, or history. If you want to 219 | have your own version, or is otherwise necessary, that is fine, but please 220 | isolate to its own commit so I can cherry-pick around it. 221 | 222 | ### Generating a new release 223 | 224 | 1. Bump the version: `rake version:bump:{major,minor,patch}`. 225 | 2. Update `CHANGELOG.md` & commit. 226 | 3. Use `rake release` to regenerate gemspec, push a tag to git, and push a new 227 | `.gem` to rubygems.org. 228 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'rubygems' 4 | require 'bundler' 5 | begin 6 | Bundler.setup(:default, :development) 7 | rescue Bundler::BundlerError => e 8 | $stderr.puts e.message 9 | $stderr.puts "Run `bundle install` to install missing gems" 10 | exit e.status_code 11 | end 12 | require 'rake' 13 | 14 | require 'juwelier' 15 | Juwelier::Tasks.new do |gem| 16 | # gem is a Gem::Specification... see http://guides.rubygems.org/specification-reference/ for more options 17 | gem.name = "letsencrypt-rails-heroku" 18 | gem.homepage = "https://github.com/pixielabs/letsencrypt-rails-heroku" 19 | gem.license = "MIT" 20 | gem.summary = %Q{Automatic LetsEncrypt certificates in your Rails app on Heroku} 21 | gem.description = %Q{This gem automatically handles creation, renewal, and applying SSL certificates from LetsEncrypt to your Heroku account.} 22 | gem.email = "team@pixielabs.io" 23 | gem.authors = ["Pixie Labs", "David Somers", "Abigail McPhillips"] 24 | 25 | # dependencies defined in Gemfile 26 | end 27 | Juwelier::RubygemsDotOrgTasks.new 28 | 29 | require 'rake/testtask' 30 | Rake::TestTask.new(:test) do |test| 31 | test.libs << 'lib' << 'test' 32 | test.pattern = 'test/**/test_*.rb' 33 | test.verbose = true 34 | end 35 | 36 | desc "Code coverage detail" 37 | task :simplecov do 38 | ENV['COVERAGE'] = "true" 39 | Rake::Task['test'].execute 40 | end 41 | 42 | task :default => :test 43 | 44 | require 'rdoc/task' 45 | Rake::RDocTask.new do |rdoc| 46 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 47 | 48 | rdoc.rdoc_dir = 'rdoc' 49 | rdoc.title = "letsencrypt-rails-heroku #{version}" 50 | rdoc.rdoc_files.include('README*') 51 | rdoc.rdoc_files.include('lib/**/*.rb') 52 | end 53 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 2.0.3 -------------------------------------------------------------------------------- /letsencrypt-rails-heroku.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by juwelier 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Juwelier::Tasks in Rakefile, and run 'rake gemspec' 4 | # -*- encoding: utf-8 -*- 5 | # stub: letsencrypt-rails-heroku 2.0.3 ruby lib 6 | 7 | Gem::Specification.new do |s| 8 | s.name = "letsencrypt-rails-heroku".freeze 9 | s.version = "2.0.3" 10 | 11 | s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= 12 | s.require_paths = ["lib".freeze] 13 | s.authors = ["Pixie Labs".freeze, "David Somers".freeze, "Abigail McPhillips".freeze] 14 | s.date = "2020-10-07" 15 | s.description = "This gem automatically handles creation, renewal, and applying SSL certificates from LetsEncrypt to your Heroku account.".freeze 16 | s.email = "team@pixielabs.io".freeze 17 | s.extra_rdoc_files = [ 18 | "CHANGELOG.md", 19 | "LICENSE.txt", 20 | "README.md" 21 | ] 22 | s.files = [ 23 | ".document", 24 | "CHANGELOG.md", 25 | "Gemfile", 26 | "Gemfile.lock", 27 | "LICENSE.txt", 28 | "README.md", 29 | "Rakefile", 30 | "VERSION", 31 | "letsencrypt-rails-heroku.gemspec", 32 | "lib/letsencrypt-rails-heroku.rb", 33 | "lib/letsencrypt-rails-heroku/exceptions.rb", 34 | "lib/letsencrypt-rails-heroku/letsencrypt.rb", 35 | "lib/letsencrypt-rails-heroku/middleware.rb", 36 | "lib/letsencrypt-rails-heroku/railtie.rb", 37 | "lib/tasks/letsencrypt.rake" 38 | ] 39 | s.homepage = "https://github.com/pixielabs/letsencrypt-rails-heroku".freeze 40 | s.licenses = ["MIT".freeze] 41 | s.rubygems_version = "3.0.6".freeze 42 | s.summary = "Automatic LetsEncrypt certificates in your Rails app on Heroku".freeze 43 | 44 | if s.respond_to? :specification_version then 45 | s.specification_version = 4 46 | 47 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 48 | s.add_runtime_dependency(%q.freeze, ["~> 2.0"]) 49 | s.add_runtime_dependency(%q.freeze, [">= 2.2", "< 4"]) 50 | s.add_development_dependency(%q.freeze, [">= 0"]) 51 | s.add_development_dependency(%q.freeze, ["~> 3.12"]) 52 | s.add_development_dependency(%q.freeze, ["~> 1.0"]) 53 | s.add_development_dependency(%q.freeze, ["~> 2.1.0"]) 54 | s.add_development_dependency(%q.freeze, [">= 0"]) 55 | else 56 | s.add_dependency(%q.freeze, ["~> 2.0"]) 57 | s.add_dependency(%q.freeze, [">= 2.2", "< 4"]) 58 | s.add_dependency(%q.freeze, [">= 0"]) 59 | s.add_dependency(%q.freeze, ["~> 3.12"]) 60 | s.add_dependency(%q.freeze, ["~> 1.0"]) 61 | s.add_dependency(%q.freeze, ["~> 2.1.0"]) 62 | s.add_dependency(%q.freeze, [">= 0"]) 63 | end 64 | else 65 | s.add_dependency(%q.freeze, ["~> 2.0"]) 66 | s.add_dependency(%q.freeze, [">= 2.2", "< 4"]) 67 | s.add_dependency(%q.freeze, [">= 0"]) 68 | s.add_dependency(%q.freeze, ["~> 3.12"]) 69 | s.add_dependency(%q.freeze, ["~> 1.0"]) 70 | s.add_dependency(%q.freeze, ["~> 2.1.0"]) 71 | s.add_dependency(%q.freeze, [">= 0"]) 72 | end 73 | end 74 | 75 | -------------------------------------------------------------------------------- /lib/letsencrypt-rails-heroku.rb: -------------------------------------------------------------------------------- 1 | require 'letsencrypt-rails-heroku/letsencrypt' 2 | require 'letsencrypt-rails-heroku/middleware' 3 | require 'letsencrypt-rails-heroku/exceptions' 4 | 5 | if defined?(Rails) 6 | require 'letsencrypt-rails-heroku/railtie' 7 | end 8 | -------------------------------------------------------------------------------- /lib/letsencrypt-rails-heroku/exceptions.rb: -------------------------------------------------------------------------------- 1 | module Letsencrypt 2 | module Error 3 | # LetsEncrypt encountered an issue verifying the challenge. 4 | class VerificationError < StandardError; end 5 | # LetsEncrypt encountered an issue finalizing the order. 6 | class FinalizationError < StandardError; end 7 | # Challenge URL is not available. 8 | class ChallengeUrlError < StandardError; end 9 | # Domain verification took longer than we'd like. 10 | class VerificationTimeoutError < StandardError; end 11 | # Order finalization took longer than we'd like. 12 | class FinalizationTimeoutError < StandardError; end 13 | # Error adding the certificate to Heroku. 14 | class HerokuCertificateError < StandardError; end 15 | # No HTTP challenge available from certificate provider. 16 | class NoHTTPChallengeError < StandardError; end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/letsencrypt-rails-heroku/letsencrypt.rb: -------------------------------------------------------------------------------- 1 | module Letsencrypt 2 | class << self 3 | attr_accessor :configuration 4 | end 5 | 6 | def self.configure 7 | self.configuration ||= Configuration.new 8 | yield(configuration) if block_given? 9 | end 10 | 11 | def self.challenge_configured? 12 | configuration.acme_challenge_filename && 13 | configuration.acme_challenge_filename.start_with?(".well-known/") && 14 | configuration.acme_challenge_file_content 15 | end 16 | 17 | def self.registered? 18 | configuration.acme_private_key && configuration.acme_key_id 19 | end 20 | 21 | class Configuration 22 | attr_accessor :heroku_token, :heroku_app, :acme_email, :acme_domain, 23 | :acme_directory, :ssl_type, :acme_terms_agreed 24 | 25 | # Not settable by user; part of the gem's behaviour. 26 | attr_reader :acme_challenge_filename, :acme_challenge_file_content, 27 | :acme_private_key, :acme_key_id 28 | 29 | def initialize 30 | @heroku_token = ENV["HEROKU_TOKEN"] 31 | @heroku_app = ENV["HEROKU_APP"] 32 | @acme_email = ENV["ACME_EMAIL"] 33 | @acme_domain = ENV["ACME_DOMAIN"] 34 | @acme_directory = 'https://acme-v02.api.letsencrypt.org/directory' 35 | @acme_terms_agreed = ENV["ACME_TERMS_AGREED"] 36 | @ssl_type = ENV["SSL_TYPE"] || 'sni' 37 | 38 | @acme_challenge_filename = ENV["ACME_CHALLENGE_FILENAME"] 39 | @acme_challenge_file_content = ENV["ACME_CHALLENGE_FILE_CONTENT"] 40 | 41 | @acme_private_key = ENV["ACME_PRIVATE_KEY"] 42 | @acme_key_id = ENV["ACME_KEY_ID"] 43 | end 44 | 45 | def valid? 46 | heroku_token && heroku_app && acme_email && acme_terms_agreed 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/letsencrypt-rails-heroku/middleware.rb: -------------------------------------------------------------------------------- 1 | module Letsencrypt 2 | class Middleware 3 | 4 | def initialize(app) 5 | @app = app 6 | end 7 | 8 | def call(env) 9 | if Letsencrypt.challenge_configured? && env["PATH_INFO"] == "/#{Letsencrypt.configuration.acme_challenge_filename}" 10 | return [200, {"Content-Type" => "text/plain"}, [Letsencrypt.configuration.acme_challenge_file_content]] 11 | end 12 | 13 | @app.call(env) 14 | end 15 | 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/letsencrypt-rails-heroku/railtie.rb: -------------------------------------------------------------------------------- 1 | class LetsencryptRailsHerokuRailtie < Rails::Railtie 2 | config.before_configuration do 3 | Letsencrypt.configure 4 | end 5 | 6 | rake_tasks do 7 | load 'tasks/letsencrypt.rake' 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/tasks/letsencrypt.rake: -------------------------------------------------------------------------------- 1 | require 'open-uri' 2 | require 'openssl' 3 | require 'acme-client' 4 | require 'platform-api' 5 | 6 | namespace :letsencrypt do 7 | 8 | desc 'Renew your LetsEncrypt certificate' 9 | task :renew do 10 | # Check configuration looks OK 11 | 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? 12 | 13 | # Set up Heroku client 14 | heroku = PlatformAPI.connect_oauth Letsencrypt.configuration.heroku_token 15 | heroku_app = Letsencrypt.configuration.heroku_app 16 | 17 | if Letsencrypt.registered? 18 | puts "Using existing registration details" 19 | private_key = OpenSSL::PKey::RSA.new(Letsencrypt.configuration.acme_private_key) 20 | key_id = Letsencrypt.configuration.acme_key_id 21 | else 22 | # Create a private key 23 | print "Creating account key..." 24 | private_key = OpenSSL::PKey::RSA.new(4096) 25 | puts "Done!" 26 | 27 | client = Acme::Client.new(private_key: private_key, 28 | directory: Letsencrypt.configuration.acme_directory, 29 | connection_options: { 30 | request: { 31 | open_timeout: 5, 32 | timeout: 5 33 | } 34 | }) 35 | 36 | print "Registering with LetsEncrypt..." 37 | account = client.new_account(contact: "mailto:#{Letsencrypt.configuration.acme_email}", 38 | terms_of_service_agreed: true) 39 | 40 | key_id = account.kid 41 | puts "Done!" 42 | print "Saving account details as configuration variables..." 43 | heroku.config_var.update(heroku_app, 44 | 'ACME_PRIVATE_KEY' => private_key.to_pem, 45 | 'ACME_KEY_ID' => account.kid) 46 | puts "Done!" 47 | end 48 | 49 | # Make a new Acme::Client with whichever private_key & key_id we ended up with. 50 | client = Acme::Client.new(private_key: private_key, 51 | directory: Letsencrypt.configuration.acme_directory, 52 | kid: key_id) 53 | 54 | domains = [] 55 | if Letsencrypt.configuration.acme_domain 56 | puts "Using ACME_DOMAIN configuration variable..." 57 | domains = Letsencrypt.configuration.acme_domain.split(',').map(&:strip) 58 | else 59 | domains = heroku.domain.list(heroku_app).map{|domain| domain['hostname']} 60 | puts "Using #{domains.length} configured Heroku domain(s) for this app..." 61 | end 62 | 63 | order = client.new_order(identifiers: domains) 64 | 65 | order.authorizations.each do |authorization| 66 | puts "Performing verification for #{authorization.domain}:" 67 | 68 | challenge = authorization.http 69 | 70 | 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 71 | 72 | print "Setting config vars on Heroku..." 73 | heroku.config_var.update(heroku_app, { 74 | 'ACME_CHALLENGE_FILENAME' => challenge.filename, 75 | 'ACME_CHALLENGE_FILE_CONTENT' => challenge.file_content 76 | }) 77 | puts "Done!" 78 | 79 | # Wait for app to come up 80 | print "Testing filename works (to bring up app)..." 81 | 82 | # Get the domain name from Heroku 83 | hostname = heroku.domain.list(heroku_app).first['hostname'] 84 | 85 | # Wait at least a little bit, otherwise the first request will almost always fail. 86 | sleep(2) 87 | 88 | start_time = Time.now 89 | 90 | begin 91 | open("http://#{hostname}/#{challenge.filename}").read 92 | rescue OpenSSL::SSL::SSLError, OpenURI::HTTPError, RuntimeError => e 93 | raise e if e.is_a?(RuntimeError) && !e.message.include?("redirection forbidden") 94 | if Time.now - start_time <= 60 95 | puts "Error fetching challenge, retrying... #{e.message}" 96 | sleep(5) 97 | retry 98 | else 99 | failure_message = "Error waiting for response from http://#{hostname}/#{challenge.filename}, Error: #{e.message}" 100 | raise Letsencrypt::Error::ChallengeUrlError, failure_message 101 | end 102 | end 103 | 104 | puts "Done!" 105 | 106 | print "Giving LetsEncrypt some time to verify..." 107 | # Once you are ready to serve the confirmation request you can proceed. 108 | challenge.request_validation 109 | 110 | start_time = Time.now 111 | while challenge.status == 'pending' 112 | if Time.now - start_time >= 30 113 | failure_message = "Failed - timed out waiting for challenge verification." 114 | raise Letsencrypt::Error::VerificationTimeoutError, failure_message 115 | end 116 | sleep(2) 117 | challenge.reload 118 | end 119 | 120 | puts "Done!" 121 | 122 | unless challenge.status == 'valid' 123 | puts "Problem verifying challenge." 124 | failure_message = "Status: #{challenge.status}, Error: #{challenge.error}" 125 | raise Letsencrypt::Error::VerificationError, failure_message 126 | end 127 | 128 | puts "" 129 | end 130 | 131 | # Unset temporary config vars. We don't care about waiting for this to 132 | # restart 133 | heroku.config_var.update(heroku_app, { 134 | 'ACME_CHALLENGE_FILENAME' => nil, 135 | 'ACME_CHALLENGE_FILE_CONTENT' => nil 136 | }) 137 | 138 | # Create CSR 139 | csr_private_key = OpenSSL::PKey::RSA.new 4096 140 | csr = Acme::Client::CertificateRequest.new(names: domains, 141 | private_key: csr_private_key) 142 | 143 | print "Asking LetsEncrypt to finalize our certificate order..." 144 | # Get certificate 145 | order.finalize(csr: csr) 146 | 147 | # Wait for order to process 148 | start_time = Time.now 149 | while order.status == 'processing' 150 | if Time.now - start_time >= 30 151 | failure_message = "Failed - timed out waiting for order finalization" 152 | raise Letsencrypt::Error::FinalizationTimeoutError, failure_message 153 | end 154 | sleep(2) 155 | order.reload 156 | end 157 | 158 | puts "Done!" 159 | 160 | unless order.status == 'valid' 161 | failure_message = "Problem finalizing order - status: #{order.status}" 162 | raise Letsencrypt::Error::FinalizationError, failure_message 163 | end 164 | 165 | certificate = order.certificate # => PEM-formatted certificate 166 | 167 | # Send certificates to Heroku via API 168 | 169 | endpoint = case Letsencrypt.configuration.ssl_type 170 | when 'sni' 171 | heroku.sni_endpoint 172 | when 'endpoint' 173 | heroku.ssl_endpoint 174 | end 175 | 176 | certificate_info = { 177 | certificate_chain: certificate, 178 | private_key: csr_private_key.to_pem 179 | } 180 | 181 | # Fetch existing certificate from Heroku (if any). We just use the first 182 | # one; if someone has more than one, they're probably not actually using 183 | # this gem. Could also be an error? 184 | existing_certificate = endpoint.list(heroku_app)[0] 185 | 186 | begin 187 | if existing_certificate 188 | print "Updating existing certificate #{existing_certificate['name']}..." 189 | endpoint.update(heroku_app, existing_certificate['name'], certificate_info) 190 | puts "Done!" 191 | else 192 | print "Adding new certificate..." 193 | endpoint.create(heroku_app, certificate_info) 194 | puts "Done!" 195 | end 196 | rescue Excon::Error::UnprocessableEntity => e 197 | warn "Error adding certificate to Heroku. Response from Heroku’s API follows:" 198 | raise Letsencrypt::Error::HerokuCertificateError, e.response.body 199 | rescue Excon::Error::Forbidden => e 200 | warn "Error adding certificate to Heroku, expected an OK response status, got a '403 Forbidden'. Response follows:" 201 | puts e.response.body 202 | # Re-raise for now. 203 | raise e 204 | end 205 | 206 | end 207 | 208 | end 209 | --------------------------------------------------------------------------------