Add article on DNS challenge
This commit is contained in:
parent
7dd8343680
commit
d354f8a830
7 changed files with 1440 additions and 5 deletions
10
config.toml
10
config.toml
|
@ -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"
|
||||
|
|
685
content/en/posts/dns-challenge.md
Normal file
685
content/en/posts/dns-challenge.md
Normal file
|
@ -0,0 +1,685 @@
|
|||
---
|
||||
title: "How to do HTTPS at home (when your infrastructure is private)"
|
||||
date: 2024-07-21T21:00:50+02:00
|
||||
draft: false
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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 is well known by now, 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
If I'm trying to obtain 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 suggests, 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.
|
||||
|
||||
{{< callout type="note" >}}
|
||||
The rest of this "guide" will be based on a deployment for a `bind9` server. It can be
|
||||
adapted to any other type of deployment, but all the configuration snippets are based
|
||||
on `bind9`. Let's Encrypt has [relevant documentations](https://community.letsencrypt.org/t/dns-providers-who-easily-integrate-with-lets-encrypt-dns-validation/86438) for
|
||||
other hosting providers.
|
||||
{{< /callout >}}
|
||||
|
||||
### 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 our 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.
|
||||
|
||||

|
||||
|
||||
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` record 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).
|
713
content/fr/posts/dns-challenge.md
Normal file
713
content/fr/posts/dns-challenge.md
Normal file
|
@ -0,0 +1,713 @@
|
|||
---
|
||||
title: "Comment faire du HTTPS chez soi (quand son infrastructure est privée)"
|
||||
date: 2024-09-08T15:18:00+02:00
|
||||
draft: false
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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 propose é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.
|
||||
|
||||

|
||||
|
||||
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, ici, 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
{{< callout type="note" >}}
|
||||
Pour le reste de ce "guide", je vais me baser sur un déploiement pour un serveur DNS
|
||||
`bind9`. Tout est adaptable pour n'importe quel autre type de déploiement, mais tous
|
||||
les exemples de configurations sont spécifiques à `bind9`. Let's Encrypt propose
|
||||
de la [documentation adaptée](https://community.letsencrypt.org/t/dns-providers-who-easily-integrate-with-lets-encrypt-dns-validation/86438) à d'autres
|
||||
types de déploiements.
|
||||
{{< /callout >}}
|
||||
|
||||
### 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/)
|
||||
|
||||
On commence par créer deux paires de clé Wireguard, cela peut se faire de la façon suivante
|
||||
|
||||
```shell
|
||||
# Installation des outils Wireguard
|
||||
apt install wireguard-tools
|
||||
|
||||
# Création de la paire de clés
|
||||
wg genkey | tee privatekey | wg pubkey > publickey
|
||||
```
|
||||
|
||||
La clé privée se trouve dans le fichier `privatekey`, et la clé publique dans le fichier
|
||||
`publickey`.
|
||||
|
||||
Ensuite, on peut créer la configuration du serveur. Commençons par placer un fichier
|
||||
`/etc/wg/wg0.conf` dans le serveur DNS avec le contenu suivant
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Ensuite, du côté de notre client, on peut faire la même chose
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Enfin, on démarre le tunnel des deux cotés en utilisant la commande `wg-quick up wg0`. Un
|
||||
ping nous permet de vérifier que le tunnel est opérationnel et que le client peut bien
|
||||
atteindre le serveur.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Pour résumer, on vient de créer un nouveau réseau privé `192.168.42.0/24` qui lie le serveur
|
||||
DNS et notre client, et nous pouvons restreindre les modifications à la zone DNS afin de ne
|
||||
les autoriser que depuis ce réseau privé virtuel, et non de partout comme c'était le cas
|
||||
auparavant.
|
||||
|
||||
{{< callout type="note" >}}
|
||||
Les Contrôles d'Accès que nous allons utiliser ici ont plusieurs autres usages possibles, tels
|
||||
que cacher certains domaines, ou servir différentes versions d'une zone en fonction de
|
||||
l'origine du client. Ça n'est pas vraiment l'objet de ce post cependant.
|
||||
{{< /callout >}}
|
||||
|
||||
### Configuration du serveur DNS
|
||||
|
||||
En utilisant des ACLs, nous allons séparer la zone DNS en différentes [vues](https://kb.isc.org/docs/aa-00851) basées sur l'adresse IP source. Notre objectif est donc que :
|
||||
|
||||
- les utilisateur·ices venant de notre réseau Wiregaurd `192.168.42.0/24` puissent modifier
|
||||
les enregistrements DNS en utilisant la clé TSIG définie précédemment
|
||||
- les utilisateur·ices venant de toute autre adresse IP puissent lire notre zone DNS, mais
|
||||
sans pouvoir la modifier, même avec la bonne clé TSIG
|
||||
|
||||
Les ACL se définissent dans `named.conf.local` en utilisant la syntaxe suivante
|
||||
|
||||
```text
|
||||
acl local {
|
||||
127.0.0.0/8;
|
||||
192.168.42.0/24;
|
||||
};
|
||||
```
|
||||
|
||||
Cela permet de considérer que les adresses locales, ainsi que les adresses de notre réseau
|
||||
Wireguard sont dans un groupe nommé `local`, et on peut utiliser ce groupe pour les
|
||||
référencer dans le reste de notre configuration.
|
||||
|
||||
Ensuite, une vue se crée de la façon suivante :
|
||||
|
||||
```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;
|
||||
};
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Globalement, cela signifie que notre vue `internal` n'est utilisable que pour les clients
|
||||
qui correspondent bien à notre liste d'accès `local` que nous venons de définir. Dans cette
|
||||
vue, nous définissons la zone `internal.example.com` comme dans les sections précédentes.
|
||||
|
||||
Nous devons également déclarer la zone pour les utilisateurs non-locaux qui ne vont dont
|
||||
pas correspondre à l'ACL `local`. Il est important de souligner que **l'on ne peut pas
|
||||
utiliser le même fichier de zone pour deux zones différentes**, donc nous ne pouvons
|
||||
pas définir la vue publique de la même façon que notre vue privée. Nous allons avoir
|
||||
recours à la syntaxe suivante :
|
||||
|
||||
```text
|
||||
view "public" {
|
||||
zone "internal.example.com." IN {
|
||||
in-view internal;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Avec cette syntaxe, dans la vue `public`, nous définissons la zone `internal.example.com`
|
||||
en indiquant que cette zone se trouve dans la vue `internal`. De cette façon nous servons
|
||||
exactement la même zone DNS quelle que soit l'origine du client, mais la **politique de mise
|
||||
à jour** ne s'applique pour les utilisateurs avec une adresse locale, qui seront donc les
|
||||
seuls à avoir le droit de mettre à jour la zone.
|
||||
|
||||
En résumé, notre fichier `named.conf.local` devrait ressembler à cela
|
||||
|
||||
```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;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Bonus 2 : complètement cacher les adresses privées
|
||||
|
||||
Dans cet article, nous avons implémenté notre propre serveur DNS (ou utilisé celui de notre
|
||||
provider) afin de résoudre des domaines privés internes et effectuer des challenges DNS pour
|
||||
ces hôtes dans le but d'obtenir des certificats SSL. Cependant il reste un élément peu satisfaisant.
|
||||
|
||||
Par exemple, prenons cet enregistrement dans notre zone DNS
|
||||
|
||||
```text
|
||||
test A 192.168.1.2
|
||||
```
|
||||
|
||||
Cela signifie qu'en utilisant la commande `host test.internal.example.com` (ou dig, ou
|
||||
n'importe quel autre outil client DNS) va renvoyer l'adresse `192.168.1.2`, que l'on
|
||||
utilise notre serveur DNS, celui de Google, etc. C'est un peu dommage : cette adresse
|
||||
est privée, elle n'a aucun sens en dehors de sa propre infrastructure, et, même si l'impact
|
||||
est limité, donner des informations sur la nature de notre infrastructure publique est
|
||||
généralement non-désirable.
|
||||
|
||||
Pour corriger ce problème, nous pourrions utiliser deux (2) serveurs DNS ayant chacun leur
|
||||
propre rôle :
|
||||
|
||||
- Un serveur à l'intérieur du réseau privé qui servira à résoudre ces hôtes privés
|
||||
- Un serveur à l'extérieur du réseau public qui servira uniquement pour les challenges DNS
|
||||
|
||||
En effet, à l'intérieur de notre réseau, nous n'avons pas besoin d'être publiquement accessible,
|
||||
mais nous avons besoin d'une résolution de nos noms locaux. De même, Let's Encrypt n'a besoin
|
||||
d'aucun enregistrement `A` pour effectuer le challenge DNS, nous n'avons besoin que d'un
|
||||
unique enregistrement `TXT`, donc chaque serveur a son propre rôle.
|
||||
|
||||

|
||||
|
||||
Globalement, nous avons besoin des éléments suivants :
|
||||
|
||||
- un serveur DNS publiquement accessible (concrètement celui des précédentes parties de cet
|
||||
article) qui aura :
|
||||
- uniquement son propre enregistrement `NS`
|
||||
- la clé TSIG et les règles permettant la mise à jour de sa zone
|
||||
- le tunnel VPN optionnel
|
||||
- l'enregistrement `TXT` permettant la réalisation des challenges DNS
|
||||
- un serveur DNS privé dans l'infrastructure locale qui aura :
|
||||
- tous les enregistrements `A` (et autres) de l'infrastructure privée
|
||||
|
||||
Nous allons donc séparer la configuration précédente (je vais utiliser la configuration
|
||||
finale obtenue dans la section [Bonus 1](#bonus-1--ajouter-une-couche-dauthentification-pour-se-connecter-au-dns)
|
||||
en guise d'exemple)
|
||||
|
||||
### Serveur DNS privé
|
||||
|
||||
Du côté du serveur privé, nous n'avons besoin que de définir notre zone locale
|
||||
`internal.example.com`. Donc notre fichier `named.conf.local` n'a besoin que
|
||||
de la configuration suivante
|
||||
|
||||
```text
|
||||
zone "internal.example.com" IN {
|
||||
type master;
|
||||
file "/var/lib/bind/internal.example.com.zone";
|
||||
allow-update { none; };
|
||||
};
|
||||
```
|
||||
|
||||
Et la définition de notre zone ressemblerait à ceci
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Ce serveur devrait être configuré en tant que serveur DNS principal dans notre configuration
|
||||
DHCP (ou en configuration statique le cas échéant).
|
||||
|
||||
### Serveur DNS public
|
||||
|
||||
Pour le serveur DNS public, nous n'avons plus besoin des enregistrements `A` précédents,
|
||||
mais uniquement de la configuration nécessaire afin de pouvoir mettre à jour la zone
|
||||
à distance. Notre fichier `named.conf.local` devrait donc ressembler à ceci (il s'agit
|
||||
de la même configuration qu'avant en vérité)
|
||||
|
||||
```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;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Le fichier de zone devrait ressembler à ceci (nous avons enlevé l'enregistrement `A` qui n'a
|
||||
plus sa place dans la zone publique)
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### Tester la configuration
|
||||
|
||||
Une fois que les deux serveurs sont opérationnels, et que tout est correctement configuré,
|
||||
nous pouvons tester que tout fonctionne comme attendu. Il suffit d'effectuer une requête DNS
|
||||
en utilisant `host`, `dig`, ... sur notre enregistrement privé afin de vérifier que
|
||||
le comportement attendu a bien lieu.
|
||||
|
||||
```shell
|
||||
# Nous pouvons bien atteindre notre domaine privé depuis notre infrastructure
|
||||
~ …
|
||||
➜ 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
|
||||
|
||||
# Utiliser un serveur DNS public (ici 8.8.8.8) ne fonctionne pas
|
||||
# car cet enregistrement n'existe pas en dehors de notre réseau
|
||||
~ …
|
||||
➜ 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)
|
||||
```
|
||||
|
||||
## Mot de la fin
|
||||
|
||||
Cette méthode (en considérant également les améliorations proposées) est plus exigeante
|
||||
que simplement ignorer le problème et n'utiliser que des challenges HTTP. Mais une fois que
|
||||
l'infrastructure est en place, cela devient très simple à mettre en place, et permet
|
||||
d'obtenir une infrastructure très propre et facile à maintenir.
|
||||
|
||||
Il s'agit également de la seule façon possible d'obtenir un certificat wildcard (par exemple
|
||||
`*.internal.example.com`) qui permettrait d'utiliser un unique certificat pour la totalité de
|
||||
notre infrastructure.
|
||||
|
||||
Je considère que ce type de setup est particulièrement adapté à un homelab, ou a un business
|
||||
de petite taille possédant une infrastructure privée, mais ne souhaitant pas s'infliger
|
||||
la création d'une infrastructure PKI (Infrastructure de Clé Privée) complète.
|
8
static/images/dns_article_dns_challenge_1.svg
Normal file
8
static/images/dns_article_dns_challenge_1.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 149 KiB |
8
static/images/dns_article_dns_challenge_2.svg
Normal file
8
static/images/dns_article_dns_challenge_2.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 159 KiB |
BIN
static/images/dns_article_firefox_warning.png
Normal file
BIN
static/images/dns_article_firefox_warning.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 51 KiB |
21
static/images/dns_article_http_challenge.svg
Normal file
21
static/images/dns_article_http_challenge.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 43 KiB |
Loading…
Add table
Reference in a new issue