Skip to content

Instantly share code, notes, and snippets.

@boof
Created March 9, 2023 00:25
Show Gist options
  • Save boof/2465e2b7e59c3206ee17d950f49f3199 to your computer and use it in GitHub Desktop.
Save boof/2465e2b7e59c3206ee17d950f49f3199 to your computer and use it in GitHub Desktop.
Update Heroku SSL Certificate using Let's Encrypt DNS challenge against INWX
#!/usr/bin/env ruby
require "bundler/inline"
gemfile do
source "https://rubygems.org"
gem "dotenv"
gem "inwx-domrobot"
gem "acme-client"
gem "jwt"
gem "platform-api"
end
require "dotenv/load"
# INWX_USERNAME
# INWX_PASSWORD
# INWX_TLD
# HEROKU_TOKEN via heroku plugins:install heroku-cli-oauth, heroku authorizations:create -d "ACME tooling"
# HEROKU_APP
# ACME_DOMAIN
# ACME_DIRECTORY is server from sudo certbot show_account
# ACME_KID is Account URL from sudo certbot show_account
# ACME_EMAIL is Email contact from sudo certbot show_account
# ACME_JWK from certbot, see /etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/.../private_key.json
require "json"
require "resolv"
module E
extend self
def const_missing(name) = const_set name, ENV.fetch(name.to_s)
def method_missing(name, *) = ENV.fetch name.to_s
end
VERBOSE = %w[-d --debug].intersection(ARGV).any?
# this uses the already existing JWK from previous certbot run exported into ENV
jwk = JWT::JWK.new JSON.load(E.ACME_JWK)
client = Acme::Client.new private_key: jwk.signing_key, directory: E.ACME_DIRECTORY, kid: E.ACME_KID
domrobot = INWX::Domrobot.new
login = domrobot.set_language('en')
.use_live
.use_json
.set_debug(VERBOSE)
.login(E.INWX_USERNAME, E.INWX_PASSWORD)
abort "INWX login failed!" if login['code'] != 1000
# create DNS challenge
order = client.new_order identifiers: [E::ACME_DOMAIN]
authorization = order.authorizations.first
challenge = authorization.dns
# create/update DNS record with challenge
name = "#{challenge.record_name}.#{E::ACME_DOMAIN}"
tld = domrobot.call "nameserver", "info", domain: E.INWX_TLD
record = tld.dig("resData", "record")&.find { _1["name"] == name }
op = if record
domrobot.call "nameserver", "updateRecord", id: record.fetch("id"), content: challenge.record_content
else
domrobot.call "nameserver", "createRecord", name:, type: challenge.record_type, content: challenge.record_content
end
domrobot.logout
# wait for DNS record to propagate
resolver = Resolv::DNS.new nameserver: %w[8.8.8.8 8.8.4.4]
begin
print "Press ENTER to continue (or type abort to exit) "
try_again = gets.chomp != "abort"
resource = resolver.getresource name, Resolv::DNS::Resource::IN::TXT
pending = resource.data != challenge.record_content
end while try_again && pending
abort "Challenge couldn't be propagated." if pending
# validate challenge
challenge.request_validation
while challenge.status == "pending"
sleep 2
challenge.reload
end
abort "Challenge couldn't be validated." unless challenge.status == "valid"
# request signed certificate
pkey = OpenSSL::PKey::RSA.new 4096
if VERBOSE
export = JWT::JWK.new(pkey, use: "sig").export include_private: true
puts JSON.fast_generate(export)
end
csr = Acme::Client::CertificateRequest.new private_key: pkey, subject: { common_name: E::ACME_DOMAIN }
order.finalize(csr:)
while order.status == 'processing'
sleep 1
order.reload
end
# upload signed certificate to heroku
heroku = PlatformAPI.connect_oauth E.HEROKU_TOKEN
endpoint = heroku.sni_endpoint
certificate = endpoint.list(E::HEROKU_APP).find { _1.dig("ssl_cert", "cert_domains").include? E::ACME_DOMAIN }
if certificate
endpoint.update E::HEROKU_APP, certificate.fetch("name"), certificate_chain: order.certificate, private_key: pkey.to_pem
else
endpoint.create E::HEROKU_APP, certificate_chain: order.certificate, private_key: pkey.to_pem
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment