Auto-hébergement : adresse IPv6 dynamique avec une zone DNS chez OVH

La plupart des personnes qui font de l'informatique savent très bien qu'IPv4 c'est fini, en théorie, et qu'il est grand temps de passer à IPv6.

Pourtant, malgré plusieurs années d'auto-hébergement, je n'avais jamais franchi le pas. La faute à plusieurs paramètres :

Déterminer son adresse IPv6🔗

Préfixe IPv6 ? De quoi on parle ?

Attention : je fais des raccourcis, je suis pas en train de faire un cours sur IPv6 (j'en serais d'ailleurs pas capable). L'idée c'est d'avoir une vue d'ensemble suffisante pour pratico-pratiquer. Mais n'hésitez pas à me corriger si je dis des grosses bêtises 😉

Il y a ~ 2 milliards d'adresses IPv4, qui sont de plus en plus rares et chères. Pour minimiser leurs coûts, et tout simplement avoir assez d'adresses, les FAI ne donnent qu'une adresse IPv4 par box. (Souvent, ils partagent même une IP entre plusieurs box. Dans ce cas, il faut demander une adresse IPv4 full-stack, c'est-à-dire non partagée.)

Mais chaque appareil connecté au réseau doit avoir sa propre adresse IP pour être joignable ! Avec IPv4, la box fait ce qu'on appelle du NAT : elle donne aux appareils une adresse IPv4 privée (souvent en 192.168.X.XXX), et quand ils envoient un paquet, elle remplace l'adresse privée par sa propre adresse IPv4 publique, envoie le paquet à l'internet mondial décentralisé, et fait le tour de passe-passe inverse quand elle reçoit un paquet depuis l'internet et le transfère à un appareil local.

Franchement, relou, quoi. On peut pas avoir une vraie adresse publique par appareil ?

Ben avec IPv6, si, carrément. Et même des centaines d'adresses par appareil si on veut : il y a plus d'adresses IPv6 que d'atomes dans l'univers (ou quelque chose comme ça 😋).

Là où on avait grosso modo 2^32 adresses IPv4 (parce qu'elles font 32 bits), on a maintenant grosso modo 2^128 adresses IPv6 (parce qu'elles font... 128 bits. bravo !).

Donc ce qu'ont l'air de faire la plupart des FAI, c'est qu'ils donnent un "préfixe" de 64 bits à ta box.

Exemple : 2a01:e0a:b3a:1dd0::/64

Et ensuite, chaque appareil connecté à internet via la box se choisit dans son coin une adresse aléatoire en complétant au pif les 64 octets qui restent.

Exemple : 2a01:e0a:b3a:1dd0:694a:810f:1f18:9b9/64

Et ça marche ! La chance qu'il y ait une collision est vraiment négligeable : 1 / 2^64.


En pratique, les ordinateurs semblent jongler avec plusieurs IPv6s temporaires. Sur mon serveur :

$ ip address
# Ça c'est mon interface *loopback*, on s'en fout
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host noprefixroute
       valid_lft forever preferred_lft forever
# Ça c'est l'interface de ma connexion ethernet.
# La votre ne s'appelle ptet pas enp6s0
# (elle peut avoir un nom comme eno1, eth0, etc)
2: enp6s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    # Mon adresse MAC
    link/ether b5:1d:96:b8:01:f1 brd ff:ff:ff:ff:ff:ff
    # Mon adresse IPv4 privée
    inet 192.168.1.35/24 brd 192.168.1.255 scope global dynamic noprefixroute enp6s0
                        # preferred lifetime pas à zéro
       valid_lft 36422sec preferred_lft 31022sec
    # Seule adresse IPv6 "temporary" pas "deprecated"
    # (j'ai raccourci les adresses pour la lisibilité)
    inet6 2a01::cf08/64 scope global temporary dynamic
       valid_lft 86387sec preferred_lft 54092sec
    # Toutes les adresses "deprecated"
    inet6 2a01::2879/64 scope global temporary deprecated dynamic
       valid_lft 86387sec preferred_lft 0sec
    inet6 2a01::46c3/64 scope global temporary deprecated dynamic
       valid_lft 86387sec preferred_lft 0sec
    inet6 2a01::eeed/64 scope global temporary deprecated dynamic
       valid_lft 86387sec preferred_lft 0sec
    inet6 2a01::4ab6/64 scope global temporary deprecated dynamic
       valid_lft 86387sec preferred_lft 0sec
    inet6 2a01::a15a/64 scope global temporary deprecated dynamic
       valid_lft 86387sec preferred_lft 0sec
    inet6 2a01::5758/64 scope global temporary deprecated dynamic
       valid_lft 56383sec preferred_lft 0sec
    # Seule adresse IPv6 pas "temporary" ni "deprecated"
    inet6 2a01::2c1/64 scope global dynamic mngtmpaddr noprefixroute
                        # preferred lifetime pas à zéro
       valid_lft 86387sec preferred_lft 86387sec
    # Celle-ci elle compte pas, c'est une adresse locale
    # ("scope link", commence par fe80)
    # La suite c'est mon adresse MAC,
    # avec un ff:fe inséré au milieu, si vous suivez 😉
    inet6 fe80::b51d:96ff:feb8:1f1/64 scope link
       valid_lft forever preferred_lft forever

Avec l'option -j/--json, ip a[ddress] nous donne une sortie en JSON, plus pratique pour en extraire des données. Ça nous sort un truc comme ça :

[
  {
    "ifname": "nom",
    "...": "...",
    "addr_info": [
      {
        "family": "inet",
        "local": "2a01:e0a:b3a:1dd0:6750:6872:c4f7:4ab6",
        "scope": "global",
        "dynamic": true,
        "deprecated": true,
        "...": "...",
        "preferred_life_time": 0
      },
      {
        "...": "..."
      }
    ]
  },
  {
    "...": "..."
  }
]
Extraction de l'IP avec la commande `jq`
# Condensé :

$ ip -j a | ${pkgs.jq}/bin/jq -r '[.[] | select(.ifname == "enp6s0") | .addr_info[] | select(.scope == "global") | select(.family == "inet6") | select(.deprecated == false or has("deprecated") | not)] | max_by(.preferred_life_time) | .local')

2a01:e0a:b3a:1dd0:b62e:99ff:fec7:2c1

# Étendu :

# ip address avec une sortie JSON
$ ip --json address \
  | ${pkgs.jq}/bin/jq -r '[
      # On récupère tous les items de la liste
      .[]
      # On ne garde que l'item qui porte le nom enp6s0
      | select(.ifname == "enp6s0")
      # On récupère les items de la liste addr_info
      | .addr_info[]
      # On ne garde que les items qui sont publiques
      | select(.scope == "global")
      # Et en ipv6
      | select(.family == "inet6")
      # Et qui sont pas dépréciées
      | select(.deprecated == false or has("deprecated") | not)
    # On refait une liste du stream obtenu
    ]
    # On récupère celle qui a le plus long preferred_life_time
    # (celle qui restera le plus longtemps)
    | max_by(.preferred_life_time)
    # On récupère l'IP
    | .local')

2a01:e0a:b3a:1dd0:b62e:99ff:fec7:2c1

Extraction de l'IP en python

Je le refais en python parce qu'on va en avoir besoin plus tard :

import json
import subprocess

# Mettre ici le nom de l'interface qui nous intéresse
INTERFACE_NAME = "enp6s0"

def getIp():
    # On exécute la commande `ip --json address`
    res = subprocess.run(
        ["ip", "--json", "address"],
        capture_output=True,
        text=True
    )
    # On parse le JSON
    data = json.loads(res.stdout)

    addresses = [
        # On récupère le champ addr_info
        interface['addr_info']
        for interface in data
        # De l'interface paramétrée
        if interface['ifname'] == INTERFACE_NAME
    # Et on ne garde que le premier
    # (et seul) élément de la liste
    ][0]
    
    # On récupère l'élément max
    ipv6 = max([ # de cette liste
            address
            for address in addresses
            # Si l'adresse est publique
            if address['scope'] == "global"
            # Si l'adresse est une IPv6
            and address['family'] == "inet6"
            # Si l'adresse n'est pas dépréciée
            and not ('deprecated' in address and address['deprecated'])
        ],
        # en calculant quel est l'élément max
        # par son preferred_life_time
        key=lambda a: a['preferred_life_time']
    # On ne garde plus que l'adresse en elle-même
    )['local']

    return ipv6

Mettre à jour sa zone DNS avec OVH🔗

Ici, je suppose que vous avez une compréhension basique de ce qu'est une zone DNS.

Ptite explication quand même

C'est une liste de records (entrées), avec une clé, qui est le nom ou sous-nom de domaine concerné, et une valeur, dont le sens dépend du type du record :

  • A : la valeur est une IPv4.
  • AAAA : la valeur est une IPv6
  • CNAME : la valeur un autre nom de domaine. C'est comme un pointeur en programmation, pour toute question (A, AAAA, ...) concernant le nom de domaine en clé, demande au nom de domaine en valeur à la place.

Il y a plein d'autres champs qui ne nous intéressent pas aujourd'hui. Si vous ne savez pas ce que fait un champ, ne le supprimez pas au pif, ça pourrait faire des problèmes.

Exemple, dans le cas de la zone de ppom.me :

# Tous les sous-noms de domaine de ppom.me (*.ppom.me) sont
# redirigés vers musi (musi.ppom.me)
*      IN CNAME musi
# musi (musi.ppom.me) est redirigé vers ppom.me.
# ('.' à la fin = on ne parle pas d'un sous-nom de domaine)
musi   IN CNAME ppom.me.
# Si la clé (premier champ) est omise, c'est qu'on parle du
# nom de domaine en lui-même,
# pas d'un sous-nom de domaine
# ppom.me est joignable à l'adresse IPv4 1.2.3.4
       IN A 1.2.3.4
# ppom.me est joignable à l'adresse IPv6 a:b:c:d:5:6:7:8
       IN AAAA a:b:c:d:5:6:7:8

Dans l'interface web d'OVH, on a une interface qui permet de tout éditer facilement. Je vous propose de vous assurer que vous avez bien une entrée AAAA pour le nom de domaine qui vous intéresse, puis on va le mettre à jour avec un script python !

Les parties de l'API qui nous intéressent🔗

Il nous faut d'abord nous familiariser avec l'API d'OVH. L'API est documentée ici. C'est toute la partie /domain/zone qui nous intéresse : elle permet d'éditer la zone DNS d'un domaine.

On peut aussi jouer pour de vrai avec l'API ici, en se connectant avec son compte OVH et en mettant des vrais paramètres aux requêtes pour tester dans une interface avant d'essayer de le faire en programmation.

Là je pars du principe qu'on ne va pas créer de nouveau record, mais seulement d'éditer celui (ou ceux) qui nous intéresse.

Pour chaque record a éditer :

Récupérer l'id🔗

On doit récupérer son id avec

GET /domain/zone/{domain}/record

Exemple 1, le champ A de musi.ppom.me :

GET /domain/zone/ppom.me/record?subDomain=musi&fieldType=A

Exemple 2, le champ AAAA de ppom.me :

GET /domain/zone/ppom.me/record?fieldType=AAAA

Exemple de réponse : [123456].

Récupérer ses infos🔗

Avec son id en poche, on peut récupérer ses infos avec :

GET /domain/zone/{domain}/record/{id}

Exemple avec l'id précédent :

GET /domain/zone/ppom.me/record/123456

Exemple de réponse :

{
    "fieldType": "AAAA",
    "id": 123456,
    "subDomain": "",
    "target": "2a01:e0a:b3a:1dd0:b62e:99ff:fec7:2c1",
    "ttl": 0,
    "zone": "ppom.me"
}

Mettre à jour l'adresse IP🔗

Si on voit que l'IPv6 n'est pas celle qu'on veut, on met à jour le record avec

PUT /domain/zone/{domain}/record/{id}

Avec PUT on ne met à jour qu'on veut changer, sans tout remplacer. C'est pratique.

Exemple avec l'id précédent :

PUT /domain/zone/ppom.me/record/123456

Avec dans le corps de la requête :

{
    "target": "2a01:e0a:b3a:1dd0:b62e:99ff:fec7:2c1"
}

La réponse est vide.

Publier la nouvelle zone🔗

Dans mon cas, je n'ai qu'un record à changer, mais vous en avez peut-être plusieurs. Une fois qu'on a fait tous les changements qu'on voulait, il faut dire à OVH de publier cette nouvelle version de la zone DNS avec un

GET /domain/zone/{domain}/refresh

Dans mon cas :

GET /domain/zone/ppom.me/refresh

Authentification🔗

Si vous avez utilisé la console OVH pour tester les requêtes, vous avez dû voir que vous créiez un token pour utiliser l'API.

 OVH API console wants to access your account. This application will have access to: GET /*, POST /*, PUT /*, DELETE /*

Ici on voit que cette autorisation porte sur toutes les actions possibles (/* sur les 4 méthodes GET, POST, PUT, DELETE). Ça allait tant qu'on jouait temporairement dans la console, mais j'ai pas envie de donner autant de pouvoirs à mon serveur, qui n'a que besoin de mettre à jour sa zone DNS.

Pour créer un jeton d'authentification, token, on va ouvrir notre plus beau navigateur (sûrement Firefox 🦊) et ouvrir ce lien (remplacer ppom.me par votre zone):

https://api.ovh.com/createToken/index.cgi?GET=/domain/zone*&PUT=/domain/zone/ppom.me/record/*&POST=/domain/zone/ppom.me/refresh

Remplir les champs, notamment le temps de validité, qu'on va mettre à Unlimited.

Bien sauvegarder quelque part les 3 secrets qui nous sont donnés.

Utiliser l'API d'OVH en Python🔗

OVH propose un paquet Python pour faciliter l'utilisation de son API. On va pas cracher dans la soupe et utiliser leur paquet. C'est pour ça que j'ai choisi le Python d'aileurs !

Je ne sais pas installer un paquet Python !

Trop dommage !

C'est un peut comme package.json/node_modules en Javascript.

Y'a moults tutos sur Internet qui expliqueront ça mieux que moi, mais en gros :

# Créer un "environnement virtuel"
# dans un nouveau dossier ~/.python-ovh
# J'ai mis ~/.python-ovh, mais
# ça peut aussi être /opt/ovh, ou ce que tu veux.
$ python3 -m venv ~/.python-ovh
# "Entrer" dans le venv
$ source ~/.python-ovh/bin/activate
# Installer le paquet ovh dans le venv
$ python3 -m pip install ovh
# C'est bon !
$ python3 mon_script_qui_a_besoin_du_paquet_ovh.py
# Pour "sortir" du venv :
$ deactivate

Pour les prochaines fois, il suffit de l'activer/désactiver :

$ source ~/.python-ovh/bin/activate
$ python3 mon_script_qui_a_besoin_du_paquet_ovh.py
$ deactivate

Pour se connecter à OVH dans notre script, on va avoir besoin des secrets récupérés plus haut. Le paquet OVH propose plusieurs méthodes. On peut passer par un fichier de configuration. Perso je préfère passer par les variables d'environnement :

OVH_ENDPOINT=ovh-eu
OVH_APPLICATION_KEY=...
OVH_APPLICATION_SECRET=...
OVH_CONSUMER_KEY=...

Cette fonction Python prend en paramètre une IP qu'on a déjà récupérée avec ip address.

import sys

import ovh

# Mettre ici la zone qu'on veut éditer
ZONE = "ppom.me"

def updateIp(newIp):
    # On initialise le client OVH
    client = ovh.Client()

    # On récupère le champ qui nous intéresse.
    # En l'occurence, le AAAA de ppom.me.
    recordId = client.get(f"/domain/zone/{ZONE}/record?fieldType=AAAA")
    # Si le tableau est vide, c'est caca
    if len(recordId) == 0:
        print(f"error no AAAA {ZONE}. record", file=sys.stderr)
        sys.exit(1)
    # On récupère le premier et seul élément du tableau retourné
    recordId = recordId[0]

    # On récupère ses informations, maintenant qu'on a l'id
    recordData = client.get(f"/domain/zone/{ZONE}/record/{recordId}")
    # En particulier, l'IP.
    oldIp = recordData['target']

    # Si l'IP ne correspond pas à celle qu'on a récupérée avec `ip address`
    if oldIp != newIp:
        # On met à jour l'IP
        client.put(f"/domain/zone/{ZONE}/record/{recordId}", target=newIp)
        # On publie la nouvelle version de la zone
        client.post(f"/domain/zone/{ZONE}/refresh")
        print(f"update from {oldIp} to {newIp}")
    else:
        print(f"unchanged {oldIp}")
Si on veut mettre à jour plusieurs records

On va boucler sur tous les records à mettre à jour :

import sys

import ovh

# Mettre ici la zone qu'on veut éditer
ZONE = "ppom.me"

RECORDS = [
    f"/domain/zone/{ZONE}/record?fieldType=AAAA"
    f"/domain/zone/{ZONE}/record?fieldType=AAAA&subDomain=musi"
]

def updateIp(newIp):
    # On initialise le client OVH
    client = ovh.Client()

    modificated = False

    for recordUrl in records:
        # On récupère le champ qui nous intéresse.
        recordId = client.get(recordUrl)
        # Si le tableau est vide, c'est caca
        if len(recordId) == 0:
            print(f"error no {recordUrl} record", file=sys.stderr)
            sys.exit(1)
        # On récupère le premier et seul élément du tableau retourné
        recordId = recordId[0]

        # On récupère ses informations, maintenant qu'on a l'id
        recordData = client.get(f"/domain/zone/{ZONE}/record/{recordId}")
        # En particulier, l'IP.
        oldIp = recordData['target']

        # Si l'IP ne correspond pas à celle qu'on a récupérée avec `ip address`
        if oldIp != newIp:
            modificated = True
            # On met à jour l'IP
            client.put(f"/domain/zone/{ZONE}/record/{recordId}", target=newIp)

    if modificated:
        # On publie la nouvelle version de la zone
        client.post(f"/domain/zone/{ZONE}/refresh")
        print(f"update from {oldIp} to {newIp}")
    else:
        print(f"unchanged {oldIp}")

Fichier complet
import json
import subprocess
import sys

import ovh


# Mettre ici le nom de l'interface qui nous intéresse
INTERFACE_NAME = "enp6s0"

# Mettre ici la zone qu'on veut éditer
ZONE = "ppom.me"

def getIp():
    # On exécute la commande `ip --json address`
    res = subprocess.run(["ip", "--json", "address"], capture_output=True, text=True)
    # On parse le JSON
    data = json.loads(res.stdout)

    addresses = [
        # On récupère le champ addr_info
        interface['addr_info']
        for interface in data
        # De l'interface paramétrée
        if interface['ifname'] == INTERFACE_NAME
    # Et on ne garde que le premier (et seul) élément de la liste
    ][0]
    
    # On récupère l'élément max
    ipv6 = max([ # de cette liste
            address
            for address in addresses
            # Si l'adresse est publique
            if address['scope'] == "global"
            # Si l'adresse est une IPv6
            and address['family'] == "inet6"
            # Si l'adresse n'est pas dépréciée
            and not ('deprecated' in address and address['deprecated'])
        ],
        # en calculant quel est l'élément max par son preferred_life_time
        key=lambda a: a['preferred_life_time']
    # On ne garde plus que l'adresse en elle-même
    )['local']

    return ipv6

def updateIp(newIp):
    # On initialise le client OVH
    client = ovh.Client()

    # On récupère le champ qui nous intéresse.
    # En l'occurence, le AAAA de ppom.me.
    recordId = client.get(f"/domain/zone/{ZONE}/record?fieldType=AAAA")
    # Si le tableau est vide, c'est caca
    if len(recordId) == 0:
        print(f"error no AAAA {ZONE}. record", file=sys.stderr)
        sys.exit(1)
    # On récupère le premier et seul élément du tableau retourné
    recordId = recordId[0]

    # On récupère ses informations, maintenant qu'on a l'id
    recordData = client.get(f"/domain/zone/{ZONE}/record/{recordId}")
    # En particulier, l'IP.
    oldIp = recordData['target']

    # Si l'IP ne correspond pas à celle qu'on a récupérée avec `ip address`
    if oldIp != newIp:
        # On met à jour l'IP
        client.put(f"/domain/zone/{ZONE}/record/{recordId}", target=newIp)
        # On publie la nouvelle version de la zone
        client.post(f"/domain/zone/{ZONE}/refresh")
        print(f"update from {oldIp} to {newIp}")
    else:
        print(f"unchanged {oldIp}")

# On exécute tout ça
updateIp(getIp())

Exécution du script🔗

Ici, on part du principe que :

# charger venv
$ source /usr/local/lib/ovh/bin/activate
# charger env vars
$ set -a
$ source /var/secrets/ovh
$ set +a
# exécuter script
$ python3 /usr/local/lib/update-zone.py

On peut créer un fichier qui fait tout ça : /usr/local/bin/update-zone

#!/usr/bin/env bash
source /usr/local/lib/ovh/bin/activate
set -a
source /var/secrets/ovh
set +a
python3 /usr/local/lib/update-zone.py

Automatisation🔗

Maintenant qu'on a notre script /usr/local/bin/update-zone et qu'il fonctionne (à vérifier de son côté !), on peut le mettre dans un cron pour qu'il soit exécuté toutes les 15 minutes :

$ crontab -e

Ajouter ceci :

*/15 * * * * /usr/local/bin/update-zone

Voir aussi : Comprendre la syntaxe CRON

Perso, je n'utilise pas cron, mais systemd. Ça demande un peu plus de fichiers et tout, mais les fonctionnalités sont bien plus nombreuses.

Automatisation avec systemd

On va créer un fichier .service, qui décrit une action à effectuer, et un fichier .timer, qui va lancer périodiquement l'exécution du fichier .service du même nom.

/etc/systemd/system/update-zone.service

[Unit]
Description=Mettre à jour l'entrée IPv6 d'une zone DNS chez OVH
Documentation=https://blog.ppom.me/dyndns-ovh

[Service]
EnvironmentFile=/var/secrets/ovh-token
ExecStart=bash -c "source /usr/local/lib/ovh/bin/activate && python3 /usr/local/lib/update-zone.py"

/etc/systemd/system/update-zone.timer

[Unit]
Description=Mettre à jour l'entrée IPv6 d'une zone DNS chez OVH
Documentation=https://blog.ppom.me/dyndns-ovh

[Timer]
OnCalendar=*:0/10

[Install]
WantedBy=multi-user.target

Maintenant on doit dire à systemd de prendre en compte ces nouveaux fichiers :

$ sudo systemctl daemon-reload

D'activer le timer au démarrage :

$ sudo systemctl enable update-zone.timer

Et de l'activer maintenant :

$ sudo systemctl start update-zone.timer

Si on veut lancer le script tout de suite maintenant, pour vérifier que ça marche bien :

$ sudo systemctl start update-zone.service

On peut voir les logs avec :

# ajouter -e pour aller à la fin des logs
# ajouter -f pour voir les nouveaux logs arriver en live
$ sudo journalctl -u update-zone

Et voilà, il n'y a plus qu'à attendre sagement que son adresse IPv6 change et s'extasier d'avoir enfin connecté son serveur au réseau IPv6, le futuur !