Using Let's Encrypt Without certbot
In my last post I talked about what a CDN is and why you might want one. To recap, my goal is automatic, magical DNS/SSL/caching management. Today we're going to talk about one aspect of this project: HTTPS and SSL.
SSL, or Secure Sockets Layer, is the mechanism web browsers use to secure and encrypt the connection between your computer and the server that is serving up the content you're looking for.
A few years ago browser vendors started getting very serious about wanting every website to be encrypted. At the time, SSL was expensive to implement because you needed to buy or pay to renew certificates at least once a year.
Almost simultaneously with this increased need for encryption, organizations including the Electronic Frontier Foundation and the Mozilla Foundation started a new certificate authority (organization that issues certificates) named Let's Encrypt. Let's Encrypt is different because it issues certificates for free with an API.
Most people use a tool named certbot
that automates the process of acquiring certificates for a given website.
However, certbot
doesn't really work for my purposes.
I want to centrally manage my certificates and copy them out to my CDN nodes on a regular basis, which means I need to use the DNS challenge type.
certbot
's support for the DNS challenge isn't really adequate for my needs.
Challenge Types
Let's Encrypt uses challenges to verify that you own the domain that you're trying to acquire a certificate for.
Currently there are two different challenge types, http-01
and dns-01
.
For http-01
, you simply create a file within a well-known directory structure within your website containing a challenge string that the API gives you.
Then you tell Let`s Encrypt to go look for it.
If the file is there and contains the correct challenge string, Let's Encrypt will give you a certificate.
dns-01
works much the same way, except instead of creating a file you create a TXT
record for your domain.
Let's Encrypt will ask your domain's DNS servers for the value of the TXT
record, and if it matches what it expects, you get a certificate.
http-01
has the advantage of being really simple and easy to use with the certbot
tool and whatever web server you happen to have.
However, with multiple servers in the mix it can get tricky to make sure that every server has a certificate without hitting Let's Encrypt's rate limits.
That's why I'm using dns-01
.
I can easily drive the API from the central management node and copy the certificates out to all of the CDN nodes simultaneously.
How ACME Works
I use a gem called acme-client
to drive Let's Encrypt ACMEv2
API.
Once you know ACME's terminology it's easy to use.
- An
order
is the initial request to generate a certificate for one or more domain names - An
authorization
is LetsEncrypt's response to theorder
. It contains one or morechallenges
for each domain name in theorder
. - After setting up the challenges with either
http-01
ordns-01
, you thenrequest_validation
. LetsEncrypt tries to verify that you were able to successfully install the challenges. - Finally, after LetsEncrypt has seen the validations in the wild, you send a
Certificate Request
(csr
). LetsEncrypt responds with a properly signed certificate, valid for all of the domain names that you verified and sent with yourcsr
.
Getting a Certificate, End to End
Step 1: Sign up for an account
The first thing we need to do is sign up for a LetsEncrypt account. Accounts are identified with a private key and an email address.
require 'acme-client'
require 'openssl'
key = OpenSSL::PKey::RSA.new(4096)
client = Acme::Client.new(
private_key: key,
directory: 'https://acme-staging-v02.api.letsencrypt.org/directory'
)
account = client.new_account(
contact: "mailto:you@example.com}",
terms_of_service_agreed: true
)
Step 2: Generate an Order
Next, let's start the process of getting a certificate.
The first thing we do is build an order
from a set of domain names.
order = client.new_order(identifiers: ['example.com']
The order contains one authorization per identifier per challenge type.
We only care about the dns
challenge type.
authorization = order.authorizations.first
label = '_acme-challenge.example.com'
record_type = authorization.dns.record_type
value = authorization.dns.record_content
Step 3: Set the value in Route53
I use AWS' Route53 service to host my DNS records for a variety of reasons. That means we now have to set a record in Route53.
First, we need to set up a client and find the zone we want to update:
require 'aws-sdk'
route53 = Aws::Route53::Client.new(region: 'us-east-1')
zone = route53.list_hosted_zones(max_items: 100)
.hosted_zones
.detect { |z| z.name = 'example.com.' }
Next, we generate an UPSERT
to create or update the record:
change = {
action: 'UPSERT',
resource_record_set: {
name: label,
type: record_type,
ttl: 1,
resource_records: [
{ value: value }
]
}
}
options = {
hosted_zone_id: zone.id,
change_batch: {
changes: [change]
}
}
route53.change_resource_record_sets(options)
Step 4: Wait for DNS to populate
Route53 takes some time to push your changes out so now we have to wait. We also have to wait for all of the DNS servers that service the zone to return with the correct value because LetsEncrypt will pick one randomly to ask for the challenge.
Let's write a loop to wait for us. First we need to get the list of nameservers for the zone:
nameservers = []
Resolv::DNS.open(nameserver: '8.8.8.8') do |dns|
while nameservers.length == 0
nameservers = dns.getresources(
'example.com',
Resolv::DNS::Resource::IN::NS
).map(&:name).map(&:to_s)
end
end
This uses Ruby's built-in DNS resolver library named Resolv
to ask Google's public DNS server what nameservers are set up for example.com
.
Next, we have a function that asks those nameservers for the challenge value:
def check_dns(nameservers)
valid = true
nameservers.each do |nameserver|
begin
records = Resolv::DNS.open(nameserver: nameserver) do |dns|
dns.getresources(
'example.com',
Resolv::DNS::Resource::IN::TXT
)
end
records = records.map(&:strings).flatten
valid = value == records.first
rescue Resolv::ResolvError
return false
end
return false if !valid
end
valid
end
while !check_dns(nameservers)
sleep 1
end
This again uses Ruby's built-in Resolv library to get a list of values.
In this case we're asking for all of the TXT
values that we set up with the Route53 upsert earlier.
We loop over each nameserver and ask if the value is what we're looking for. If it isn't we bail out early because we need all of the nameservers to have the correct value.
Step 5: Request Validation
Finally, after verifying that DNS has the correct values set, we tell LetsEncrypt to validate our challenges. If we had just asked for verification immediately after the upsert LetsEncrypt would have failed the order and there's no way to restart it or ask for them to check again. You get one validation per order and if you fail you have to start all over.
authorization.dns.request_validation
while true
authorization.dns.reload
if status == 'pending'
sleep(2)
else
break
end
end
Step 6: Send a CSR and receive the certificate
Finally, after validation completes we can actually request a certificate.
cert_key = OpenSSL::PKey::RSA.new(4096)
csr = Acme::Client::CertificateRequest.new(
private_key: cert_key,
names: ['example.com']
)
order.finalize(csr: csr)
sleep(1) while order.status == 'processing'
puts cert_key.to_pem.to_s
puts order.certificate
The acme-client
library comes with a handy Acme::Client::CertificateRequest
wrapper that takes care of building a CSR exactly how LetsEncrypt wants to see them, so all we have to fill in is the list of domain names we want the certificate to apply to.
After a short wait LetsEncrypt will return the bright shiny new certificate in order.certificate
.
Wildcard Wrinkle
The above is great if you want to list out every domain name that you want the certificate to apply to. LetsEncrypt recently added support for wildcard certificates, though, which are very useful but have one additional wrinkle.
Wildcard certificates apply to all of the subdomains at a single level for a given pattern. Let's say you want your certificate to apply to these domain names:
example.com
www.example.com
mx.example.com
foobar.example.com
blah.foobar.example.com
Instead of listing all of these domains in the certificate request you can ask for a wildcard, like this:
example.com
*.example.com
The wildcard will apply to any subdomain that matches a star.
mx.example.com
will match but blah.mx.example.com
will not.
The wrinkle here is that LetsEncrypt will give you two challenges for the same domain name because it wants you to verify both the root and the wildcard. You can't set easily set multiple TXT records for a given label in Route53, though, so you have to collapse them into one upsert:
change = {
action: 'UPSERT',
resource_record_set: {
name: label,
type: record_type,
ttl: 1,
resource_records: [
{ value: value_for_root }
{ value: value_for_wildcard }
]
}
}
options = {
hosted_zone_id: zone.id,
change_batch: {
changes: [change]
}
}
route53.change_resource_record_sets(options)
This seems simple because it is. That didn't stop it from taking me about four hours to figure out, however :)