angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

Aiohttp avec serveurs DNS personnalisés, Unbound et Docker

Déchargez votre application Python aoihttp en ajoutant des résolveurs DNS de mise en cache à votre système local.

13 juillet 2023
Dans Async
post main image
https://www.pexels.com/nl-nl/@cihankahraman

L'utilisation de aiohttp semble facile, mais ce n'est pas le cas. C'est déroutant. La documentation "Client Quickstart" commence par ce qui suit :

Remarque

Ne créez pas de session par demande. Le plus souvent, vous avez besoin d'une session par application qui exécute toutes les demandes ensemble.

Des cas plus complexes peuvent nécessiter une session par site, par exemple une pour Github et une autre pour Facebook APIs. Quoi qu'il en soit, créer une session pour chaque requête est une très mauvaise idée.

Une session contient un pool de connexions. La réutilisation des connexions et les keep-alives (tous deux activés par défaut) peuvent accélérer les performances totales.

Hmmm ... ok ... répétez s'il vous plaît ...

Quoi qu'il en soit, le problème est le suivant : je dois vérifier de nombreux sites différents, et je veux également utiliser des serveurs DNS personnalisés. Cela signifie une session par site. Je ne sais pas quels sites, je reçois juste une liste d'Urls. Nous optons donc pour une session par Url. Et tout utilise Docker.

Dans ce billet, nous alimentons l'aiohttp AsyncResolver avec les IP addresses de deux résolveurs DNS Unbound caching, Cloudflare ou Quad9, fonctionnant sur notre système local. Comme toujours, mon système de développement est Ubuntu 22.04.

L'application Python

Voici notre application (incomplète) Python . Elle utilise aiohttp pour vérifier les sites (Urls). Le script est exécuté dans un conteneur Python Docker . Je ne vais pas vous ennuyer avec la mise en place d'une image et d'un conteneur Docker Python .

Notez que nous créons un AsyncResolver pour chaque TCPConnector pour chaque session en utilisant les IP addresses de Cloudflare ou Quad9.

# check_urls.py
import asyncio
import aiodns
import aiohttp
import logging
import os
import socket
import sys

def get_logger(
    console_log_level=logging.DEBUG,
    file_log_level=logging.DEBUG,
    log_file=os.path.splitext(__file__)[0] + '.log',
):
    logger_format = '%(asctime)s %(levelname)s [%(filename)-30s%(funcName)30s():%(lineno)03s] %(message)s'
    logger = logging.getLogger(__name__)
    logger.setLevel(logging.DEBUG)
    if console_log_level:
        # console
        console_handler = logging.StreamHandler(sys.stdout)
        console_handler.setLevel(console_log_level)
        console_handler.setFormatter(logging.Formatter(logger_format))
        logger.addHandler(console_handler)
    if file_log_level:
        # file
        file_handler = logging.FileHandler(log_file)
        file_handler.setLevel(file_log_level)
        file_handler.setFormatter(logging.Formatter(logger_format))
        logger.addHandler(file_handler)
    return logger

logger = get_logger(file_log_level=None)


async def check_url(task_number, url, nameservers=None):
    logger.debug(f'[{task_number}] url = {url}, nameservers = {nameservers}')

    resolver = None
    if nameservers:
        resolver = aiohttp.resolver.AsyncResolver(nameservers=nameservers)

    connector=aiohttp.TCPConnector(
        limit=1,
        use_dns_cache=True,
        ttl_dns_cache=300,
        family=socket.AF_INET,
        resolver=resolver
    )

    async with aiohttp.ClientSession(
        connector=connector,
        # if we want to reuse the connector with other sessions, 
        # we must not close it: connector_owner=False
        connector_owner=True,
    ) as session:
        async with session.get(
            url,
        ) as client_response:
            logger.debug(f'[{task_number}] status = {client_response.status}')
            logger.debug(f'[{task_number}] url = {client_response.url}')
            logger.debug(f'[{task_number}] content_type = {client_response.headers.get("Content-Type", None)}')
            logger.debug(f'[{task_number}] charset = {client_response.charset}')


async def main():
    logger.debug(f'()')
    dns_cloudflare = ['1.1.1.1', '1.0.0.1']
    dns_quad9 = ['9.9.9.9', '149.112.112.112']

    sites = [
        ('http://www.example.com', dns_cloudflare),
        ('http://www.example.org', dns_quad9)
    ]

    tasks = []
    for task_number, site in enumerate(sites):
        url, nameservers = site
        task = asyncio.create_task(check_url(task_number, url, nameservers))
        tasks.append(task)

    for task in tasks:
        await task
    logger.debug(f'ready')

asyncio.run(main())

Problème : connexion directe à des serveurs DNS distants

Bien que la méthode ci-dessus fonctionne, elle présente quelques problèmes. Si nous vérifions un grand nombre d'URL, nous envoyons un grand nombre de requêtes (distinctes) aux serveurs DNS de Cloudflare ou de Quad9.

Nous pouvons réutiliser le TCPConnector, par exemple en créant un pool de TCPConnector, et utiliser la mise en cache DNS des connecteurs. C'est une grande amélioration, mais c'est encore loin d'être parfait car nos connecteurs restent "directement" connectés au monde extérieur (via le résolveur).

Solution : Serveurs DNS locaux de mise en cache

Nous pouvons faire mieux en exécutant un ou plusieurs serveurs DNS de mise en cache sur notre système local, et en alimentant les AsyncResolver avec les IP addresses de nos serveurs DNS de mise en cache.

Serveur DNS cache : Non lié

Il y a beaucoup de Docker serveur DNS images et j'ai sélectionné 'Unbound DNS Server Docker Image', voir les liens ci-dessous. Pourquoi ? Il est facile à utiliser et, par défaut, il transmet les requêtes à un serveur DNS distant, Cloudflare. Une caractéristique intéressante est que nous pouvons utiliser le DNS sur TLS (DoT). Cela signifie que nous protégeons les requêtes contre le suivi (ISP).

Parce que nous voulons plus d'un serveur DNS local, nous copions d'abord quelques fichiers de configuration à l'extérieur du conteneur. Dans le répertoire où nous démarrons le serveur DNS, nous créons un nouveau répertoire :

my_conf

Puis nous démarrons le serveur DNS :

docker run --name=my-unbound mvance/unbound:1.17.0

Et dans un autre terminal, nous copions certains fichiers de l'intérieur du conteneur vers notre système :

mkdir my_conf
docker cp my-unbound:/opt/unbound/etc/unbound/forward-records.conf my_conf
docker cp my-unbound:/opt/unbound/etc/unbound/a-records.conf my_conf

On arrête le serveur DNS en tapant 'CTRL-C'.

J'ai créé le fichier docker-compose.yml suivant :

version: '3'

services:
  unbound_cloudflare_service:
    image: "mvance/unbound:1.17.0"
    container_name: unbound_cloudflare_container
    networks:
     - dns
    volumes:
      - type: bind
        read_only: true
        source: ./my_conf/forward-records.conf
        target: /opt/unbound/etc/unbound/forward-records.conf
      - type: bind
        read_only: true
        source: ./my_conf/a-records.conf
        target: /opt/unbound/etc/unbound/a-records.conf
    restart: unless-stopped

networks:
  dns:
    external: true
    name: unbound_dns_network

volumes:
  mydata:

Nous nous connectons depuis l'application Python , le conteneur Docker , au conteneur du serveur DNS en utilisant le réseau Docker . Cela signifie qu'il n'est pas nécessaire de spécifier les ports dans le fichier docker-compose.yml. La publication d'aucun port est synonyme de meilleure sécurité. Pour créer le réseau Docker 'unbound_dns_network' :

docker network create unbound_dns_network

Pour démarrer le serveur DNS :

docker-compose up

Vérifier si le serveur DNS fonctionne

Pour cela, j'utilise 'netshoot : a Docker + Kubernetes network trouble-shooting swiss-army container', voir les liens ci-dessous. Lorsque nous le démarrons, nous nous connectons également au réseau 'unbound_dns_network' :

docker run --rm -it --net=unbound_dns_network nicolaka/netshoot

Ensuite, nous utilisons 'dig' pour vérifier si notre serveur DNS fonctionne.

Notez que nous nous référons au nom du service Docker-compose , 'unbound_cloudflare_service', ici :

dig @unbound_cloudflare_service -p 53 google.com

Résultat :

---
; <<>> DiG 9.18.13 <<>> @unbound_cloudflare_service -p 53 google.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 55895
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;google.com.            IN    A

;; ANSWER SECTION:
google.com.        300    IN    A    142.250.179.174

;; Query time: 488 msec
;; SERVER: 172.17.10.3#53(unbound_cloudflare_service) (UDP)
;; WHEN: Wed Jul 12 16:32:56 UTC 2023
;; MSG SIZE  rcvd: 55

La 'ANSWER SECTION' donne le IP address. Le temps de requête est de 488 millisecondes. Si nous exécutons à nouveau la commande, nous obtenons le même résultat mais le temps d'interrogation sera (proche de) 0. Notez que le IP address de notre service de serveur DNS local est également affiché :

172.17.10.3

Quoi qu'il en soit, notre serveur DNS local fonctionne !

Nous pouvons répéter ces étapes pour Quad9. Créez un nouveau répertoire et copiez les fichiers de l'installation Cloudflare.

Editez le fichier docker-compose.yml et remplacez 'cloudflare' par 'quad9'.

Editez le fichier 'forward-records.conf' :

  • Commenter les lignes pour Cloudflare
  • Décommentez les lignes pour Quad9

Et c'est parti !

Utiliser nos serveurs DNS locaux dans notre script Python

C'est la dernière étape. Nous devons faire deux choses :

  • Ajouter le 'unbound_dns_network' à notre conteneur Python Docker .
  • Traduire les noms 'unbound_cloudflare_service' et 'unbound_quad9_service' en IP addresses.

L'ajout de 'unbound_dns_network' à notre conteneur Python Docker est facile. Nous procédons de la même manière que dans le fichier Unbound docker-compose.yml.

Nous connaissons déjà les IP address de nos services de serveur DSN locaux, mais ils peuvent changer. Au lieu de coder en dur les IP addresses, nous traduisons les noms de service en IP addresses dans notre script Python , en modifiant le code suivant dans notre script, voir ci-dessus, de :

    dns_cloudflare = ['1.1.1.1', '1.0.0.1']
    dns_quad9 = ['9.9.9.9', '149.112.112.112']

à :

    dns_cloudflare = [socket.gethostbyname('unbound_cloudflare_service')]
    dns_quad9 = [socket.gethostbyname('unbound_quad9_service')]

Bien entendu, cela ne fonctionne que si les services du serveur DNS local sont opérationnels.

Désormais, toutes les requêtes DNS de notre application Python sont acheminées vers nos serveurs DNS locaux !

En résumé

Nous voulions nous comporter de manière conviviale et ne pas surcharger les serveurs DNS distants avec un trop grand nombre de connexions. Nous voulions également supprimer la connexion directe de notre application Python aux serveurs DNS distants. Pour ce faire, nous avons mis en place des services de serveurs DNS locaux et connecté notre script Python à ces derniers, en utilisant le réseau Docker .
Nous avons créé une dépendance supplémentaire, les serveurs DNS locaux, mais nous avons également supprimé une dépendance. Si un serveur DNS distant est hors service (pendant un certain temps), notre application Python continue à fonctionner.

Liens / crédits

Docker Container Published Port Ignoring UFW Rules
https://www.baeldung.com/linux/docker-container-published-port-ignoring-ufw-rules

netshoot: a Docker + Kubernetes network trouble-shooting swiss-army container
https://github.com/nicolaka/netshoot

Unbound
https://nlnetlabs.nl/projects/unbound/about

Unbound DNS Server Docker Image
https://github.com/MatthewVance/unbound-docker

Laissez un commentaire

Commentez anonymement ou connectez-vous pour commenter.

Commentaires

Laissez une réponse

Répondez de manière anonyme ou connectez-vous pour répondre.