Add article on DNS challenge
All checks were successful
ci/woodpecker/push/test Pipeline was successful

This commit is contained in:
Melora Hugues 2024-07-02 23:16:04 +02:00 committed by Melora Hugues
parent 7dd8343680
commit 3c862d8356
7 changed files with 1088 additions and 5 deletions

View file

@ -90,11 +90,11 @@ logoText = "Hello there!"
logoHomeLink = "/fr/"
[menu]
# [[menu.main]]
# identifier = "blog"
# name = "Blog"
# url = "/posts"
# weight = 1
[[menu.main]]
identifier = "blog"
name = "Blog"
url = "/posts"
weight = 1
[[menu.main]]
identifier = "about_me"

View file

@ -0,0 +1,678 @@
---
title: "How to do HTTPS at home (when your infrastructure is private)"
date: 2024-07-02T21:00:50+02:00
draft: true
toc: true
images:
tags:
- self-hosting
- sysadmin
---
## The problem of having a self-hosted infrastructure
I've been maintaining a personal homelab and self-hosted infrastructure for a few years
now, but one of the most infuriating pages when starting such project is this dreaded
**Warning: Potential Security Risk Ahead** page that appears when you're using a
self-signed certificate, or when trying to use a password on a website or app that is
served through plain HTTP.
![A screenshot of a warning from Firefox indicating that the website that is being accessed is not secure.](/images/dns_article_firefox_warning.png)
While acceptable if you're alone on your own infrastructure or dev environment, this
poses several issues in many other contexts:
- It is not acceptable to publicly expose a website presenting this issue
- It's not advisable to say "hey look, I know that your browser gives you a big red
warning, but it's okay, you can just accept" to friends/family/etc. It's just a very
bad habit to have
- After a while, it really starts to get on your nerve
Thankfully a free solution for that, which you will probably know already, has existed
for almost ten (10) years now: [Let's Encrypt and the ACME protocol](https://letsencrypt.org/)
{{< callout type="note" >}}
I promise this is not yet another Let's Encrypt tutorial, well it is, but for a more
specific use-case
{{< /callout >}}
## The Let's Encrypt solution
### What is Let's Encrypt
[Let's Encrypt](https://letsencrypt.org/) is a nonprofit certificate authority founded
in November 2014. Its main goal was to provide an easy and free way to obtain a TLS
certificate in order to make it easy to use HTTPS everywhere.
The [ACME protocol](https://letsencrypt.org/docs/client-options/) developed by Let's
Encrypt is an automated verification system aiming at doing the following:
- verifying that you own the domain for which you want a certificate
- creating and registering that certificate
- delivering the certificate to you
Most client implementation also have an automated renewal system, further reducing the
workload for sysadmins.
The current specification for the ACME protocol proposes two (2) types of challenges
to prove ownership and control over a domain: [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) and [DNS-01](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) challenge.
{{< callout type="note" >}}
Actually there are two (2) others: [TLS-SNI-01](https://letsencrypt.org/docs/challenge-types/#tls-sni-01) which is now disabled, and [TLS-ALPN-01](https://letsencrypt.org/docs/challenge-types/#tls-alpn-01) which is only aimed at a very
specific category of users, which we will ignore here.
{{< /callout >}}
### The common solution: HTTP challenge
The [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge
is the most common type of ACME challenge, and will satisfy most use-cases.
![A schema describing the HTTP challenge workflow for the ACME protocol and the interactions between the application server, Let's Encrypt, and the DNS server, all of them public.](/images/dns_article_http_challenge.svg)
For this challenge, we need the following elements :
- A domain name and a record for that domain in a public DNS server (it can be a self-hosted DNS server, our providers', etc)
- Access to a server with a public IP that can be publicly reached
When performing this type of challenge, the following happens (in a very simplified way):
1. The ACME client will ask to start a challenge to the Let's Encrypt API
2. In return, it will get a token
3. It will then either start a standalone server, or edit the configuration for our
current web server (nginx, apache, etc) to serve a file containing the token and a fingerprint of our account key.
4. Let's Encrypt will try to resolve our domain `test.example.com`.
5. If resolution works, then it will check the url `http://test.example.com/.well-known/acme-challenge/<TOKEN>`, and verify that the file from step 3 is served with the correct
content.
If everything works as expected, then the ACME client can download the certificate and key, and we can configure our reverse proxy or server to use this valid certificate,
all is well.
{{< callout type="help" >}}
Okay, but my app contains my accounts, or my proxmox management interface, and I
don't really want to make it public, so how does it work here?
{{< /callout >}}
Well it doesn't. For this type of challenge to work, the application server **must** be
public. For this challenge we need to prove that we have control over the application
that uses the target domain (even if we don't control the domain itself). But the
DNS-01 challenge bypasses this limitation.
### When it's not enough: the DNS challenge
As we saw in the previous section, sometimes, for various reasons, the application
server is in a private zone. It must be only reachable from inside a private network,
but we might still want to be able to use a free Let's Encrypt certificate.
For this purpose, the [DNS-01](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) challenge is based on proving that one has control over the **DNS
server** itself, instead of the application server.
![A schema describing the DNS challenge workflow for the ACME protocol and the interaction between Let's Encrypt, the public DNS server and the private application server](/images/dns_article_dns_challenge_1.svg)
For this type of challenge, the following elements are needed :
- A public DNS server we have control over (can be a self-hosted server, or your DNS provider)
- A ACME client (usually it would be on the application server), it doesn't need to be public
Then, the challenge is done the following way :
1. The ACME client will ask to start a challenge to the Let's Encrypt API.
2. In return, it will get a token.
3. The client then creates a `TXT` record at `_acme-challenge.test.example.com` derived from the token
and the account key.
4. Let's Encrypt will try to resolve the expected `TXT` record, and verify that the content is correct.
If the verification succeeds, we can download your certificate and key, just like the other
type of challenge.
It's important to note that **at no point in time did Let's Encrypt have access to the
application server itself**, because this challenges involves proving that we control
the domain, not that we control the destination of that domain.
As someone trying to use a valid certificate for my Proxmox interface, this is the way I
would want to go, because it would allow me to have a valid certificate, despite my server
not being public at all. So let's see how it works in practice.
## DNS challenge in practice
For this example, I will try to obtain a certificate for my own domain
`test.internal.example.com`. As this name hints, it is an internal domain and should not
be publicly reachable, so this means I'm going to use a DNS challenge. I don't really want
to use my DNS provider API for this, so I'm going to use a self-hosted [bind](https://www.isc.org/bind/)
server for that.
### Configuring the DNS server
The first step is configuring the DNS server. For this, I'll just use a [bind](https://bind9.readthedocs.io/en/v9.18.27/)
server installed from my usual package manager.
```bash
# example on Debian 12
sudo apt install bind9
```
Most of the configuration happens in the `/etc/bind` directory, mostly in `/etc/bind/named.conf.local`
```text
root@dns-server: ls /etc/bind/
bind.keys db.127 db.empty named.conf named.conf.local rndc.key
db.0 db.255 db.local named.conf.default-zones named.conf.options zones.rfc1918
```
Let's declare a first zone, for `internal.example.com`. Add the following config to
`/etc/bind/named.conf.local`
```text
zone "internal.example.com." IN {
type master;
file "/var/lib/bind/internal.example.com.zone";
```
This simply declares a new zone which is described in the file `/var/lib/bind/internal.example.com.zone`
Let's now create the zone itself. A DNS zone has a base structure that we must follow
```dns
$ORIGIN .
$TTL 7200 ; 2 hours
internal.example.com IN SOA ns.internal.example.com. admin.example.com. (
2024070301 ; serial
3600 ; refresh (1 hour)
600 ; retry (10 minutes)
86400 ; expire (1 day)
600 ; minimum (10 minutes)
)
NS ns.internal.example.com.
$ORIGIN internal.example.com.
ns A 1.2.3.4
test A 192.168.1.2
```
This file declares a zone `internal.example.com` which master is `ns.internal.example.com`.
It also sets the parameters (time to live for the records, and the current serial for the
zone config).
Finally, two (2) A records are created, associating the name `ns.internal.example.com` to
the IP address `1.2.3.4`, and `test.internal.example.com` (the domain for which we want
a certificate) to a local IP address `192.168.1.2`.
A simple `systemctl restart bind9` would be enough to apply the modification, but we still
have one thing to do, which is allowing remote modifications to the zone.
### Enabling remote DNS zone modification
To allow remote modification of our DNS zone, we are going to use [TSIG](https://www.ibm.com/docs/en/aix/7.3?topic=ssw_aix_73/network/bind9_tsig.htm)
which stands for **Transaction signature**. It's a way to secure server to server operations
to edit a DNS zone, and is preferred to access control based on IP addresses.
Let's start with creating a key using the command `tsig-keygen <keyname>`
```shell
➜ tsig-keygen letsencrypt
key "letsencrypt" {
algorithm hmac-sha256;
secret "oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=";
};
```
This creates a key with the given name using the default algorithm (which is `hmac-sha256`).
The entire output of this command is actually a code block that you can add to your bind9
configuration.
Finally, using `update-policy`, allow this key to be used to update the zone.
```text
update-policy {
grant letsencrypt. zonesub txt;
};
```
{{< callout type="note" >}}
Doing so allows users to update everything in our zone using this key. In fact
we would only need to update `_acme-challenge.test.internal.example.com` as seen
in the DNS challenge description.
If we want a better restriction, then we can use the following configuration instead
```text
update-policy {
grant letsencrypt. name _acme-challenge.test.internal.example.com. txt;
};
```
{{< /callout >}}
This means our entire `named.conf.local` would become something like this
```text
key "letsencrypt" {
algorithm hmac-sha256;
secret "oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=";
};
zone "internal.example.com." IN {
type master;
file "/var/lib/bind/internal.example.com.zone";
update-policy {
grant letsencrypt. zonesub txt;
};
};
```
{{< callout type="warning" >}}
Be **very cautious** about the `.` at the end of the zone name and the key name, they are
easy to miss, and forgetting them will cause issues that would be hard to detect.
{{< /callout >}}
With that being done, you can restart the DNS server and everything is ready server side,
the only remaining thing to do would be the DNS challenge itself.
### Performing the challenge
We start by installing the certbot with the RFC2136 plugin (to perform the DNS challenge).
```shell
apt install python3-certbot-dns-rfc2136
```
It's handled using a `.ini` configuration file, let's put it in `/etc/certbot/credentials.ini`
```ini
dns_rfc2136_server = <you_dns_ip>
dns_rfc2136_port = 53
dns_rfc2136_name = letsencrypt.
dns_rfc2136_secret = oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=
dns_rfc2136_algorithm = HMAC-SHA512
```
Finally, run the challenge using certbot (if it's the first time you're using certbot on
that machine, it might ask for an email to handle admin stuff).
```shell
root@toolbox:~: certbot certonly --dns-rfc2136 --dns-rfc2136-credentials /etc/certbot/credentials.ini -d 'test.internal.example.com'
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Requesting a certificate for test.internal.example.com
Waiting 60 seconds for DNS changes to propagate
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/test.internal.example.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/test.internal.example.com/privkey.pem
This certificate expires on 2024-09-30.
These files will be updated when the certificate renews.
Certbot has set up a scheduled task to automatically renew this certificate in the background.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
If you like Certbot, please consider supporting our work by:
* Donating to ISRG / Let's Encrypt: https://letsencrypt.org/donate
* Donating to EFF: https://eff.org/donate-le
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
```
And that's done, we have a certificate, and a no point in time did we need to
actually expose our application to the outside world.
{{< callout type="warning" >}}
We used `standalone` mode for the certbot here, which means that when it renews
the certificates, certbot will only download the new certificates, and nothing more.
If we use a reverse proxy like `nginx`, we would also need to restart the service
in order to load the new certificates when they are renewed, as certbot would not do
it itself in `standalone` mode.
{{< /callout >}}
Now because I like to go way too far, I can propose two (2) improvements to this
setup:
- Using ACL in addition to the TSIG key to secure operations on the DNS server
- Using a second DNS server only locally accessible for your private records, and
using the public server to only perform challenges
## Bonus 1: adding a second layer of authentication to connect to the DNS
In our setup, we used **TSIG** to secure our access to the DNS server, meaning that
having the key is necessary to perform the operations. If we are paranoid, or if we
want to do a little bit more, then we could add a second layer of authentication based
on [Access Control List (ACL)](https://bind9.readthedocs.io/en/v9.18.1/security.html).
**ACL** allow to filter allowed operations based on several characteristics, such as
IP address, TSIG key, subnet. In our case, we will use an IPV4 subnet from inside a
Wireguard tunnel between the application servers (DNS clients) and the DNS server. It
could be any form of tunnel, but Wireguard is easy to configure and perfect for
point-to-point tunnels such as what we are doing here.
### Wireguard configuration
First, let's create the [Wireguard](https://www.wireguard.com/quickstart/) tunnel.
We start by creating two wireguard key pairs, which can be done this way
```shell
# Install wireguard tools
apt install wireguard-tools
# Create the keypair
wg genkey | tee privatekey | wg pubkey > publickey
```
Private key is in the `privatekey` file, and public key in the `publickey` file.
Then we can create the server configuration, create a file `/etc/wg/wg0.conf` on
the DNS server.
```ini
[Interface]
PrivateKey = <server_private_key>
Address = 192.168.42.1/24
ListenPort = 51820
[Peer]
PublicKey = <client_public_key>
AllowedIPs = 192.168.42.0/24
```
Then on the client side you can do the same
```ini
[Interface]
PrivateKey = <client_private_key>
Address = 192.168.42.2/24
[Peer]
PublicKey = <server_public_key>
Endpoint = <dns_public_ip>:51820
AllowedIPs = 192.168.42.1/32
```
Then you can start the tunnel on both sides using `wg-quick up wg0`, check that ip works
by pinging the server from the client
```shell
root@toolbox:~ ping 192.168.42.1
PING 192.168.42.1 (192.168.42.1) 56(84) bytes of data.
64 bytes from 192.168.42.1: icmp_seq=1 ttl=64 time=19.2 ms
64 bytes from 192.168.42.1: icmp_seq=2 ttl=64 time=8.25 ms
```
Basically, we created a new network `192.168.42.0/24` which links the DNS server and our client,
and we can restrict modification to the DNS zone to force them to be from inside the
virtual network, instead of allowing them from anywhere.
{{< callout type="note" >}}
The ACL that we are going to use here can have many other purposes, such as hiding
some domains, or serving different versions of a zone depending on the origin of
the client. This is not our topic of concern here though.
{{< /callout >}}
### DNS configuration
Using ACLs, we are going to split the DNS zone into several [views](https://kb.isc.org/docs/aa-00851)
based on the source IP. Basically our goal is to say that
- Users coming from inside our wireguard network `192.168.42.0/24` can modify DNS
records in our zone using the TSIG key defined earlier.
- Users coming from any other IP can read the DNS zone, but nothing else, so they can't
update it, even using the correct key.
ACL can be defined inside `named.conf.local` using the following syntax.
```text
acl local {
127.0.0.0/8;
192.168.42.0/24;
};
```
This means that local addresses, and addresses coming from our wireguard network
will be considered as `local` and can be referenced as such in the rest of the
configuration.
Then, a view can be created like this:
```text
view "internal" {
match-clients { local; };
zone "internal.example.com." IN {
type master;
file "/var/lib/bind/internal.example.com.zone";
update-policy {
grant letsencrypt. zonesub txt;
};
};
};
```
Basically this means that the view `internal` is only used for clients that match
the `local` ACL defined above. In this view we define the zone `internal.example.com`,
which is the zone we defined earlier.
We also need to declare the zone for non-local users who wouldn't match the `local` ACL.
It's important to note that **you cannot use the same zone file twice in different zones**,
so we cannot define the public view exactly the same way. Our public view will be
defined the following way:
```text
view "public" {
zone "internal.example.com." IN {
in-view internal;
};
};
```
This way, in the `public` view, we define the `internal.example.com` zone, and we
define this zone as being inside the `internal` view. This way, we will serve the
exact same DNS zone whatever the origin, but the *update policy* only applies to user
from local addresses, and they will be the only ones able to edit the zone.
In summary, our `named.conf.local` file should now look like this.
```text
acl local {
127.0.0.0/8;
192.168.42.0/24;
};
key "letsencrypt." {
algorithm hmac-sha512;
secret "oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=";
};
view "internal" {
match-clients { local; };
zone "internal.example.com." IN {
type master;
file "/var/lib/bind/internal.example.com.zone";
update-policy {
grant letsencrypt. zonesub txt;
};
};
};
view "public" {
zone "internal.example.com." IN {
in-view internal;
};
};
```
And now, without any additional change needed, we have a second layer of authentication
for the DNS zone updates. We can go a little further and make sure that the private IPs
themselves are hidden from the outside.
## Bonus 2: completely hiding your private domains from outside
In this post, we implemented our own DNS server (or we used the one from our provider) in
order to resolve internal private hosts, and perform DNS challenges for those hosts in order
to obtain SSL certificates. But this is not entirely satisfying.
For example, we have the following record in our DNS zone:
```text
test A 192.168.1.2
```
This means that running `host test.internal.example.com` (or dig, or any other DNS query tool)
will return `192.168.1.2`, whether you're using your internal DNS, or Google's, or any
other server. This is not great: this IP is private, and should not have any meaning
outside of your network, and, while there wouldn't probably be any impact, publicly
giving the information that you have a private host named `test` on an internal domain,
its IP address (and thus par of your internal infrastructure) isn't great, especially
if you have 10 hosts instead of only one.
For this reason we could use two (2) DNS servers with a different purpose:
- A server inside the private network which would resolve the private hosts
- A server outside the private network, which is only used for the challenges
Indeed, inside our network, we don't really need to be publicly reachable, but we need
name resolution on our local hosts. In the same way, Let's Encrypt doesn't need any
`A` record to perform DNS challenges, it only needs a `TXT` record, so each server
can have its own specific role.
![A schema describing the DNS challenge workflow for the ACME protocol with a separation between a public and a private DNS servers and the interaction between Let's Encrypt and the public DNS server on one side, and the private application server, the user, and the private DNS server on the other side](/images/dns_article_dns_challenge_2.svg)
Basically, what we need is the following:
- a publicly reachable DNS server (the one from the previous parts of this post), that will
have:
- only its own `NS` records
- the TSIG key and rules to update the zone
- optionally, the VPN tunnel
- the `TXT` records to perform the DNS challenges
- a private DNS on your local infrastructure, that will have
- all the `A` (and other types of) DNS records for your internal infrastructure
Let's split the previous configuration (I'll use the one from the [Bonus 1](#bonus-1-adding-a-second-layer-of-authentication-to-connect-to-the-dns) section as an example
### Private DNS server
On the private DNS server, the only thing we need is our local `internal.example.com` zone
definition, so our `named.conf.local` should look like this
```text
zone "internal.example.com" IN {
type master;
file "/var/lib/bind/internal.example.com.zone";
allow-update { none; };
};
```
And our zone definition would look like this
```text
$ORIGIN .
$TTL 7200 ; 2 hours
internal.example.com IN SOA ns.internal.example.com. admin.example.com. (
2024070301 ; serial
3600 ; refresh (1 hour)
600 ; retry (10 minutes)
86400 ; expire (1 day)
600 ; minimum (10 minutes)
)
NS ns.internal.example.com.
$ORIGIN internal.example.com.
ns A 192.168.1.1
test A 192.168.1.2
```
This server should be set as DNS in our DHCP configuration (or in the client
configuration if we don't use DHCP).
### Public DNS server
For the public DNS server, we don't need private `A` records, we just need the
configuration necessary to update the public zone, so our `named.conf.local`
file should look like this (it's the exact same configuration as before)
```text
acl local {
127.0.0.0/8;
192.168.42.0/24;
};
key "letsencrypt." {
algorithm hmac-sha512;
secret "oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=";
};
view "internal" {
match-clients { local; };
zone "internal.example.com." IN {
type master;
file "/var/lib/bind/internal.example.com.zone";
update-policy {
grant letsencrypt. zonesub txt;
};
};
};
view "public" {
zone "internal.example.com." IN {
in-view internal;
};
};
```
The zone file should be the following (we only removed the private `A` record,
the rest is the same as before).
```text
$ORIGIN .
$TTL 7200 ; 2 hours
internal.example.com IN SOA ns.internal.example.com. admin.example.com. (
2024070301 ; serial
3600 ; refresh (1 hour)
600 ; retry (10 minutes)
86400 ; expire (1 day)
600 ; minimum (10 minutes)
)
NS ns.internal.example.com.
$ORIGIN internal.example.com.
ns A 1.2.3.4
test A 192.168.1.2
```
### Testing the configuration
Once the two servers are up and running, and everything is configured as expected,
we can test that everything works as expected by trying to perform a DNS query
using `hosts`, `dig`, etc on our private records and our `NS` record.
```shell
# Trying to resolve our domain from inside our private infra returns the expected IP
~ …
➜ host test.internal.example.com
Using domain server:
Name: 192.168.1.1
Address: 192.168.1.11#53
Aliases:
test.internal.example.com has address 192.168.1.2
# Trying to resolve our domain using a public DNS server (here Google)
# fails since it doesn't exist outside our network
~ …
➜ host test.internal.example.com 8.8.8.8
Using domain server:
Name: 8.8.8.8
Address: 8.8.8.8#53
Aliases:
Host test.internal.example.com not found: 3(NXDOMAIN)
```
## Final words
While this method, including the small adjustments and improvements, is a bit
more involved than ignoring the issue and using only HTTP challenges, when the
infrastructure is in place it becomes very easy to use and to set-up, and makes
for a very clean infrastructure.
It is also the only way to obtain a wildcard certificate `*.internal.example.com`
for example that would allow using a single certificate for all the services inside
an infrastructure.
I would argue that a setup of this type is very adapted to homelabs or small businesses
that have a private infrastructure, but don't want to go through the trouble of setuping
an entire PKI (Private Key Infrastructure).

View file

@ -0,0 +1,368 @@
---
title: "Comment faire du HTTPS chez soi (quand son infrastructure est privée)"
date: 2024-07-02T21:00:50+02:00
draft: true
toc: true
images:
tags:
- self-hosting
- sysadmin
---
## Le problème quand on a une infrastructure chez soi
Cela fait plusieurs années que je maintiens ma propre infrastructure chez
moi, mais l'un des problèmes les plus pénibles lorsque l'on commence ce
type de projet est la fameuse page **Attention: risque de sécurité** qui
apparaît lorsque l'on utilise un certificat autosigné, ou lorsque l'on essaye
d'utiliser un mot de passe sur un site web ou une application servie uniquement
via HTTP.
![Une capture d'écran de la page Firefox indiquant que le site web auquel on essaye d'accéder n'est pas sécurisé.](/images/dns_article_firefox_warning.png)
Si on peut accepter cela si on est seul·e sur son infrastructure, ou son
environnement de dev, cela commence à poser des problèmes dans d'autres contextes :
- Ce n'est pas acceptable d'exposer publiquement un site web présentant ce problème
- Cela paraît douteux de conseiller à ses ami·es ou fanille "t'inquiète, je sais que
ton navigateur te montre un gros avertissement en rouge là, mais tu peux accepter".
C'est juste une très mauvaise habitude à avoir
- Au bout d'un moment c'est vraiment pénible de voir cette page à chaque fois
Heureusement, il y a une solution pour cela. Vous la connaissez sûrement déjà, et cela
fait maintenant presque dix (10) ans que cela existe, c'est
[Let's Encrypt, et le protocole ACME](https://letsencrypt.org/).
{{< callout type="note" >}}
Je vous jure que ce n'est pas encore un nouveau tuto Let's Encrypt. Enfin, en un sens
c'est le cas, mais c'est un peu plus spécifique ici.
{{< /callout >}}
## La solution Let's Encrypt
### Let's Encrypt, c'est quoi?
[Let's Encrypt](https://letsencrypt.org/) est une autorité de certification non-lucrative
fondée en novembre 2014. Son objectif principal était de proposer une façon facile, et
gratuite d'obtenir un certificat TLS afin de rendre facile le HTTPS partout sur le Web.
Le [protocole ACME](https://letsencrypt.org/docs/client-options/), développé par Let's
Encrypt, est un système de vérification automatique visant à faire la chose suivante :
- vérifier que l'on possède le domaine pour lequel on veut obtenir un certificat
- créer et enregistrer ce certificat
- délivrer le certificat à l'utilisateur
La majorité des implémentations client de ce protocole proposent également un système
automatisé de renouvellement, réduisant davantage la charge de travail pour les
administrateur·ices système.
Les spécifications actuelles du protocol ACME offrent le choix entre deux (2) types de
challenge afin de prouver le contrôle sur le domaine : [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) et [DNS-01](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge).
{{< callout type=note >}}
En vérité, il existe deux (2) autres types de challenges : [TLS-SNI-01](https://letsencrypt.org/docs/challenge-types/#tls-sni-01) aujourd'hui déprécié et désactivé, ainsi que [TLS-ALPN-01](https://letsencrypt.org/docs/challenge-types/#tls-alpn-01).
Ce dernier s'adresse à un public très spécifique, et nous allons donc complètement l'ignorer
pour le moment.
{{< /callout >}}
### La solution habituelle : le challenge HTTP
Le challenge [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge)
est le type de challenge ACME le plus courant. Il est largement suffisant pour la majorité
des cas d'usage.
![Un schema décrivant le fonctionnement du challenge HTTP challenge pour le protocole ACME et les interactions entre le serveur applicatif, Let's Encrypt, et le serveur DNS, tous publics.](/images/dns_article_http_challenge.svg)
Pour ce challenge, on a besoin des éléments suivants :
- Un nom de domaine et un enregistrement pour ce domaine dans un serveur DNS public
(cela peut être un serveur DNS self-hosted, celui du provider DNS, etc)
- Un accès à un serveur avec une adresse IP publiquement accessible
Ce type de challenge se déroule de la façon suivante (de façon schématisée et simplifiée)
1. Le client ACME contacte l'API Let's Encrypt afin de commencer le challenge
2. Il obtient un token
3. Ensuite, il va démarrer un serveur dédié, ou éditer la configuration du serveur Web actuel
(nginx, apache, etc) afin de serveur un fichier contenant le token et l'empreinte de la clé
de notre compte Let's Encrypt
4. Let's Encrypt va ensuite essayer de résoudre notre domaine `test.example.com`
5. Si la résolution fonctionne, Let's Encrypt va accéder à l'URL `http://test.example.com/.well-known/acme-challange/<TOKEN>` et vérifier que le fichier généré à l'étape 3 est bien servi avec le
contenu attendu
Si tout se déroule comme prévu, alors le client ACME peut télécharger le certificat et sa clé,
et on peut configurer notre reverse-proxy ou serveur pour utiliser ce certificat, et c'est bon.
{{< callout type=help >}}
Ok, super, mais mon app c'est l'interface de management de mon serveur Proxmox, et franchement
je ne veux vraiment pas la rendre accessible publiquement, du coup ça marchera comment ?
{{< /callout >}}
Dans ce cas, ce type de challenge ne fonctionnera tout simplement pas. En effet, pour ce type
de challenge, le serveur applicatif **doit** être public. Ce type de challenge cherche à montrer
que l'on a le contrôle sur la destination ciblée par le domaine (même si on n'a pas le contrôle
sur le domaine lui-même). En revanche, le challenge DNS-01 permet de répondre à ce besoin.
### Si cela ne suffit pas : le challenge DNS
Comme on l'a dit précédemment, parfois, pour diverses raisons, le serveur applicatif se trouve
dans une zone privée. Il ne doit être accessible que depuis un réseau privée, mais on aimerait
quand même pouvoir avoir recours à un certificat gratuit Let's Encrypt
Dans cet objectif, il existe le challenge [DNS-01](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge), dont l'objectif est de prouver le contrôle du **serveur DNS** (ou
plutôt de la zone DNS), et non du serveur applicatif.
![Un schema décrivant le fonctionnement du challenge DNS pour le protocole ACME ainsi que les interactions entre Let's Encrypt, le serveur DNS public et le serveur applicatif privé](/images/dns_article_dns_challenge_1.svg)
Pour ce type de challenge, les éléments suivants sont nécessaires :
- Un serveur DNS public dont on a le contrôle (ici encore, il peut s'agit d'un serveur self-hosted,
de l'interface de notre provider, etc)
- Un client ACME qui n'a pas besoin d'être sur une machine publiquement accessible
Ensuite, le challenge se déroule de la façon suivante :
1. Le client ACME demande à l'API Let's Encrypt de démarrer le challenge
2. Le client obtient un token en retour
3. Le client crée ensuite un enregistrement `TXT` sur `_acme-challenge.test.example.com`, dont
la valeur est dérivée du token et de la clé utilisateur Let's Encrypt
4. Let's Encrypt essaye de résoudre l'enregistrement `TXT` en question, et vérifie que le contenu
est correct.
Si la vérification se déroule correctement, il est ensuite possible de télécharger le certificat
et la clé associée, comme pour le type de challenge précédent.
Il faut noter que **à aucun moment Let's Encrypt n'a eu besoin d'avoir accès au serveur de l'application**
car ce challenge a pour objectif de montrer que l'on contrôle le domaine, pas que l'on contrôle
la destination de ce domaine.
Si je veux obtenir un certificat valide et vérifiable pour mon interface Proxmox, c'est ce type
de challenge que je vais vouloir utiliser. En effet, cela me permet d'obtenir mon certificat
valide sans pour autant rendre public mon serveur Proxmox. Voyons-voir comment cela fonctionne
en pratique.
## Comment faire un challenge DNS en pratique
Dans le cadre de cet example, je vais essayer d'obtenir un certificat pour mon propre domaine
`test.internal.example.com`. Comme ce nom le suggère, il s'agit d'un domaine interne qui ne
devrait pas être publiquement accessible, ce qui signifie que je vais utiliser un challenge
DNS. Je ne veux pas vraiment utiliser l'API de mon provider DNS pour cela, et je vais donc
me reposer sur un serveur [bind](https://www.isc.org/bind/) self-hosted.
### Configuration du serveur DNS
La première étape est de configurer un serveur DNS. Pour cela, je vais simplement utiliser
un server [bind](https://bind9.readthedocs.io/en/v9.18.27/) installé depuis mon gestionnaire
de paquets habituel.
```bash
# exemple pour Debian 12
sudo apt install bind9
```
La majorité de la configuration se produit dans le dossier `/etc/bind/`, et principalement
dans le fichier `/etc/bind/named.conf.local`
```shell
root@dns-server: ls /etc/bind/
bind.keys db.127 db.empty named.conf named.conf.local rndc.key
db.0 db.255 db.local named.conf.default-zones named.conf.options zones.rfc1918
```
Commençons par déclarer une première zone pour le domaine `internal.example.com`. On ajoute
la configuration suivante au fichier `/etc/bind/named.conf.local`
```text
zone "internal.example.com." IN {
type master;
file "/var/lib/bind/internal.example.com.zone";
```
Cela permet de déclarer une nouvelle zone dont le contenu est décrit dans le fichier
`/var/lib/bind/internal.example.com.zone`.
À présent, on va créer la zone elle-même. Une zone DNS a une structure de base qu'il faut suivre.
```dns
$ORIGIN .
$TTL 7200 ; 2 hours
internal.example.com IN SOA ns.internal.example.com. admin.example.com. (
2024070301 ; serial
3600 ; refresh (1 hour)
600 ; retry (10 minutes)
86400 ; expire (1 day)
600 ; minimum (10 minutes)
)
NS ns.internal.example.com.
$ORIGIN internal.example.com.
ns A 1.2.3.4
test A 192.168.1.2
```
Ce fichier déclare une zone `internal.example.com`, dont le serveur maître a pour nom
`ns.internal.example.com`. On définit également une série de paramètres (durée de vie
des enregistrements, numéro de série de la zone, etc).
Enfin, on crée deux (2) enregistrements `A` afin d'associer le nom `ns.internal.example.com`
à l'adresse IP `1.2.3.4`, et `test.internal.example.com` (qui est le domaine pour lequel on
voudra un certificat) à une adresse IP locale `192.168.1.2`.
Enfin, un simple `systemctl restart bind9` (sur Debian) permettrait d'appliquer directement
ces modifications. Mais il nous reste encore une chose à configurer avant : permettre la
modification de cette zone à distance.
### Activation des modifications de la zone à distance
Afin de permettre de modifier notre zone DNS à distance, nous allons utiliser
[TSIG](https://www.ibm.com/docs/en/aix/7.3?topic=ssw_aix_73/network/bind9_tsig.htm), ce
qui signifie **Transaction signature**. C'est une façon de sécuriser des opérations
entre serveurs afin d'éditer une zone DNS, et elle est préférée par rapport à une simple
sécurisation basée sur l'adresse IP des clients par exemple.
Commençons par créer une clé en utilisant la commande `tsig-keygen <keyname>`.
```shell
➜ tsig-keygen letsencrypt
key "letsencrypt" {
algorithm hmac-sha256;
secret "oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=";
};
```
Cela permet de créer une clé qui a le nom donné en paramètre en utilisant l'algorithme
par défaut (ici il s'agit de `hmac-sha256`). L'intégralité de la sortie de cette commande
est en fait un bloc de configuration que l'on peut ajouter au reste de la configuration
de `bind`.
Enfin, en utilisant la directive `update-policy`, on peut permettre à cette clé d'autoriser
des modifications à distance de notre zone.
```text
update-policy {
grant letsencrypt. zonesub txt;
};
```
{{< callout type=note >}}
En faisant cela, nous autorisons les utilisateur·ices à modifier l'intégralité de cette
zone en utilisant cette clé. En fait, ici nous n'aurions besoin de modifier que l'enregistrement
`TXT` `_acme-challenge.test.internal.example.com`, comme ce qui est spécifié pour le challenge
DNS.
Si on veut une meilleure restriction, on peut utiliser cette configuration à la place,
et dans ce cas n'autoriser que la modification d'un enregistrement spécifique.
```text
update-policy {
grant letsencrypt. name _acme-challenge.test.internal.example.com. txt;
};
```
{{< /callout >}}
Cela veut dire que le contenu de notre fichier `named.conf.local` devient
```text
key "letsencrypt" {
algorithm hmac-sha256;
secret "oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=";
};
zone "internal.example.com." IN {
type master;
file "/var/lib/bind/internal.example.com.zone";
update-policy {
grant letsencrypt. zonesub txt;
};
};
```
{{< callout type="warning" >}}
Il faut faire **très attention** au `.` à la fin du nom de la zone ainsi que du nom de la clé,
c'est très facile de les oublier, ce qui causerait des problèmes difficiles à détecter.
{{< /callout >}}
### Réalisation du challenge
On commence par installer le certbot avec le plugin **RFC 2136** (qui nous permet de réaliser
les challenges DNS).
```shell
apt install python3-certbot-dns-rfc2136
```
Le certbot se configure via un fichier de configuration au format `.ini`, on va le placer
dans le fichier `/etc/certbot/credentials.ini`.
```ini
dns_rfc2136_server = <you_dns_ip>
dns_rfc2136_port = 53
dns_rfc2136_name = letsencrypt.
dns_rfc2136_secret = oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=
dns_rfc2136_algorithm = HMAC-SHA512
```
Enfin, on peut lancer le challenge en utilisant le certbot (si c'est la première fois que
le bot est utilisé sur notre machine, on nous demandera d'accepter les conditions d'utilisation
et de donner une adresse email pour la gestion administrative des certificats et les notifications
de renouvellement, c'est normal.)
```shell
root@toolbox:~: certbot certonly --dns-rfc2136 --dns-rfc2136-credentials /etc/certbot/credentials.ini -d 'test.internal.example.com'
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Requesting a certificate for test.internal.example.com
Waiting 60 seconds for DNS changes to propagate
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/test.internal.example.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/test.internal.example.com/privkey.pem
This certificate expires on 2024-09-30.
These files will be updated when the certificate renews.
Certbot has set up a scheduled task to automatically renew this certificate in the background.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
If you like Certbot, please consider supporting our work by:
* Donating to ISRG / Let's Encrypt: https://letsencrypt.org/donate
* Donating to EFF: https://eff.org/donate-le
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
```
Et c'est bon, on a un certificat, et à aucun moment nous n'avons eu besoin d'exposer une
application au monde extérieur.
{{< callout type="warning" >}}
On a utilisé ici le mode `standalone` pour le certbot, ce qui signifie que lorsque les
certificats sont renouvelés, aucune action supplémentaire n'est exécutée. Si on utilise
un reverse proxy comme `nginx`, il faut également redémarrer le serveur (ou le recharger)
afin de charger les nouveaux certificats après renouvellement car le `certbot` ne le fait
pas de lui-même en mode `standalone`.
{{< /callout >}}
Maintenant, comme j'aime aller beaucoup trop loin dans tout ce que je fais. on peut ajouter
deux (2) améliorations à notre setup :
- Utiliser des ACL (Contrôle d'Accès) en plus des clés TSIG pour sécuriser les opérations sur
notre serveur DNS
- Utiliser un second serveur DNS accessible uniquement localement contenant nos enregistrements
privés, et utiliser le serveur public uniquement pour réaliser les challenges
## Bonus 1 : ajouter une couche d'authentification pour se connecter au DNS
Dans notre setup, on a utilisé **TSIG** afin de sécuriser l'accès au serveur DNS, ce qui
signifie que la possession de la clé est nécessaire pour effectuer les opérations. Si vous
êtes paranoïaque, ou que vous voulez juste en faire un peu plus, il est possible d'ajouter
une seconde couche d'authentification en utilisant des [Listes de Contrôle d'Accès (ACL)](https://bind9.readthedocs.io/en/v9.18.1/security.html).
Les **ACL** nous permettent de filtrer les opérations autorisées en se basant sur plusieurs
caractéristiques, par exemple l'adresse IP, une clé TSIG, etc. Dans notre cas, nous allons
utiliser un sous-réseau IPV4 qui se situe à l'intérieur d'un tunnel Wireguard entre notre
serveur applicatif (client DNS qui veut réaliser le challenge) et le serveur DNS. Cela peut
être n'importe quel type de tunnel, mais Wireguard est facile à configurer et est parfait pour
notre cas d'usage.
### Configuration de Wireguard
Commençons par créer le tunnel [Wireguard](https://www.wireguard.com/quickstart/)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 149 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 43 KiB