├── .gitignore ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md └── main.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | *.pem 3 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-2.3.0 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | ruby "2.3.0" 4 | 5 | gem "acme-client" 6 | gem "dnsimple" 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | acme-client (0.3.0) 5 | faraday (~> 0.9, >= 0.9.1) 6 | json-jwt (~> 1.2, >= 1.2.3) 7 | activesupport (4.2.5.1) 8 | i18n (~> 0.7) 9 | json (~> 1.7, >= 1.7.7) 10 | minitest (~> 5.1) 11 | thread_safe (~> 0.3, >= 0.3.4) 12 | tzinfo (~> 1.1) 13 | bindata (2.1.0) 14 | dnsimple (2.1.1) 15 | httparty 16 | faraday (0.9.2) 17 | multipart-post (>= 1.2, < 3) 18 | httparty (0.13.7) 19 | json (~> 1.8) 20 | multi_xml (>= 0.5.2) 21 | i18n (0.7.0) 22 | json (1.8.3) 23 | json-jwt (1.5.2) 24 | activesupport 25 | bindata 26 | multi_json (>= 1.3) 27 | securecompare 28 | url_safe_base64 29 | minitest (5.8.4) 30 | multi_json (1.11.2) 31 | multi_xml (0.5.5) 32 | multipart-post (2.0.0) 33 | securecompare (1.0.0) 34 | thread_safe (0.3.5) 35 | tzinfo (1.2.2) 36 | thread_safe (~> 0.1) 37 | url_safe_base64 (0.2.2) 38 | 39 | PLATFORMS 40 | ruby 41 | 42 | DEPENDENCIES 43 | acme-client 44 | dnsimple 45 | 46 | BUNDLED WITH 47 | 1.11.2 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Dan Peterson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # letsencrypt-dnsimple 2 | 3 | Quick hack to use the letsencrypt [DNS challenge](https://letsencrypt.github.io/acme-spec/#rfc.section.7.4) with dnsimple. 4 | 5 | ## Running with installed ruby 6 | 7 | Requires ruby 2.3.0. 8 | 9 | ```bash 10 | $ gem install bundler 11 | $ bundle install 12 | $ DNSIMPLE_API_USER=you@foo.org \ 13 | DNSIMPLE_API_TOKEN=... \ 14 | NAMES=foo.org,www/foo.org \ 15 | ACME_CONTACT=mailto:you@foo.org \ 16 | bundle exec ruby main.rb 17 | ``` 18 | 19 | `.pem` files will be written to files named after the value of `NAMES`, with the above config they would match `foo.org_www.foo.org-*`: 20 | 21 | ``` 22 | foo.org_www.foo.org-cert.pem 23 | foo.org_www.foo.org-chain.pem 24 | foo.org_www.foo.org-fullchain.pem 25 | foo.org_www.foo.org-key.pem 26 | ``` 27 | 28 | ## Running with Docker 29 | 30 | Check out https://github.com/meskyanichi/dockerized-letsencrypt-dnsimple which wraps this in a Docker container so a ruby install is not needed. 31 | 32 | ## Config 33 | 34 | Comes from the environment. 35 | 36 | * `DNSIMPLE_API_USER` and `DNSIMPLE_API_TOKEN`: get these from https://dnsimple.com/user 37 | * `NAMES`: a `,`-separated list of names that will be in the requested cert. Use `/` instead of `.` to denote the separation between subdomain and dnsimple domain. For example, to request a cert for `www.danp.net`, where `danp.net` is the domain dnsimple knows about, you'd use `www/danp.net`. 38 | * `ACME_CONTACT`: the contact to use for [registration](https://letsencrypt.github.io/acme-spec/#rfc.section.6.3) 39 | * `LETSENCRYPT_ENDPOINT`: optional, defaults to the production endpoint at `https://acme-v01.api.letsencrypt.org/` 40 | * `OUTPUT_FILE_BASE`: optional, if specified, overrides the output filename base 41 | -------------------------------------------------------------------------------- /main.rb: -------------------------------------------------------------------------------- 1 | require "openssl" 2 | require "shellwords" 3 | 4 | require "dnsimple" 5 | require "acme/client" 6 | 7 | DEFAULT_LETSENCRYPT_ENDPOINT = "https://acme-v01.api.letsencrypt.org/" 8 | LETSENCRYPT_NAME = "_acme-challenge" # paranoid, don't use value from acme client 9 | LETSENCRYPT_NAME_TYPE = "TXT" # paranoid, don't use value from acme client 10 | DNSIMPLE_TTL = 60 11 | 12 | raw_names = ENV.fetch("NAMES").split(",") 13 | authorize_names = raw_names.inject({}) {|h, rn| n = rn.sub("/", "."); d = rn.split("/", 2).last; h.update(n => d) } 14 | 15 | dnsimple = Dnsimple::Client.new(username: ENV.fetch("DNSIMPLE_API_USER"), api_token: ENV.fetch("DNSIMPLE_API_TOKEN")) 16 | domains = authorize_names.values.uniq.inject({}) {|h, d| h.update(d => dnsimple.domains.domain(d)) } 17 | 18 | private_key = OpenSSL::PKey::RSA.new(2048) 19 | acme = Acme::Client.new(private_key: private_key, endpoint: ENV.fetch("LETSENCRYPT_ENDPOINT", DEFAULT_LETSENCRYPT_ENDPOINT)) 20 | 21 | registration = acme.register(contact: ENV.fetch("ACME_CONTACT")) 22 | registration.agree_terms 23 | 24 | authorize_names.each do |authorize_name, authorize_domain_name| 25 | authorize_domain = domains.fetch(authorize_domain_name) 26 | authorization = acme.authorize(domain: authorize_name) 27 | 28 | challenge = authorization.dns01 29 | if challenge.record_name != LETSENCRYPT_NAME 30 | abort "acme wanted record name #{challenge.record_name}, expected #{LETSENCRYPT_NAME}" 31 | end 32 | if challenge.record_type != LETSENCRYPT_NAME_TYPE 33 | abort "acme wanted record type #{challenge.record_type}, expected #{LETSENCRYPT_NAME_TYPE}" 34 | end 35 | 36 | # full name to authorize 37 | letsencrypt_authorize_name = "#{LETSENCRYPT_NAME}.#{authorize_name}" 38 | # the name we care about at dnsimple 39 | dnsimple_authorize_name = letsencrypt_authorize_name.sub(/(\A|\.)#{Regexp.escape(authorize_domain.name)}\z/, "") 40 | 41 | puts "preparing to authorize #{authorize_name} via #{letsencrypt_authorize_name} with dnsimple record #{dnsimple_authorize_name}/#{authorize_domain.name}" 42 | 43 | dnsimple.domains.records(authorize_domain.id).select do |record| 44 | record.name == dnsimple_authorize_name && record.type == LETSENCRYPT_NAME_TYPE 45 | end.each do |existing_record| 46 | puts "deleting existing record: #{existing_record.name} #{existing_record.type} #{existing_record.content}" 47 | dnsimple.domains.delete_record(authorize_domain.id, existing_record.id) 48 | end 49 | 50 | puts "creating new record: #{dnsimple_authorize_name} (#{authorize_domain.name}) #{LETSENCRYPT_NAME_TYPE} #{challenge.record_content}" 51 | 52 | dnsimple.domains.create_record( 53 | authorize_domain.id, 54 | name: dnsimple_authorize_name, 55 | record_type: LETSENCRYPT_NAME_TYPE, 56 | content: challenge.record_content, 57 | ttl: DNSIMPLE_TTL) 58 | 59 | puts "waiting for record to be at dnsimple servers" 60 | loop do 61 | system("dig @ns1.dnsimple.com #{Shellwords.escape(letsencrypt_authorize_name)} txt | grep -e #{Shellwords.escape(challenge.record_content)}") 62 | break if $?.success? 63 | sleep 5 64 | end 65 | 66 | puts "sleeping for ttl #{DNSIMPLE_TTL} seconds" 67 | sleep(DNSIMPLE_TTL) 68 | 69 | puts "requesting verification" 70 | challenge.request_verification 71 | 72 | puts "waiting for verification..." 73 | loop do 74 | if challenge.verify_status == "valid" 75 | puts "valid" 76 | break 77 | end 78 | 79 | if challenge.error 80 | abort "challenge error: #{challenge.error}" 81 | end 82 | 83 | sleep 5 84 | end 85 | end 86 | 87 | filename_base = ENV["OUTPUT_FILE_BASE"] || authorize_names.keys.sort.join("_") 88 | 89 | csr = Acme::Client::CertificateRequest.new(names: authorize_names.keys) 90 | 91 | puts "requesting certificate" 92 | certificate = acme.new_certificate(csr) 93 | 94 | File.write("#{filename_base}-key.pem", certificate.request.private_key.to_pem) 95 | File.write("#{filename_base}-cert.pem", certificate.to_pem) 96 | File.write("#{filename_base}-chain.pem", certificate.chain_to_pem) 97 | File.write("#{filename_base}-fullchain.pem", certificate.fullchain_to_pem) 98 | --------------------------------------------------------------------------------