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 :
- Mon fournisseur d'accès Internet, Free, permet d'avoir une adresse IPv4 fixe (ex
1.2.3.4
), mais ne garantit pas de préfixe IPv6 fixe (ex12:34:ab:cd:/64
) - Mon registraire et hébergeur de ma zone DNS, OVH, supporte DynHost, une façon d'automatiser facilement la mise à jour d'une entrée DNS. Mais que pour IPv4 🤡
- En pratique je comprenais pas si bien comment ça marchait IPv6 sur des réseaux domestiques
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 IPv6CNAME
: 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.
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):
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 :
- les variables d'environnement sont dans un fichier
/var/secrets/ovh
(lisibles que depuis l'utilisateur qui en a besoin, faire lechmod
/chown
qui convient !) - le venv est à
/usr/local/lib/ovh
- le script python est exécutable et à
/usr/local/lib/update-zone.py
# 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 !