Mon lab - Épisode 2 : Automatiser ses gateways
2025-11-15
Je ne sais pas si vous vous souvenez, mais dans l'article précédent j'ai présenté la prochaine infrastructure et plus particulièrement la gestion des VMs et des réseaux.
Les machines (VMs et containers) seront sur leurs réseaux et n'ont aucun moyen de joindre l'extérieur ni même l'hyperviseur hôte. Il va falloir créer une passerelle. Autre point, si ces serveurs n'ont pas accès à l'hôte, ils n'ont pas accès à un serveur DHCP ou DNS. Autant le DNS ce n'est pas bien problématique, autant le DHCP cela signifie qu'il faut assigner des IPs à nos machines manuellement... Et je n'ai pas du tout envie de gérer des IPs (alors que j'ai netbox, mais ce n'est pas le sujet). Il y a une dernière fonctionnalité que j'aimerais mettre en place : joindre certains réseaux entre eux. Pour celleux qui travaillent sur des infrastructures cloud, vous voyez très bien où je vais : les VPC.
--------------------------------------------------------------------------------
Pour celleux qui n'ont jamais joué avec des VPC, l'idée est que les machines présentes dans VPC 1 peuvent communiquer entre elles sans être dans les mêmes réseaux privés mais celles de VPC 2 ne peuvent pas contacter les machines de VPC 1 ou VPC 3.
--------------------------------------------------------------------------------
Mais qui dit cloud dit création de ses propres images pour les déployer par la suite sans avoir besoin de lancer un playbook Ansible pour faire toute la configuration. Dans mon cas, je cherche à déployer rapidement les passerelles afin de permettre aux instances présentes dans un réseau privé d'avoir accès à internet et d'être accessible par la même occasion.
Il faut bien entendu stocker ces images. Sur Incus il y a deux moyens. Stocker ses images sur une des instances. Pratique, pas de dépendance tierce, mais peu résilient en cas de panne de l'instance. L'autre méthode est de créer un serveur "simplestreams".
Créer une image pour un host avec Packer
Pour celleux qui ne connaissent pas Packer, il s'agit d'un outil développé par Hashicorp pour créer des images identiques pour différents clouds providers. J'ai fait le choix d'utiliser Packer, car ce dernier récupère une image déjà existante comme base, la configure comme je veux et l'export en image le tout en 26 secondes. Il est possible de le faire sans Packer, je vous la décris plus bas, mais je n'ai pas retenu cette méthode.
La création de l'image
Il existe un plugin Incus pour Packer. Ce plugin permet de récupérer une image source et de configurer l'image formée par l'outil. C'est accompagné d'un builder qui se base sur une source et qui lance tout un tas de "provisionner" c'est-à-dire des actions pour altérer l'image et la modeler comme nous le souhaitons. Étant plutôt bien à l'aise avec Ansible j'ai tenté d'utiliser le provisionner Ansible sans grand succès, car je n'ai à aucun moment réussi à me connecter en SSH. Je suis donc parti⋅e sur notre bon vieux script bash accompagné tout de même d'un provisionner file.
Si vous avez lu le premier article, comme à chaque fois tout se trouve sur mon repository. Vous allez également constater la même arborescence de fichier :
dryusdan@Nemo:~/gateway-image-packer$ tree
.
├── dhcp_maker
│ ├── dhcp-maker.py
│ ├── poetry.lock
│ └── pyproject.toml
├── packer
│ ├── build.pkr.hcl
│ ├── plugins.pkr.hcl
│ ├── source.pkr.hcl
│ └── variables.pkr.hcl
├── provisioning.sh
└── rootfs
└── etc
├── default
│ └── unbound
├── locale.gen
├── nftables.conf
├── ssh
│ └── sshd_config.d
│ └── pxe-boot.conf
├── sysctl.d
│ └── 10-forwarding.conf
├── systemd
│ └── system
│ ├── dhcpmaker.service
│ └── nftables.service
└── unbound
└── unbound.conf.d
├── forwardzone.conf
└── server.conf
13 directories, 20 files
Côté Packer j'ai donc créé un dossier pour tout ranger. Honnêtement, je ne vais pas présenter plugins.pkr.hcl et variables.pkr.hcl. Par contre source.pkr.hcl m'intéresse
source "incus" "debian" {
image = var.image
output_image = var.output_image
publish_properties = {
aliases = var.aliases
description = var.description
os = var.os
release = var.release
architecture = var.architecture
public = true
variant = var.variant
}
reuse = true
}
"image" c'est l'image de base que je récupère, output_image est le nom de l'image exporté et reuse est pour dire que si l'image, existe tant pis, il faut écraser. Maintenant la directive publish_properties. Il s'agit en réalité des metadata properties que vous pouvez retrouver d'une simple commande
dryusdan@Nemp:~$ incus config metadata show meet-collie architecture: amd64 creation_date: 1761465660 expiry_date: 1764057660 properties: architecture: amd64 description: Debian trixie amd64 (cloud) (20251026_07:49) name: debian-trixie-amd64-cloud-20251026_07:49 os: debian release: trixie serial: "20251026_07:49" variant: cloud
Bien sûr, j'ai collé le tout dans des variables pour pouvoir automatiser tout ça. Il ne reste plus que le fichier build.pkr.hcl pour comprendre tout le reste de l'architecture de ce projet.
build {
sources = [
"incus.debian"
]
provisioner "file" {
destination = "/"
source = "./rootfs/"
}
provisioner "file" {
source = "./dhcp_maker/dhcp-maker.py"
destination = "/usr/local/bin/dhcp-maker"
}
provisioner "shell" {
script = "./provisioning.sh"
}
}
Ici je copie l'intégralité de mon rootfs à la racine du container pour déployer facilement toutes mes configurations (tiens, ça ne vous rappelle pas un rsync -azvP rootfs / ? 😇). Je copie aussi mon programme dchp-maker que je présenterai plus tard. Je lance ensuite le script ./provisioning.sh. Ce script va installer quelques dépendances pour dhcp-maker mais également nftables pour réaliser un NAT.
export DEBIAN_FRONTEND=noninteractive apt-get update apt-get -yq -o Dpkg::Options::="--force-confold" install \ curl \ dnsmasq \ less \ nftables \ python3 \ python3-yaml \ unbound \ resolvconf
À savoir que resolvconf va désinstaller systemd-networkd.
Ensuite ce script rend exécutable dhcp-maker, active nftables, recharge systemd pour pouvoir activer le service dhcpmaker, car ce dernier vient de la copie du rootfs plus haut.
echo "Add execution right for dhcp-maker" chmod +x /usr/local/bin/dhcp-maker echo "Enable nftables" systemctl enable nftables echo "Enable dhcpmaker" systemctl daemon-reload systemctl enable dhcpmaker
Ensuite il va permettre à la machine de faire office de passerelle réseau en activant l'IPv4 et IPv6 forwarding.
echo "Add sysctl" echo "net.ipv4.ip_forward=1" > /etc/sysctl.d/10-forwarding.conf echo "net.ipv6.conf.all.forwarding=1" >> /etc/sysctl.d/10-forwarding.conf
"dnsmasq" arrive avec tout un tas de valeurs par défaut déjà activées... Donc le script retire ça pour utiliser les configurations de dhcp-maker
echo "Disable and mask dnsmasq" systemctl disable dnsmasq systemctl mask dnsmasq echo "Remove default dnsmasq config" rm -f /etc/default/dnsmasq
J'ai également retiré la configuration trust-anchor de Unbound qui vient avec lui. Cette configuration ne fonctionne uniquement si resolveconf est installé et embarque le téléchargement des anchors-keys soit les clés et chemins vers les roots DNS. Sauf qu'ici chaque gateway ne va pas contacter les roots DNS directement, donc cela ne sert à rien.
echo "Remove Unbound bad config" rm -f /etc/unbound/unbound.conf.d/root-auto-trust-anchor-file.conf
Maintenant, très important, il va falloir réinitialiser cloud-init pour pouvoir utiliser la machine comme modèle et non comme copie d'une machine.
apt-get clean echo "Remove machine-id files" rm -f /var/lib/dbus/machine-id /etc/machine-id echo "Reset cloud-init" cloud-init clean --logs
L'auto-configuration du DHCP
J'utilise plusieurs services pour faire fonctionner correctement et de manière fluide cette passerelle. Tout d'abord, dnsmasq. Il gère la partie DHCP sur les interfaces dans le VPC, de serveur DNS autoritaire pour chaque réseau afin de ne pas avoir à connaitre l'IP d'une machine mais également de forwarder DNS vers Unbound. Vu qu'il peut y avoir plusieurs réseaux au sein d'un VPC, il peut y avoir plusieurs instances dnsmasq, mais toutes les instances ne peuvent pas écouter sur l'interface publique de cette gateway, ni se partager entre elles leurs baux DHCP. J'ai donc mis sur l'interface "public" un Unbound qui forward les requêtes DNS vers un autre serveur DNS mais également qui cache les réponses pour tous les réseaux du VPC. Cet Unbound permet également de forward vers dnsmasq les requêtes liées à un hostname.
J'ai fait un schéma pour essayer d'être plus clair.
Pour faire simple. dnsmasq fait du DHCP et grâce à ça construit sa zone DNS, car il connait l'IP et le hostname associé. Si une requête ne correspond à rien dans sa zone, il transmet la requête à Unbound qui lui va avoir 2 instructions : s'il connait le domaine demandé (genre pn2.dryusdan.net) il transmet la requête au dnsmasq associé, sinon il renvoie la requête au serveur DNS principal qui fait sa tambouille.
Sachant que tout cela doit être automatique, j'ai développé un script qui configure tout à la création de la machine. Ce script va
- Récupérer toutes les interfaces comportant un nom particulier et les trier
- Retourner une liste de sous-réseau en fonction d'un réseau défini (en gros je split 10.0.0.0/8 en /22).
- Attribuer un sous-réseau à chaque interface détectée.
- Générer les configurations de dnsmasq, unbound et de systemd-networkd
- Écrire ces configurations
- S'occuper des services pour être reboot proof.
Je ne vais pas passer sur l'intégralité du script qui se trouve sur mon repository git, parce qu'il sera amené à évoluer (pour intégrer l'IPv6 par exemple) mais je vais tout de même m'arrêter sur trois fonctions.
La fonction config_service va spécifiquement générer la configuration de dnsmasq. Je crée une configuration liée à l'interface iif (qui sera écrite ensuite sous forme /etc/default/dnsmasq.{iif} pour être appelée par un service dnsmasq déjà existant). Ici j'indique à dnsmasq d'ignorer le fichier resolv.conf localisé dans /run/dnsmasq/resolv.conf et de ne pas bind l'interface lo (notamment utilisée par Unbound). Puis je bind dnsmasq sur l'interface {iif}, je lui indique son domaine qui est le nom de l'interface + le hostname (en réalité ça me fait des noms à rallonge du genre pn50.gw1.cluster.dryusdan.net mais le domaine est géré par CoreDNS avec comme source Netbox). J'indique ensuite quel range dnsmasq doit utiliser pour son DHCP et la durée du bail.
def config_service(self, iif_net: dict) -> dict:
configs: dict = {}
for iif in iif_net:
net = iif_net[iif]
configs.update(
{
iif: {
"dnsmasq": {
"IGNORE_RESOLVCONF": "yes",
"DNSMASQ_EXCEPT": "lo",
"DNSMASQ_OPTS": f'"--interface={iif} --except-interface=lo --bind-interfaces --domain {iif}.{socket.gethostname()} --dhcp-range {net["ipv4"][2]},{net["ipv4"][-1]},12h --server ::1 --server 127.0.0.1"',
},
"networkd": {"ipv4": net["ipv4"][1]},
}
}
)
return configs
Cette fonction permet de configurer Unbound pour lui indiquer que les requêtes pour le domaine pn50.gw1.cluster.dryusdan.net se trouve au niveau de l'IP que dnsmasq écoute.
def write_unbound_configs(self, configs: dict) -> None:
p = Path("/etc/unbound/unbound.conf.d/")
for iif in configs:
q = p / f"forwardzone-{iif}.conf"
unbound_conf: dict = {
"forward-zone": {
"name": f"{iif}.{socket.gethostname()}",
"forward-addr": str(configs[iif]["networkd"]["ipv4"]),
}
}
with q.open("w") as f:
yaml.dump(unbound_conf, f, default_flow_style=False)
Cette fonction va rechercher systemd-networkd, désactiver dhcpmaker pour éviter une modification des configurations ci-dessus puis activer le service "variable" packagé avec dnsmasq.
def do_with_systemd(self, configs: dict) -> None:
subprocess.run(["systemctl", "reload", "systemd-networkd"])
subprocess.run(["systemctl", "disable", "dhcpmaker"])
for iif in configs:
subprocess.run(["systemctl", "enable", "--now", f"dnsmasq@{iif}"])
Limitations
"dhcp-maker" a plusieurs limitations. Pas de conservation des IPs, en cas de redémarrage il est possible que le serveur DHCP attribue d'autres IPs. Il est également possible qu'en cas de réinstallation les attributions des ranges d'IPs changent également. Si on rajoute une interface, le script ne se relance pas, il faut même recréer la machine. Ce ne sont que des axes d'amélioration mais ces limitations existent (Quoique relancer le dhcp-maker devrait maintenant suffire).
Le serveur simplestreams
Bon c'est cool, j'ai build une image avec Packer je l'ai importé sur une des machines. Mais si je réinstalle cette machine (comme il est prévu qu'elles soient réinstallables à volonté), je suis obligé⋅e de reconstruire l'image et de la pousser à nouveau. Je pourrais la déployer sur chaque machine, mais au final ça corrige le problème de SPOF, pas de réinstallation. L'idéal serait de trouver un moyen d'héberger une image comme le fait Linuxcontainers. Canonical à l'époque de LXD a développé un protocole nommé simplestreams et cela a été repris dans Incus. Bon, protocole c'est TRÈS vite dit, il s'agit plutôt d'une convention de nommage et d'organisation de fichiers accessible via HTTPS. Ce protocole est utilisé si on veut que notre serveur héberge ses propres images mais n'est que peu documenté... Cependant, assez récemment les gens derrière Incus ont développé un outil pour permettre de gérer facilement les images et de créer les fichiers nécessaires afin d'héberger ses images derrière n'importe quel serveur Web, S3, whatever. La commande incus-simplestreams vient avec le paquet incus-extra pour Debian ou incus-tools pour Archlinux.
Pour créer une image publique, il faut une archive .tar.xz contenant les metadata et soit une image qcow2 si c'est une VM soit un squashfs si c'est un container.
dryusdan@Nemo:~/images$ incus-simplestreams add incus.tar.xz rootfs.squashfs
dryusdan@Nemo:~/images$ incus-simplestreams list
+------------------------------------------------------------------+------------------------------------+--------+---------+---------+--------------+-----------+----------------------+
| FINGERPRINT | DESCRIPTION | OS | RELEASE | VARIANT | ARCHITECTURE | TYPE | CREATED |
+------------------------------------------------------------------+------------------------------------+--------+---------+---------+--------------+-----------+----------------------+
| bb98cde6f1616ac60ac75fed1a45a347042dd128e9fbbd03bf46aef75bd5516a | debian trixie amd64 (202511141828) | debian | trixie | gateway | x86_64 | container | 2025/11/14 00:00 UTC |
+------------------------------------------------------------------+------------------------------------+--------+---------+---------+--------------+-----------+----------------------+
dryusdan@Nemo:~/images$ tree
.
├── images
│ ├── 10d7770cf96df16551b5c09e5c6548de61d95e37182aa223ee781dad1b84f3a3.incus.tar.xz
│ └── 10d7770cf96df16551b5c09e5c6548de61d95e37182aa223ee781dad1b84f3a3.squashfs
└── streams
└── v1
├── images.json
└── index.json
4 directories, 6 files
Ces fichiers-là, vous les exportez où vous voulez. Puis sur votre serveur Incus, tapez la commande :
root@kida ~ # incus remote add customimage https://images.example.tld --protocol=simplestreams
Honnêtement quand j'ai découvert ça, ça m'a beaucoup aidé. J'ai un peu de configuration à perfectionner (comme automatiser le push des images) mais la base est présente.
Améliorer la partie Packer
Je vais tout de même améliorer le déploiement avec Packer, parce que même si cette solution nous sort une image à exporter, en l'exportant on récupère les metadonnées nécessaires qu'on a modifié, un rootfs qu'on peut "squashfs-isé" et des templates dont je vous parlerai en temps voulu.
Je reprends globalement tout ce que j'ai déjà fait. Je variablise quelques informations
IMAGE="images:debian/13/cloud"
OUTPUT_IMAGE="debian-gateway"
ALIASES="${OUTPUT_IMAGE}"
DESCRIPTION="Gateway image for Dryusdan's cloud"
OS="debian"
RELEASE="trixie"
ARCHITECTURE="amd64"
VARIANT="gateway"
# Ici j'ai quelques variables utilisées plus tard
EXPORT_PATH="$(mktemp -d)"
REBUILD_PATH="$(mktemp -d)"
IMAGE_PATH="images"
IMAGE_NAME="${IMAGE_PATH}/rootfs.squashfs"
Je lance le build de Packer.
packer build \
-var "image=${IMAGE}" \
-var "output_image=${OUTPUT_IMAGE}" \
-var "aliases=${ALIASES}" \
-var "description=${DESCRIPTION}" \
-var "os=${OS}" \
-var "release=${RELEASE}" \
-var "architecture=${ARCHITECTURE}" \
-var "variant=${VARIANT}" \
./packer
Je récupère l'image.
fingerprint=$(incus image ls "${OUTPUT_IMAGE}" -f json | jq -r .[].fingerprint)
incus image export "${fingerprint}" "${EXPORT_PATH}/${fingerprint}"
En root, j'extrais l'image, je récupère les fichiers metadata.yaml et templates/ pour les recompresser. Puis je crée un squashfs avec le dossier rootfs.
Je viens de constater que sans être root, le tar désarchive en utilisant l'utilisateur actuel, le miens étant 1000, tout mon système était donc avec l'UID 1000 et non 1. Il faut donc lancer cette partie en root.
tar --same-owner --extract --file "${EXPORT_PATH}/${fingerprint}.tar.gz" --directory "${REBUILD_PATH}"
tar --same-owner --create --xz --file "${IMAGE_PATH}/incus.tar.xz" --directory "${REBUILD_PATH}" metadata.yaml templates/
mksquashfs "${REBUILD_PATH}/rootfs" "${IMAGE_NAME}" -comp xz -e boot
Je vais m'attarder sur les fichiers contenus dans incus.tar.xz. Le fichier metadata.yaml est créé par Incus et Packer, donc il est formaté comme je le souhaite. Le dossier "templates", lui, possède des fichiers qui seront copiés dans le container ou la VM lors de son déploiement, notamment le hostname.tpl pour que son hostname justement soit le nom donné dans Incus. Ou encore des pré-configurations pour cloud-init.
J'ai créé un script nommé bootstrap.sh qui effectue toutes ces actions. Tout ce qu'il reste à faire après c'est ajouter l'image dans le "serveur simplestreams".
Sans utiliser Packer
Avant de saisir comment était formée une image construite avec les méthodes précédentes, j'ai développé une méthode pour construire ses propres images. Ça se base forcément sur du debootstrap. Cette fois-ci le repository a changé : gateway-image mais ne sera pas maintenu, la méthode est fonctionnelle et je vous la présente parce que certain⋅e⋅s ne voudront pas utiliser Packer 😉.
Les configurations du rootfs sont celles tirées du repository gateway-image-packer. Le fichier get-templates.sh va récupérer l'archive contenant les metadatas et les templates qui sont utiles pour cloud-init notamment. Le fichier generate_metadata.py va basiquement altérer le fichier metadata.yaml pour y modifier certaines valeurs comme la date de création ou surtout les propriétés. Le fichier make-squashfs.sh va lancer un debootstrap (vu et revus), installer des dépendances, supprimer des fichiers et configurer systemd. Ça ressemble à ce que je vous ai montré au tout début de cet article. Puis je run un mksquashfs.
En réalité, il existe même un fichier bootstrap.sh (encore un) qui permet de lancer tout ça en une seule commande.
Je ne m'attarde pas plus sur cette méthode, car la différence entre celle-ci et Packer réside dans le debootstrap, debootstrap vu dans l'article précédent ou dans Installer une Debian stock sur un Raspberry PI.
--------------------------------------------------------------------------------
Maintenant vous savez créer une image custom pour votre infrastructure Incus, notamment une gateway qui nous sera utile dans le prochain article !
À bientôt pour l'épisode 3 ! Et si vous ne voulez pas le louper, abonnez vous au flux /RSS
Sur ce, portez vous bien :)
Photo de Wai Siew
--------------------------------------------------------------------------------