Documentation technique du service dns.shaftinc.fr


Everything-over-TLS

Mise à jour du 12/09/2021 : Prise en compte de la version 1.6.0 de dnsdist.

Après des mois de procrastination bêta-tests privés, je lance enfin mon résolveur DNS sur TLS (DoT) et DNS sur HTTPS (DoH) public : dns.shaftinc.fr. Pour la politique suivie par ce résolveur et la configuration côté client, voir la page dédiée à ces questions. Ce billet traite de la configuration du serveur, au cas où des gens veulent également se lancer dans l'aventure. Attention, billet long et technique.

dns.shaftinc.fr utilise 2 logiciels : Unbound et dnsdist. dnsdist est un répartiteur de charge pour serveurs DNS avec la particularité de gérer DoH et DoT. Le but est donc de l'installer sur la même machine qu'Unbound et de le mettre devant : Unbound n'écoutera que localement et dnsdist, lui, sera ouvert au public. Il est bien évidemment possible d'utiliser un autre résolveur (Knot Resolver, PowerDNS Recursor...) ou bien d'utiliser un résolveur distant (sous votre contrôle de préférence). Le principe général étant posé, voyons comment configurer le tout. Commme d'habitude sur ce blog, les exemples sont valables pour Debian (Bullseye minimum, la version de dnsdist dans Buster est trop vieille) et ses dérivés.

Unbound

La configuration d'Unbound est dérivée de celle que j'utilise pour mon réseau personnel, en augmentant un peu la taille des caches. Elle n'entre finalement pas tellement dans le cadre de ce billet, il n'y a pas vraiment de paramètres spécifiques à passer pour le brancher à dnsdist. Pour les curieux·ses la configuration est consultable par ici. À noter que la taille des caches est sans doute un peu grande, elle diminuera peut-être avec plus de recul.

dnsdist

La base de la configuration est tirée de celle documentée par Stéphane Bortzmeyer, complétée par la lecture de la (très complète) documentation du logiciel. Le fichier qui nous intéresse est /etc/dnsdist/dnsdist.conf. Sous Debian, il doit comporter le paramètre setSecurityPollSuffix("") : il s'agit de la désactivation d'un contrôle de sécurité de la version ajoutée par les personnes en charge de la maintenance du paquet chez Debian. Si ce paramètre revient à sa valeur par défaut (contôle actif), dnsdist plante au démarrage.

Commençons par créer le serveur DoT :

-- DoT
		addTLSLocal(
			"[2001:bc8:2c86:853::853]:853",
			"/etc/dnsdist/certificat.pem",
			"/etc/dnsdist/cléprivée.key",
			{
			provider="openssl",
			minTLSVersion="tls1.2",
			ciphers="ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384",
			ciphersTLS13="TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384",
			ocspResponses={"/etc/dnsdist/dnsshaftinc.oscp"},
			tcpFastOpenQueueSize=256,
			maxInFlight=300
			}
		)
		

Les premiers paramètres sont explicites : on indique sur quelle adresse et port écouter puis on indique le chemin vers le certificat X.509 et la clé privée. Attention toutefois, sous Debian, dnsdist est très restreint en capacité afin de protéger le système (jetez un œil au fichier de service systemd), clé et certificat doivent donc être dans un répertoire accessible par le logiciel (/etc/dnsdist) et doivent être lisible pour l'utilisateur _dnsdist.

On passe ensuite les options que l'on souhaite. Les 4 premières sont explicites (à noter que dnsdist est agnostique et peut utiliser GnuTLS comme fournisseur — au prix de quelques modifications de paramétrage), mais les 2 suivantes sont plus obscures et nécessitent quelques explications. Elles sont par ailleurs totalement optionnelles (et je ne suis pas sûr que tout les clients soient en mesures d'utiliser ces techniques). Enfin, la dernière option (maxInFlight), ajouté dans dnsdist 1.6.0, est requise pour faire proprement du DNS au-dessus de TCP (ce qui est le cas de DoT, étant donné que TLS se fait sur TCP).

ocspResponses permet d'utiliser l'OCSP Stapling ou agafrage OCSP. Acronyme de Online Certificate Status Protocol et défini dans le RFC 6960, OCSP est une technique utilisée lors de l'établissement d'une connexion TLS et permettant à un client de vérifier le statut d'un certificat X.509 auprès de l'autorité de certification l'ayant signé. Cette dernière répond si le certificat en question est révoqué ou non. Le problème de ce protocole est qu'il ralenti l'établissement de la connexion TLS (il faut demander des choses à l'autorité de certification) et surtout pose un souci de vie privée (l'autorité connaît de fait les domaines que vous visitez). Pour palier à ces deux problèmes, on permet au serveur d'envoyer directement la réponse OCSP au client lors de la poignée de main TLS via l'agrafage (stapling donc) de cette dernière. Pour ce faire le serveur demande cette réponse à l'autorité de certification, cette dernière nous en donne une valable en général quelques jours et signée cryptographiquement afin d'éviter les problèmes, et il ne reste plus qu'à la fournir au client. Les serveurs Web les plus courants (Nginx & Apache) ont des mécanismes pour faire tout cela automatiquement, ce n'est pas le cas de dnsdist, ce qui demande quelques manipulations.

La technique suivante est reprise de la documentation de dnsdist. Dans un premier temps, il faut récupérer l'URL du serveur OCSP de l'autorité de certification. Ce dernier est présent dans le certificat et on le récupère avec OpenSSL :

		# openssl x509 -noout -ocsp_uri -in /etc/dnsdist/certificat.pem
		

Avec une autorité comme Let's Encrypt, la réponse devrait-être quelque chose comme http://r3.o.lencr.org. Ne reste plus qu'à interroger ladite autorité et écrire sa réponse dans un fichier, toujours avec OpenSSL :

		# openssl ocsp -no_nonce -issuer /chemin/vers/certificat/autorité/certification -cert /etc/dnsdist/certificat.pem -text -url url/trouvé/ci/dessus -respout /etc/dnsdist/dnsshaftinc.oscp
		

Si vous utilisez le client ACME certbot, le certificat de l'autorité s'obtient en passant le paramètre --chain-path /chemin/vers/... lors de la création ou le renouvellement du certificat.

L'autorité doit répondre un gros pâté de ce type :

		OCSP Request Data:
		    Version: 1 (0x0)
		    Requestor List:
		    ...
		OCSP Response Data:
		    OCSP Response Status: successful (0x0)
		    Response Type: Basic OCSP Response
		    Version: 1 (0x0)
		    Responder Id: C = US, O = Let's Encrypt, CN = R3
		    Produced At: Aug 10 22:45:00 2021 GMT
		    Responses:
		    Certificate ID:
		      Hash Algorithm: sha1
		      Issuer Name Hash: 48DA...
		      Issuer Key Hash: 142E...
		      Serial Number: 0415...
		    Cert Status: good
		    This Update: Aug 10 22:00:00 2021 GMT
		    Next Update: Aug 17 22:00:00 2021 GMT

		    Signature Algorithm: sha256WithRSAEncryption
		         8f:0a:...

		Response verify OK
		...
		

Dans la réponse nous avons bien le statut 0x0 indiquant un succès et l'on remarque que la réponse est valable une semaine. De ce que j'ai pu constater avec Let's Encrypt, elle est en réalité renouvellée tous les 3 jours. Dans tous les cas, il faut mettre à jour notre fichier automatiquement, sous peine de se retrouver avec une réponse périmée. Le plus simple est de faire un script et de le déclencher quotidiennement via la crontab. Cela donne :

#!/bin/bash

		ocspURL=$(openssl x509 -noout -ocsp_uri -in /etc/dnsdist/certificat.pem)
		openssl ocsp -no_nonce -issuer /chemin/vers/certificat/autorité/certification -cert /etc/dnsdist/certificat.pem -text -url $ocspURL -respout /etc/dnsdist/dnsshaftinc.oscp

		chown _dnsdist: /etc/dnsdist/dnsshaftinc.oscp

		# On recharge le tout dans dnsdist
		dnsdist -e "reloadAllCertificates()"

Pour vérifier que l'ensemble fonctionne bien une fois en place, on peut utiliser OpenSSL :

		$ openssl s_client -connect dns.shaftinc.fr:853 -status | grep OCSP
		...
		OCSP response:
		OCSP Response Data:
		    OCSP Response Status: successful (0x0)
		    Response Type: Basic OCSP Response
		

Encore une fois, nous avons le statut 0x0, tout fonctionne.

Attention à bien regénérer ce fichier à chaque changement de certificat, et à bien lancer la commande

		dnsdist -e "reloadAllCertificates()"

à chaque modification du certificat, de la clé privée ou du fichier OCSP.

Le deuxième option obscure était tcpFastOpenQueueSize. TCP Fast Open (ou TFO, décrit dans le RFC 7413) est une méthode permettant d'accélerer l'établissement d'une connexion TCP. Normalement, une connexion TCP s'établie via la fameuse triple poignée de main :

C'est relativement lent, surtout qu'ensuite dans le cadre de DoH et DoT il faut établir la connexion TLS. Pour accélérer l'ensemble, TFO propose au client d'envoyer des données dès le premier paquet SYN. Pour faire simple, la première connexion à un serveur gérant TFO se fait toujours de la manière usuelle, mais si le client a indiqué connaître TFO (via une option dans le premier paquet SYN), alors le serveur fourni au passage un petit cookie qu'il a généré. Pour les connexions suivantes, le client n'a qu'à fournir ce cookie dans son premier paquet SYN et y mettre directement des données, dans le cas qui nous intéresse un ClientHello de TLS.

TFO est entièrement implémenté dans le noyau Linux depuis la version 3.16 et il est actif en tant que client depuis la même période a peu près (un peu avant, le noyau 3.16 apporte juste TFO pour IPv6). Il se configure via le paramètre net.ipv4.tcp_fastopen. Ce dernier accepte les valeurs suivantes :

Pour dns.shaftinc.fr, je suis parti sur le mode client & serveur. Pour l'activer, on passe la commande :

		$ sudo sysctl -w net.ipv4.tcp_fastopen=3
		

On vérifie au besoin que c'est pris en compte :

		$ sudo sysctl -a --pattern "fastopen"
		net.ipv4.tcp_fastopen = 3
		...
		

Le problème de la commande sysctl est que le changement de paramètre ne survivra pas au redémarrage de la machine. Pour palier à ça, on va créer un fichier /etc/sysctl.d/10-tcp-fastopen.conf (le numéro peut être différent) et y mettre tout simplement :

net.ipv4.tcp_fastopen=3

Pour revenir à notre paramètre tcpFastOpenQueueSize, il sert juste à dire à dnsdist de conserver les données pour 256 clients. J'avoue avoir choisi la valeur un peu au hasard, n'ayant pas de recul. Elle pourra peut-être évoluer.

Enfin le paramètre maxInFlight, ajout récent, permet à dnsdist de traiter les requêtes venant d'une même connexion TCP dans un ordre non-séquentiel. C'est à dire que si un client envoie 2 requêtes R1 et R2 et que la réponse pour R2 arrive avant celle de R1, alors dnsdist est en mesure de l'envoyer en premier au client. C'est une recommandation du RFC 7766 (section 6.2.1.1). La valeur associée est le nombre de reqûtes pouvant être traitées de la sorte simulanément sur une même connexion. La valeur de 300 est lié au paramètre setMaxTCPQueriesPerConnection (cf. infra).

Le serveur DoT est configuré, passons à DNS over HTTPS. Les paramètrages sont identiques, à quelques excpetions liées à HTTP :

-- DoH
		addDOHLocal(
				"[2001:bc8:2c86:853::853]:443",
				"/etc/dnsdist/certificat.pem",
				"/etc/dnsdist/cléprivée.key",
				{"/", "/about", "/politique", "/config"},
				{
				provider="openssl",
				minTLSVersion="tls1.2",
				ciphers="ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384",
				ciphersTLS13="TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384",
				ocspResponses={"/etc/dnsdist/dnsshaftinc.oscp"},
				customResponseHeaders={
						["Expect-CT"]="max-age=604800, enforce",
						["Strict-Transport-Security"]="max-age=31536000",
						["link"]="<https://www.shaftinc.fr/dns-shaftinc.html> rel=\"service-meta\"; type=\"text/html\""
				},
				tcpFastOpenQueueSize=256
			}
		)

		-- Liens externes

		helppages = {
		newDOHResponseMapEntry("^/about$", 308, "https://www.shaftinc.fr/dns-shaftinc.html"),
		newDOHResponseMapEntry("^/politique$", 308, "https://www.shaftinc.fr/dns-shaftinc.html#politique"),
		newDOHResponseMapEntry("^/config$", 308, "https://www.shaftinc.fr/dns-shaftinc.html#configuration")
		}

		dohFE = getDOHFrontend(0)
		dohFE:setResponsesMap(helppages)

Le paramétrage est donc sensiblement similaire. Notons que l'on peut utiliser une clé privée et donc un certificat différents pour le serveur DoT. Les différences viennent de la ligne :

{"/", "/about", "/politique", "/config"},

Le premier chemin est celui que les clients utiliseront pour interroger le serveur (si on avait mis par exemple "/query", l'URL du serveur aurait été https://dns.shaftinc.fr/query). Les 3 autres sont des pages d'aides, défini dans la section en dessous comme étant des redirections. À noter également l'ajout de quelques entêtes HTTP, notamment afin d'améliorer la sécurité (Expect-CT et Strict-Transport-Security).

On notera que l'option maxInFlight n'est pas présente pour DoH car le comportement permit par ce paramètre est actif par défaut avec DoH.

On ajoute ensuite les autorisations et limitations, reprises de la configuration de Stéphane Bortzmeyer (hormis pour la limitation de requêtes par seconde, montée à 200 :

-- ACL

		addACL("[::]/0")

		-- Limitation à 200 QpS
		addAction(MaxQPSIPRule(200), DropAction())

		-- Limitations de connexions

		setMaxUDPOutstanding(65535)     -- Nombre maximum de requêtes en attente pour un résolveur
		setMaxTCPClientThreads(30)      -- Nombre maximum de fils d'exécution TCP (chacun pouvant traiter plusieurs clients)
		setMaxTCPConnectionDuration(1800) -- Après trente minutes, on raccroche
		setMaxTCPQueriesPerConnection(300) -- Après trois cents requêtes, on raccroche
		setMaxTCPConnectionsPerClient(10) -- Dix connexions par client (élévé mais il faut penser à des choses comme le CG-NAT)

		-- Cache
		pc = newPacketCache(100000)
		getPool(""):setCache(pc)

Pour le cache, la documentation de dnsdist précise que pour une machine dotée de 8 Gio de RAM et en considérant la taille moyenne d'une réponse à 512 octets, un cache de 1 000 000 d'entrées est une estimation raisonnable. La machine hébergeant dns.shaftinc.fr n'a que 4 Gio de RAM, fait tourner d'autres services consommateurs de mémoire (dont Unbound)... donc dans un premier temps, 100 000 entrées maximum dans le cache semble plus que correct.

On configure ensuite le serveur Web interne et la console. Ils serviront à sortir des statistiques ou bien passer des commandes à dnsdist via la console (pour recharger le certificat X.509 par exemple) :

-- Webserver

		webserver("127.0.0.1:8083", "super pharase de passe compliquée", "clé pour l'API compliquée aussi")

		-- Console
		controlSocket('[::1]:5199')
		setKey("phrase de passe super balaise")

Attention à bien suivre la procédure pour générer la clé de la console.

Tout est presque en place, ne manque plus qu'à donner à dnsdist un ou plusieurs résolveurs avec qui discuter. Pour dns.shaftinc.fr, dnsdist n'est connecté qu'avec le Unbound local, mais le logiciel étant un répartiteur de charge, il est possible d'en ajouter plusieurs et appliquer une politique de répartition. En n'utilisant qu'un serveur local la configuration est au final simple :

newServer({address="[::1]:53", useClientSubnet=false, maxInFlight=1000, name="Unbound"})

On ajoute également l'option maxInFlight, pour que les requêtes entre dnsdist et Unbound soient également traitées dans un ordre non-séquentiel, quand la communication se fait via TCP, ce qui est normalement le cas avec les requêtes venant de l'extérieur voyageant par TCP, sachant qu'Unbound gère par défaut le traitement non-séquentiel depuis sa version 1.9.0.

Supervision

Pour superviser dnsdist, l'utilitaire getdns_server_mon présent dans le paquet getdns-utils permet de faire des tests pour DoT proposant une sortie conforme à ce qu'attendent Nagios & dérivés. Attention le logiciel à une syntaxe lourde. Pour tester le RTT par exemple :

$ getdns_server_mon -M -K 'pin-sha256="ilee9nHBVT0DVWER1VDA+0NCaYd25zVvP0C1Jb4gCIc="' -S -T @2001:bc8:2c86:853::853#853~dns.shaftinc.fr rtt 2500,3000 . SOA
DNS SERVER OK - RTT lookup succeeded in 23ms
		

Détaillons un peu les paramètres de cette commande :

La commande pourrait être allégée, par exemple en n'utilisant un profil opportuniste et donc en authentifiant pas la connexion, mais cela perd en intéret. L'aide de getdns_server_mon détaille l'ensemble des tests possibles. Ceux qui me semblent utiles sont :

Et de manière plus occasionnelle, les tests suivants, qui permettent notamment de vérifier que les résolveurs avec lesquelles dnsdist ont bien les fonctionnalités attendues :

Voici une commande pour Icinga 2 utilisant getdns_server_mon. Elle comporte toutes les options à l'exception de -D (mode debug) et -V (affiche la version) :

object CheckCommand "dot" {
			import "plugin-check-command"
			command = [ "/usr/bin/getdns_server_mon", "-M", "-v" ]
			arguments += {
				"-E" = {
					description = "Fail on DNS error (NXDOMAIN, SERVFAIL)"
					set_if = "$getdns_fail_dns_error$"
				}
				"-K" = {
					description = "SPKI pin for TLS connections (can repeat)"
					repeat_key = true
					value = "$getdns_spki$"
				}
				"-S" = {
					description = "Use strict profile (require authentication)"
					set_if = "$getdns_strict_auth$"
				}
				"-T" = {
					description = "Use TLS transport"
					set_if = "$getdns_tls$"
				}
				"-t" = {
					description = "Use TCP transport"
					set_if = "$getdns_tcp$"
				}
				"-u" = {
					description = "Use UDP transport"
					set_if = "$getdns_udp$"
				}
				test = {
					description = "Test to apply"
					order = 98
					required = true
					skip_key = true
					value = "$getdns_test$"
				}
				test_params = {
					description = "Optionnal params for tests"
					order = 99
					skip_key = true
					value = "$getdns_test_param$"
				}
				upstream = {
					description = "@<ip>[%<scope_id>][@<port>][#<tls_port>][~<tls name>][^<tsig spec>]"
					order = 97
					required = true
					skip_key = true
					value = "$getdns_upstream$"
				}
			}
		}

Et on peut ensuite créer des services, voici celui effectuant le test lookup :

object Service "DoT - Lookup" {
			host_name = "dns.shaftinc.fr"
			check_command = "dot"
			check_timeout = 16s
			vars.getdns_fail_dns_error = true
			vars.getdns_spki = "pin-sha256=\"ilee9nHBVT0DVWER1VDA+0NCaYd25zVvP0C1Jb4gCIc=\""
			vars.getdns_strict_auth = true
			vars.getdns_test = "lookup"
			vars.getdns_test_param = [ "d.nic.fr", "AAAA" ]
			vars.getdns_tls = true
			vars.getdns_upstream = "@2001:bc8:2c86:853::853#853~dns.shaftinc.fr"
		}

À noter que l'ensemble a été généré avec le module Director d'Icinga 2 (et que je suis plutôt débutant pour tout ce qui touche à Icinga ☺).

Pour la surveillance de DoH, j'utilise le plugin check_doh présenté dans le billet de Stéphane Bortzmeyer. Il faut aussi penser à surveiller la date d'expiratuion du certificat. Le plugin check_ssl_cert inclut dans le paquet monitoring-plugins-contrib fait parfaitement l'affaire.

Pour terminer cette section sur la surveillance/supervision, dnsdist est capable de se brancher à Carbon (non testé) ou parler SNMP (non testé également). Par ailleurs, le webserver interne est capable de cracher de la statisque au format JSON en interrogeant notamment /jsonstat?command=stats et /api/v1/servers/localhost :

		$ curl -s --header "X-API-Key: 8DjQ..." http://127.0.0.1:8083/jsonstat?command=stats | jq
		{
			"acl-drops": 0,
			"cache-hits": 1076,
			"cache-misses": 447,
			"cpu-iowait": 130965,
			"cpu-steal": 0,
			...
		}
		$ curl -s --header "X-API-Key: 8DjQ..." http://127.0.0.1:8083/api/v1/servers/localhost | jq
		"cache-hit-response-rules": [],
		"daemon_type": "dnsdist",
		"dohFrontends": [
		  {
			"address": "[2001:bc8:2c86:853::853]:443",
			"bad-requests": 2,
			"error-responses": 0,
			"get-queries": 327,
			...
		  }
		

Il est par exemple possible de récupérer ce qui nous intéresse et le donner à manger à Munin notamment. Voici par exemple un graphe Munin montrant le nombre d'entrées dans le cache :

Le script Munin que j'ai fabriqué est trop spécifique à mon installation (et un peu trop sale aussi) pour être partagé, mais si vous arrivez à manipuler du JSON avec jq ou les outils de votre langage préféré, cela ne devrait pas poser trop de soucis à faire. Encore une fois la documentation de dnsdist est très bien faite.