---
title: "Infrastructure for ACME (LetsEncrypt) certificates in a private LAN"
date: 2024-04-10
draft: false
'blog/tags':
- sysadmin
- Échirolles
---

[LetsEncrypt](https://letsencrypt.org) --- the non-profit certification
authority that certifies a vast portion of the web --- doesn't really need
introduction anymore. They provide free SSL certificates, and even better, they
do so using their standardized automated protocol, ACME
([RFC 8555](https://datatracker.ietf.org/doc/html/rfc8555)).

When a client requests a certificate through ACME for a domain, it will be
handed a token --- a random string --- by the server. This token (the
challenge) must be temporarily served by the client to prove that it indeed
controls the domain. This automated validation uses either `http-01` or
`dns-01` challenges. The former requires the client to serve the token via http
at a certain URL. The latter requires the client to register a `TXT` DNS entry
under the domain bearing the token. The `http-01` process is clearly easier to
implement, and makes ACME straightforward in many cases.

Assume, however, that the machine trying to obtain a certificate is in a
private LAN --- it has no public IP address, or should remain completely
firewalled from the Internet, including the ACME server. In this case, the
`http-01` challenge cannot be used, and `dns-01` must be used instead. Many
articles online give instructions on how to make Certbot or other ACME clients
update DNS records. However, I am really not comfortable with giving all of my
applicative servers write-access to my DNS zones.

## ACME NS challenger

Our solution, instead, is to leave the ACME client running on the applicative
server (we use [Dehydrated](https://github.com/dehydrated-io/dehydrated)), and
to implement a simple API on the DNS server to allow applicative servers to
update only what they need to be able to update.

On the DNS server, a simple Flask-based webserver listens on the internal
network. It has two API endpoints: one to create an ACME challenge for a domain
(`PUT` request), one to mark a previously-created challenge as obsolete
(`DELETE` request).

Each request is validated in two ways:
* it must come from an IP address which resolves to the domain to be updated
  --- with some configurable exceptions for eg. domains with addresses in
  isolated networks;
* it must bear a challenge, which is the HMAC of the current time and a shared
  secret between the DNS and applicative server (distributed out-of-band
  earlier). The DNS server thus identifies which token is allowed to update
  which domain.

An intermediate directory contains all the currently deployed challenges. The
`PUT` request for domain X generates a DNS zone file for X under the
intermediate directory; a `DELETE` request thus only needs to delete this file.
Finally, after a `PUT` request, all domain-specific zone files are gathered
under a single file, with the Python equivalent of
`cat intermediate_dir/*.zone > acme.zone`. A `DELETE` request, however, only
deletes the intermediate file: to avoid generating too many zone updates, the
global zone file will only be updated upon the next `PUT`, or upon scheduled
garbage collection.

Finally, the generated `acme.zone` file is included in the global DNS
configuration file.

## Deployment in practice


