diff --git a/.forgejo/workflows/docs.yml b/.forgejo/workflows/docs.yml
new file mode 100644
index 0000000..e8b7b07
--- /dev/null
+++ b/.forgejo/workflows/docs.yml
@@ -0,0 +1,168 @@
+name: Documentation
+
+# Un tag de release ajoute une version au site : la publication est donc
+# déclenchée par les deux, sans filtre de chemin sur les tags — c'est le tag
+# lui-même qui est la nouveauté, pas un fichier modifié.
+on:
+ push:
+ branches:
+ - main
+ tags:
+ - '[0-9]*.[0-9]*.[0-9]*'
+ workflow_dispatch:
+
+# Deux publications simultanées se pousseraient l'une sur l'autre.
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ publish:
+ runs-on: docker
+ env:
+ TOKEN: ${{ secrets.RELEASE }}
+ SITE_DIR: /tmp/site
+ # Préfixe de chemin sous lequel le site est servi. Vide = racine du
+ # domaine, ce qui couvre le cas courant et un serveur de test local.
+ # À renseigner (par exemple /two) seulement si les pages sont publiées
+ # sous un sous-chemin. Aucun nom d'hôte ici : les liens du menu de
+ # version sont relatifs à l'origine, donc le site fonctionne à
+ # l'identique en local et en production.
+ DOCS_BASE_PATH: ''
+ steps:
+ # fetch-depth: 0 — les tags et leur contenu sont nécessaires : chaque
+ # version est construite depuis son propre ref.
+ - uses: actions/checkout@v3
+ with:
+ fetch-depth: 0
+
+ - name: Installer Sphinx
+ run: |
+ apt-get update
+ apt-get install -y python3 python3-venv git
+ python3 -m venv /tmp/venv
+ /tmp/venv/bin/pip install --quiet --upgrade pip
+ /tmp/venv/bin/pip install --quiet -r docs/requirements.txt
+
+ # Les versions publiables : main, plus les tags finaux qui contiennent
+ # déjà un répertoire docs/. 0.1.0 est antérieure à la documentation et
+ # n'est donc pas constructible — le filtre l'écarte de lui-même, sans
+ # liste à maintenir.
+ - name: Choisir les versions à publier
+ run: |
+ versions=""
+ for tag in $(git tag --sort=-v:refname)
+ do
+ case "${tag}" in *rc*) continue ;; esac
+ if git ls-tree --name-only "${tag}" | grep -qx docs
+ then
+ versions="${versions} ${tag}"
+ else
+ echo "ignoré : ${tag} n'a pas de docs/"
+ fi
+ done
+ echo "VERSIONS=${versions# }" >> "${GITHUB_ENV}"
+ echo "versions retenues : main${versions}"
+
+ # main est construite en premier et son échec est fatal : la doc courante
+ # doit toujours partir. L'échec d'une version figée est signalé mais ne
+ # bloque pas la publication — une vieille version qui ne se reconstruit
+ # plus ne doit pas empêcher de publier la doc du jour.
+ - name: Construire chaque version
+ run: |
+ build () {
+ local ref="$1" src="$2"
+ DOCS_VERSION="${ref}" \
+ /tmp/venv/bin/sphinx-build -b html -W --keep-going \
+ -d "/tmp/doctrees-${ref}" "${src}/docs" "${SITE_DIR}/${ref}"
+ }
+
+ build main .
+
+ for version in ${VERSIONS}
+ do
+ rm -rf "/tmp/src-${version}"
+ git worktree add --quiet --detach "/tmp/src-${version}" "${version}"
+ if build "${version}" "/tmp/src-${version}"
+ then
+ echo "construit : ${version}"
+ else
+ echo "::warning::la version ${version} ne se construit plus, elle est absente du site"
+ rm -rf "${SITE_DIR}/${version}"
+ fi
+ git worktree remove --force "/tmp/src-${version}"
+ done
+
+ # Les liens du menu de version sont relatifs à l'origine : le thème les
+ # concatène au chemin de la page courante avant de les poser en href, si
+ # bien qu'un chemin relatif y serait résolu depuis la page et casserait
+ # selon sa profondeur. Une barre initiale les ancre à la racine du site,
+ # sans jamais nommer d'hôte.
+ - name: Assembler la racine du site
+ run: |
+ preferred="$(echo ${VERSIONS} | tr ' ' '\n' | head -1)"
+ [ -n "${preferred}" ] || preferred="main"
+
+ {
+ echo '['
+ echo ' {"name": "dev (main)", "version": "main", "url": "'"${DOCS_BASE_PATH}"'/main/"},'
+ first=1
+ for version in ${VERSIONS}
+ do
+ [ -d "${SITE_DIR}/${version}" ] || continue
+ [ ${first} -eq 1 ] && suffix=', "preferred": true' || suffix=''
+ first=0
+ echo ' {"name": "'"${version}"'", "version": "'"${version}"'", "url": "'"${DOCS_BASE_PATH}"'/'"${version}"'/"'"${suffix}"'},'
+ done
+ } | sed '$ s/,$//' > "${SITE_DIR}/switcher.json"
+ echo ']' >> "${SITE_DIR}/switcher.json"
+
+ python3 -c "import json,sys; json.load(open('${SITE_DIR}/switcher.json'))"
+ cat "${SITE_DIR}/switcher.json"
+
+ # La racine ne sert qu'à rediriger : le contenu vit dans les
+ # sous-répertoires de version.
+ cat > "${SITE_DIR}/index.html" <
+
+
+
+ two — documentation
+
+
+ Documentation de two
+
+ HTML
+
+ - name: Alléger le site
+ run: |
+ find "${SITE_DIR}" -name '*.map' -delete
+ find "${SITE_DIR}" -name '.buildinfo' -delete
+ touch "${SITE_DIR}/.nojekyll"
+ du -sh "${SITE_DIR}"
+
+ - name: Publier sur la branche pages
+ run: |
+ cd "${SITE_DIR}"
+ git init --quiet --initial-branch=pages
+ git config user.name "forgejo-actions"
+ git config user.email "forgejo-actions@git.g3e.fr"
+ git remote add origin "https://${TOKEN}@git.g3e.fr/${{ github.repository }}.git"
+
+ if git fetch --quiet --depth=1 origin pages 2>/dev/null
+ then
+ git reset --soft FETCH_HEAD
+ else
+ echo "branche pages absente : premier build"
+ fi
+
+ git add -A
+ if git diff --cached --quiet
+ then
+ echo "site identique au précédent, rien à publier"
+ exit 0
+ fi
+
+ git commit --quiet -m "docs: build de ${GITHUB_SHA}"
+ git push --quiet origin pages
+ echo "publié : $(git rev-parse --short HEAD) — $(git ls-files | wc -l) fichiers"
diff --git a/.gitignore b/.gitignore
index 68ae417..fd7d60e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,6 @@ go.work.sum
# ignore local info
data/
+
+# Sphinx build output
+docs/_build/
diff --git a/README.md b/README.md
index 9439539..20627f4 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ Options utiles :
| `-d` | dry-run : affiche les commandes sans les exécuter |
| `-V` | désactiver la vérification des sommes de contrôle |
-Un déploiement relève les instances `dnsmasq@` et `metadata@` actives **avant** l'arrêt des
+Un déploiement relève les instances `dnsmasq@`, `dhcp@` et `metadata@` actives **avant** l'arrêt des
services, et les redémarre ensuite — c'est la seule façon de savoir lesquelles relancer.
## Configuration
diff --git a/docs/architecture/vue-densemble.rst b/docs/architecture/vue-densemble.rst
index b8fb8e8..a0f5c3f 100644
--- a/docs/architecture/vue-densemble.rst
+++ b/docs/architecture/vue-densemble.rst
@@ -50,7 +50,7 @@ Paquets
* - ``internal/vm``
- cycle de vie d'une VM : tap, iptables, metadata, qemu
* - ``internal/dhcp``
- - génération des configurations dnsmasq et entrées ip → mac
+ - plan d'adressage ip → mac, et configurations dnsmasq du backend historique
* - ``internal/metadata``
- serveur de metadata cloud-init et ses templates
* - ``internal/watchdog``
diff --git a/docs/concepts/vpc-subnet-vm.rst b/docs/concepts/vpc-subnet-vm.rst
index ac1e673..6337ea9 100644
--- a/docs/concepts/vpc-subnet-vm.rst
+++ b/docs/concepts/vpc-subnet-vm.rst
@@ -24,7 +24,8 @@ Subnet
------
Un subnet appartient à un VPC et pose, dans son netns, un bridge qui porte ``interface_ip`` — la
-gateway vue par les VM. Il fournit aussi le DHCP (dnsmasq) et les routes annoncées aux guests.
+gateway vue par les VM. Il fournit aussi le DHCP — dnsmasq ou le serveur intégré selon
+``dhcp.backend`` — et les routes annoncées aux guests.
``iface_type`` est une clé **logique** (``vms``, ``internet``, ``admin``…), traduite en nom de
bridge physique par la configuration de l'agent. Une clé absente ou inconnue retombe sur
diff --git a/docs/conf.py b/docs/conf.py
index 19d4d9e..e6cdf00 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -3,6 +3,8 @@
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
+import os
+
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
@@ -43,6 +45,48 @@ html_theme = 'sphinx_book_theme'
html_static_path = []
html_show_sphinx = False
+# Le thème publie le source de chaque page dans _sources/ et l'expose derrière
+# un bouton de téléchargement. Les deux vont ensemble : couper la copie sans
+# couper le bouton laisserait un lien mort vers un répertoire vide.
+html_copy_source = False
+html_show_sourcelink = False
+
+# Le sélecteur de version est piloté par le workflow de publication : hors CI
+# la variable est absente, le sélecteur n'apparaît pas, et le build ne dépend
+# d'aucun réseau.
+_docs_version = os.environ.get('DOCS_VERSION')
+
html_theme_options = {
'home_page_in_toc': True,
+ 'use_download_button': False,
+ 'icon_links': [
+ {
+ 'name': 'Dépôt',
+ 'url': 'https://git.g3e.fr/syonad/two',
+ 'icon': 'fa-solid fa-code-branch',
+ 'type': 'fontawesome',
+ },
+ ],
}
+
+if _docs_version:
+ html_theme_options['switcher'] = {
+ # Chemin relatif volontairement : le thème le résout contre la racine
+ # de la version courante, donc toujours dans la même origine que la
+ # page. Une URL absolue ferait échouer la requête en CORS dès que le
+ # site est consulté depuis un autre hôte — un serveur de test local,
+ # par exemple.
+ 'json_url': '../switcher.json',
+ 'version_match': _docs_version,
+ }
+ # Le thème book vide navbar_start et place tout dans la barre latérale : le
+ # sélecteur doit donc y être inséré explicitement, à côté du logo.
+ html_sidebars = {
+ '**': [
+ 'navbar-logo.html',
+ 'icon-links.html',
+ 'version-switcher.html',
+ 'search-button-field.html',
+ 'sbt-sidebar-nav.html',
+ ]
+ }
diff --git a/docs/demarrage/installation.rst b/docs/demarrage/installation.rst
index 56c4cda..ed4217a 100644
--- a/docs/demarrage/installation.rst
+++ b/docs/demarrage/installation.rst
@@ -73,6 +73,10 @@ Ce que fait ``-i``
**masqué** : il prendrait le port 53 en concurrence des instances ``dnsmasq@`` que l'agent lance
dans les netns.
+``dnsmasq`` reste installé même avec ``dhcp.backend: two`` : le backend intégré ne le remplace que
+pour les subnets créés après la bascule, et le paquet est nécessaire tant qu'un hyperviseur peut
+revenir en arrière. Voir :doc:`/exploitation/configuration`.
+
**Noyau** — chargement de ``br_netfilter``, puis ``net.ipv4.ip_forward = 1`` et
``net.bridge.bridge-nf-call-iptables = 1``. Cette dernière clé est **requise** par la DNAT vers
le serveur de metadata : sans elle, iptables ne voit pas le trafic bridgé des VM et cloud-init
@@ -118,16 +122,21 @@ Binaires installés
* - ``db``
- inspection de la base clé-valeur en ligne de commande
- ``-conf``
+ * - ``dhcp``
+ - serveur DHCP intégré, une instance par subnet dans le netns du VPC ; démarré uniquement
+ avec ``dhcp.backend: two``
+ - ``-conf``
-Les trois partagent le même fichier, ``/etc/two/agent.yml`` — voir
-:doc:`/exploitation/configuration`.
+Les quatre partagent le même fichier, ``/etc/two/agent.yml`` — voir
+:doc:`/exploitation/configuration`. ``dhcp`` reçoit en plus son bridge et ses deux chemins de
+fichiers en paramètres, posés par son script d'enrobage.
Mise à jour
-----------
-``deploy.sh`` relève les instances ``dnsmasq@`` et ``metadata@`` actives **avant** d'arrêter les
-services, et les redémarre ensuite : c'est la seule façon de savoir lesquelles relancer. Arrêter
-les services à la main avant de lancer le script fait perdre cette liste.
+``deploy.sh`` relève les instances ``dnsmasq@``, ``dhcp@`` et ``metadata@`` actives **avant**
+d'arrêter les services, et les redémarre ensuite : c'est la seule façon de savoir lesquelles
+relancer. Arrêter les services à la main avant de lancer le script fait perdre cette liste.
Vérifier l'installation
-----------------------
diff --git a/docs/deploiement/image-qcow2.rst b/docs/deploiement/image-qcow2.rst
index 63ddc28..1283f52 100644
--- a/docs/deploiement/image-qcow2.rst
+++ b/docs/deploiement/image-qcow2.rst
@@ -182,7 +182,7 @@ disques : le disque de travail et le disque cible ne doivent pas être confondus
cd /work
curl "${os_link}" -O
- qemu-img convert ./*.qcow2 -O raw ${os_disk}
+ qemu-img convert ./*.qcow2 -O raw "${os_disk}"
L'image du fournisseur est écrite **en brut** directement sur le disque cible : le qcow2 obtenu
côté host contient donc une image disque complète et amorçable, sans backing file.
@@ -194,7 +194,7 @@ côté host contient donc une image disque complète et amorçable, sans backing
sleep 2
# La partition racine est la plus grande du disque
- root_partition=$(fdisk -lo device,size /dev/sda | grep -E '^\/dev\/' | tr -s ' ' \
+ root_partition=$(fdisk -lo device,size "${os_disk}" | grep -E '^/dev/' | tr -s ' ' \
| sort -rhk2 | head -n1 | cut -d ' ' -f1)
mount -o nouuid $root_partition /mnt
diff --git a/docs/exploitation/api-agent/asynchronisme.rst b/docs/exploitation/api-agent/asynchronisme.rst
index 96b5172..a506d55 100644
--- a/docs/exploitation/api-agent/asynchronisme.rst
+++ b/docs/exploitation/api-agent/asynchronisme.rst
@@ -18,7 +18,7 @@ Les deux temps d'une requête
A->>A: Prepare — valide, écrit "creating"
A-->>C: 202 + ressource en creating
A->>W: Dispatch (file d'attente)
- W->>W: Execute — netns, netif, dnsmasq
+ W->>W: Execute — netns, netif, dhcp
W->>W: état → running (ou error)
C->>A: GET /subnets/
A-->>C: 200 + state
diff --git a/docs/exploitation/configuration.rst b/docs/exploitation/configuration.rst
index 63b29a9..7024527 100644
--- a/docs/exploitation/configuration.rst
+++ b/docs/exploitation/configuration.rst
@@ -1,12 +1,19 @@
Configuration
=============
-Un seul fichier, ``/etc/two/agent.yml``, partagé par les trois binaires : ``agent -config``,
-``metadata -conf`` et ``db -conf``. Le fichier de référence commenté est
+Un seul fichier, ``/etc/two/agent.yml``, partagé par les quatre binaires : ``agent -config``,
+``metadata -conf``, ``db -conf`` et ``dhcp -conf``. Le fichier de référence commenté est
``conf/agent/config.exemple.yml`` dans le dépôt.
Le chargement se fait par **viper** : les clés sont celles ci-dessous, en YAML.
+.. warning::
+
+ Un fichier **absent** est toléré : toutes les valeurs par défaut s'appliquent. Un fichier
+ **présent mais invalide** fait en revanche échouer le démarrage, volontairement — jusqu'à
+ la version 0.1.0 il était ignoré en silence, et l'agent tournait alors entièrement sur les
+ défauts sans le dire. Une tabulation d'indentation ou un ``--`` égaré suffisent.
+
.. danger::
**L'API de l'agent n'a aucune authentification.** L'exemple livré écoute sur
@@ -117,6 +124,71 @@ Les chemins OVMF sont nécessaires aux VM démarrées avec ``uefi: true`` (paque
Debian et Ubuntu). ``uefi_vars_dir`` reçoit une copie inscriptible des variables UEFI par VM,
créée au démarrage et supprimée à l'arrêt.
+Backend DHCP
+------------
+
+.. code-block:: yaml
+
+ dhcp:
+ backend: dnsmasq # ou two
+
+Choisit qui sert le DHCP des subnets **créés par cet agent** :
+
+.. list-table::
+ :header-rows: 1
+ :widths: 14 44 42
+
+ * - Valeur
+ - Serveur
+ - Unit
+ * - ``dnsmasq``
+ - dnsmasq, configuré par fichiers dans ``/etc/dnsmasq.d``
+ - ``dnsmasq@_``
+ * - ``two``
+ - le binaire ``dhcp``, piloté par socket Unix
+ - ``dhcp@_``
+
+Le défaut est ``dnsmasq`` : un fichier de configuration de la 0.1.0, non modifié, se comporte
+exactement comme avant. Toute autre valeur que ``dnsmasq`` ou ``two`` fait échouer le démarrage.
+
+Le répertoire d'exécution du backend ``two`` — ``/run/two/dhcp`` — **n'est pas configurable** :
+le script d'enrobage le code en dur, une clé que lui ignorerait serait un mensonge.
+
+Ce que le backend ``two`` apporte : la configuration DHCP devient modifiable par VM et non plus
+seulement par subnet, ce qui permet de n'annoncer la route par défaut que sur **une** interface
+d'une VM multi-réseaux. Le watchdog peut en outre interroger le serveur et comparer ce qu'il sert
+à ce que la base dit — voir :doc:`diagnostic`.
+
+Bascule d'un backend à l'autre
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. warning::
+
+ L'option ne décide que du backend des **nouveaux** subnets. Elle ne migre rien : un subnet
+ déjà créé continue d'être servi par le serveur qui l'a été. Changer la valeur sans vider
+ l'hyperviseur laisse l'agent parler à un serveur qui ne tourne pas — les VM existantes
+ continuent, les nouvelles n'obtiennent pas d'adresse.
+
+La bascule est **manuelle** et suppose un hyperviseur vide :
+
+1. Supprimer toutes les VM, puis tous les subnets, puis les VPC.
+2. Vérifier qu'il ne reste aucune unit DHCP active et aucun résidu :
+
+ .. code-block:: bash
+
+ systemctl list-units 'dnsmasq@*' 'dhcp@*'
+ ls /etc/dnsmasq.d/ /run/two/dhcp/
+
+3. Modifier ``dhcp.backend`` dans ``/etc/two/agent.yml``.
+4. ``systemctl restart agent`` — la valeur est lue au démarrage, pas à chaque commande.
+5. Recréer VPC, subnets et VM.
+6. Sur la première VM, vérifier l'adresse **et les trois routes** : la route par défaut, la
+ route vers le CIDR du VPC, et la route ``/32`` vers ``169.254.169.254``. C'est cette
+ dernière qui conditionne le provisionnement cloud-init.
+
+Le retour arrière suit la même procédure. Il n'y a pas de bascule à chaud, dans un sens ni dans
+l'autre.
+
Watchdog
--------
diff --git a/docs/exploitation/diagnostic.rst b/docs/exploitation/diagnostic.rst
index 89f7b81..b60200c 100644
--- a/docs/exploitation/diagnostic.rst
+++ b/docs/exploitation/diagnostic.rst
@@ -25,7 +25,9 @@ partiellement créés subsistent.
La VM démarre mais n'a pas d'adresse
------------------------------------
-Le DHCP est servi par l'instance ``dnsmasq@`` du subnet.
+Le DHCP est servi par une instance dédiée au subnet. Quelle unit selon ``dhcp.backend`` :
+
+**Backend ``dnsmasq``**
.. code-block:: bash
@@ -34,7 +36,31 @@ Le DHCP est servi par l'instance ``dnsmasq@`` du subnet.
cat /run/dnsmasq-_.leases
cat /etc/dnsmasq.d/_.conf
-Si dnsmasq ne voit passer aucune requête, le problème est en amont : tap absent, bridge non
+**Backend ``two``**
+
+.. code-block:: bash
+
+ systemctl status 'dhcp@_'
+ journalctl -u 'dhcp@_' -n 50
+
+ # Ce que le serveur a réellement en mémoire
+ echo '{"verb":"get-state"}' \
+ | socat - UNIX-CONNECT:/run/two/dhcp/_.sock | jq .
+
+ # Ce qu'il enverrait à une MAC donnée, sans effet de bord
+ echo '{"verb":"probe","mac":"00:22:33:00:00:0A"}' \
+ | socat - UNIX-CONNECT:/run/two/dhcp/_.sock | jq .lease
+
+``probe`` est le point de départ le plus rapide : il montre l'adresse, le masque, le routeur, les
+DNS et les routes classless tels qu'ils partiraient. Une réponse ``"served": false`` signifie que
+la MAC n'est pas réservée — l'ordre ``set-host`` n'a jamais atteint le serveur, ou la VM n'a pas
+été créée par cet agent.
+
+Le watchdog signale ces écarts de lui-même, à chaque tick, en comparant l'état servi à la base :
+``dhcp reservation missing on the server``, ``stale dhcp reservation``, ``dhcp reservation
+diverges``. Regarder ses notifications avant de sonder à la main.
+
+Si le serveur ne voit passer aucune requête, le problème est en amont : tap absent, bridge non
raccordé, VM dans le mauvais netns.
La VM a une adresse mais cloud-init n'applique rien
diff --git a/docs/exploitation/observabilite.rst b/docs/exploitation/observabilite.rst
index 05392d1..b8f7a4c 100644
--- a/docs/exploitation/observabilite.rst
+++ b/docs/exploitation/observabilite.rst
@@ -57,7 +57,8 @@ Journaux
journalctl -u agent -f
journalctl -u 'metadata@i-web' -n 50
- tail -f /var/log/dnsmasq-vp-admin_br-sn000001.log
+ tail -f /var/log/dnsmasq-vp-admin_br-sn000001.log # backend dnsmasq
+ journalctl -fu 'dhcp@vp-admin_br-sn000001' # backend two
Inspection de la base
---------------------
diff --git a/docs/exploitation/services.rst b/docs/exploitation/services.rst
index 4e096cc..146f98c 100644
--- a/docs/exploitation/services.rst
+++ b/docs/exploitation/services.rst
@@ -1,7 +1,8 @@
Services systemd
================
-Trois units, installées sous ``/opt/two/bin`` par ``deploy.sh``.
+Quatre units, installées sous ``/opt/two/bin`` par ``deploy.sh``. Les deux units DHCP
+s'excluent : celle qui tourne dépend de ``dhcp.backend`` (voir :doc:`configuration`).
.. list-table::
:header-rows: 1
@@ -15,7 +16,10 @@ Trois units, installées sous ``/opt/two/bin`` par ``deploy.sh``.
- processus principal : API, dispatcher, exécution, watchdog
* - ``dnsmasq@.service``
- ``_``
- - dnsmasq lancé dans le netns du VPC, un par subnet
+ - dnsmasq lancé dans le netns du VPC, un par subnet — backend ``dnsmasq``
+ * - ``dhcp@.service``
+ - ``_``
+ - serveur DHCP intégré, un par subnet — backend ``two``
* - ``metadata@.service``
- ````
- serveur de metadata cloud-init, un par VM
@@ -26,14 +30,15 @@ n'y a pas à les démarrer à la main en fonctionnement normal.
.. code-block:: bash
systemctl status agent
- systemctl status 'dnsmasq@vp-admin_br-sn000001'
+ systemctl status 'dnsmasq@vp-admin_br-sn000001' # backend dnsmasq
+ systemctl status 'dhcp@vp-admin_br-sn000001' # backend two
systemctl status 'metadata@i-web'
dnsmasq
-------
-Le script ``run-dnsmasq-in-netns.sh`` entre dans le netns puis exécute dnsmasq avec un fichier
-de configuration par subnet, généré par l'agent :
+Backend historique. Le script ``run-dnsmasq-in-netns.sh`` entre dans le netns puis exécute dnsmasq
+avec un fichier de configuration par subnet, généré par l'agent :
.. list-table::
:widths: 40 60
@@ -50,6 +55,42 @@ de configuration par subnet, généré par l'agent :
Le fichier de baux et le journal sont les deux premiers endroits à regarder quand une VM n'obtient
pas d'adresse.
+Serveur DHCP intégré
+--------------------
+
+Backend ``two``. Le script ``run-dhcp-in-netns.sh`` entre dans le netns puis exécute le binaire
+``dhcp``, à qui il passe le bridge à servir et ses deux chemins de fichiers — il ne déduit rien et
+ignore le netns dans lequel il tourne :
+
+.. code-block:: bash
+
+ /opt/two/bin/dhcp -conf /etc/two/agent.yml \
+ -interface br-sn000001 \
+ -state /run/two/dhcp/vp-admin_br-sn000001.state \
+ -socket /run/two/dhcp/vp-admin_br-sn000001.sock
+
+.. list-table::
+ :widths: 40 60
+
+ * - Socket de contrôle
+ - ``/run/two/dhcp/_.sock``
+ * - État
+ - ``/run/two/dhcp/_.state``
+ * - Journal
+ - ``journalctl -u 'dhcp@_'``
+
+Il n'y a **ni fichier de configuration ni fichier de baux**. L'agent pousse l'état désiré sur la
+socket de contrôle : la configuration du subnet à sa création, une réservation par interface à
+chaque création ou suppression de VM. Les réservations sont statiques — une MAC inconnue n'obtient
+rien, et le serveur reste silencieux plutôt que de répondre par un refus.
+
+Le fichier d'état **appartient au processus**, qui l'écrit et le relit à son démarrage. L'agent ne
+l'écrit jamais ; il le supprime seulement, à la création du subnet pour écarter un résidu et à sa
+suppression après avoir arrêté l'unit. Il vit dans ``/run`` parce qu'il n'a aucun sens sans le
+netns, qui ne survit pas au redémarrage de l'host.
+
+Diagnostic : voir :doc:`diagnostic`, qui montre comment interroger la socket.
+
QEMU n'est pas une unit
-----------------------
@@ -86,6 +127,6 @@ journal au moment d'un ``stop`` n'est donc pas une anomalie.
Mise à jour
-----------
-``deploy.sh`` relève les instances ``dnsmasq@`` et ``metadata@`` actives **avant** d'arrêter les
+``deploy.sh`` relève les instances ``dnsmasq@``, ``dhcp@`` et ``metadata@`` actives **avant** d'arrêter les
services, et les redémarre ensuite : c'est la seule façon de savoir lesquelles relancer. Arrêter
les services à la main avant de lancer le script fait perdre cette liste.
diff --git a/docs/versions/0.2.0.md b/docs/versions/0.2.0.md
new file mode 100644
index 0000000..a8f52a5
--- /dev/null
+++ b/docs/versions/0.2.0.md
@@ -0,0 +1,2 @@
+```{include} ../../release_notes/0.2.0.md
+```
diff --git a/docs/versions/index.rst b/docs/versions/index.rst
index 4333ae4..09f40fa 100644
--- a/docs/versions/index.rst
+++ b/docs/versions/index.rst
@@ -6,6 +6,7 @@ Chaque version porte un nom de code dérivé du rang de sa publication : anges e
.. toctree::
:maxdepth: 1
+ 0.2.0
0.1.0
.. include:: ../../release_notes/codenames.md
diff --git a/internal/api/dhcp/digest_test.go b/internal/api/dhcp/digest_test.go
index fe5dd29..f8a0510 100644
--- a/internal/api/dhcp/digest_test.go
+++ b/internal/api/dhcp/digest_test.go
@@ -1,6 +1,8 @@
package dhcpapi
import (
+ "encoding/json"
+ "strings"
"testing"
)
@@ -171,3 +173,13 @@ func TestCanonical_SortsHostsByMAC(t *testing.T) {
t.Errorf("hosts = %v, want sorted by mac", got.Hosts)
}
}
+
+func TestResponse_ServedIsAlwaysOnTheWire(t *testing.T) {
+ raw, err := json.Marshal(Response{OK: true, Served: false})
+ if err != nil {
+ t.Fatalf("Marshal: %v", err)
+ }
+ if !strings.Contains(string(raw), `"served":false`) {
+ t.Errorf("response = %s, want an explicit served:false — omitting it makes \"not served\" indistinguishable from a missing field when probing by hand", raw)
+ }
+}
diff --git a/internal/api/dhcp/models.go b/internal/api/dhcp/models.go
index b0268cf..d454194 100644
--- a/internal/api/dhcp/models.go
+++ b/internal/api/dhcp/models.go
@@ -54,5 +54,5 @@ type Response struct {
State *State `json:"state,omitempty"`
Digest string `json:"digest,omitempty"`
Lease *Lease `json:"lease,omitempty"`
- Served bool `json:"served,omitempty"`
+ Served bool `json:"served"`
}
diff --git a/internal/config/agent/dhcp_test.go b/internal/config/agent/dhcp_test.go
index 6796e56..50fed1c 100644
--- a/internal/config/agent/dhcp_test.go
+++ b/internal/config/agent/dhcp_test.go
@@ -54,3 +54,29 @@ func TestLoadConfig_ReadsTheTwoBackend(t *testing.T) {
t.Errorf("backend = %q, want two", cfg.DHCP.Backend)
}
}
+
+func TestLoadConfig_MalformedFileIsReported(t *testing.T) {
+ path := writeConfig(t, "--\n\ndhcp:\n backend: two\n")
+
+ if _, err := LoadConfig(path); err == nil {
+ t.Fatal("an unparseable config must be reported: silently falling back to defaults would run the wrong dhcp backend")
+ }
+}
+
+func TestLoadConfig_TabIndentedFileIsReported(t *testing.T) {
+ path := writeConfig(t, "dhcp:\n\tbackend: two\n")
+
+ if _, err := LoadConfig(path); err == nil {
+ t.Fatal("yaml forbids tabs for indentation: that must surface, not be swallowed")
+ }
+}
+
+func TestLoadConfig_MissingFileStillFallsBackToDefaults(t *testing.T) {
+ cfg, err := LoadConfig(filepath.Join(t.TempDir(), "absent.yml"))
+ if err != nil {
+ t.Fatalf("an absent file remains valid, only an unreadable one is an error: %v", err)
+ }
+ if cfg.DHCP.Backend != BackendDnsmasq {
+ t.Errorf("backend = %q, want %q", cfg.DHCP.Backend, BackendDnsmasq)
+ }
+}
diff --git a/internal/config/agent/struct.go b/internal/config/agent/struct.go
index d4afea1..0f84a09 100644
--- a/internal/config/agent/struct.go
+++ b/internal/config/agent/struct.go
@@ -1,6 +1,10 @@
package configuration
import (
+ "errors"
+ "fmt"
+ "os"
+
"github.com/spf13/viper"
)
@@ -86,7 +90,9 @@ func LoadConfig(path string) (*Config, error) {
v.SetDefault("logger.level", "info")
v.SetDefault("logger.debug", false)
- v.ReadInConfig()
+ if err := v.ReadInConfig(); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, fmt.Errorf("read %s: %w", path, err)
+ }
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
diff --git a/internal/dhcp/generate.go b/internal/dhcp/generate.go
index dc125a5..0e5833e 100644
--- a/internal/dhcp/generate.go
+++ b/internal/dhcp/generate.go
@@ -29,12 +29,7 @@ func GenerateConfig(c Config) (string, map[string]string, error) {
fmt.Fprintf(&sb, "dhcp-hostsdir=%s\n", HostsDir(c.ConfDir, c.Name))
fmt.Fprintf(&sb, "dhcp-optsdir=%s\n", OptsDir(c.ConfDir, c.Name))
- entries := make(map[string]string)
- i := 0
- for ip := cloneIP(c.Network.IP); c.Network.Contains(ip); incrementIP(ip) {
- entries[ip.String()] = fmt.Sprintf("00:22:33:%02X:%02X:%02X", (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF)
- i++
- }
+ entries := Entries(c.Network)
for _, dir := range []string{c.ConfDir, HostsDir(c.ConfDir, c.Name), OptsDir(c.ConfDir, c.Name)} {
if err := os.MkdirAll(dir, 0755); err != nil {
@@ -46,6 +41,16 @@ func GenerateConfig(c Config) (string, map[string]string, error) {
return outPath, entries, os.WriteFile(outPath, []byte(sb.String()), 0644)
}
+func Entries(network *net.IPNet) map[string]string {
+ entries := make(map[string]string)
+ i := 0
+ for ip := cloneIP(network.IP); network.Contains(ip); incrementIP(ip) {
+ entries[ip.String()] = fmt.Sprintf("00:22:33:%02X:%02X:%02X", (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF)
+ i++
+ }
+ return entries
+}
+
func classlessRoutes(c Config) []string {
nextHop := c.InterfaceIP.String()
diff --git a/internal/dhcp/reservation.go b/internal/dhcp/reservation.go
index 0cd875c..ab915f5 100644
--- a/internal/dhcp/reservation.go
+++ b/internal/dhcp/reservation.go
@@ -117,3 +117,11 @@ func RemoveSubnetDirs(confDir, name string) error {
}
return nil
}
+
+func RemoveConfig(confDir, name string) error {
+ path := filepath.Join(confDir, name+".conf")
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("remove %s: %w", path, err)
+ }
+ return nil
+}
diff --git a/internal/dhcpbackend/backend.go b/internal/dhcpbackend/backend.go
new file mode 100644
index 0000000..3c9c909
--- /dev/null
+++ b/internal/dhcpbackend/backend.go
@@ -0,0 +1,53 @@
+package dhcpbackend
+
+import (
+ "fmt"
+ "net"
+
+ configuration "git.g3e.fr/syonad/two/internal/config/agent"
+)
+
+type Subnet struct {
+ Name string
+ VPC string
+ Bridge string
+ Network *net.IPNet
+ InterfaceIP net.IP
+ VPCRoute *net.IPNet
+ DefaultGateway net.IP
+}
+
+func (s Subnet) Instance() string {
+ return s.VPC + "_" + s.Bridge
+}
+
+type Reservation struct {
+ Index int
+ MAC string
+ IP string
+ DefaultRoute bool
+}
+
+type Backend interface {
+ Unit(s Subnet) string
+ ConfigureSubnet(s Subnet) error
+ TeardownSubnet(s Subnet) error
+ SetVM(s Subnet, vmName string, res []Reservation) error
+ DelVM(s Subnet, vmName string, res []Reservation) error
+}
+
+func New(cfg *configuration.Config) (Backend, error) {
+ if cfg == nil {
+ return nil, fmt.Errorf("configuration is required to pick a dhcp backend")
+ }
+ if err := configuration.ValidBackend(cfg.DHCP.Backend); err != nil {
+ return nil, err
+ }
+
+ switch cfg.DHCP.Backend {
+ case configuration.BackendTwo:
+ return Two{}, nil
+ default:
+ return Dnsmasq{}, nil
+ }
+}
diff --git a/internal/dhcpbackend/backend_test.go b/internal/dhcpbackend/backend_test.go
new file mode 100644
index 0000000..bf63b2f
--- /dev/null
+++ b/internal/dhcpbackend/backend_test.go
@@ -0,0 +1,99 @@
+package dhcpbackend
+
+import (
+ "net"
+ "testing"
+
+ configuration "git.g3e.fr/syonad/two/internal/config/agent"
+)
+
+func testSubnet(t *testing.T) Subnet {
+ t.Helper()
+ _, network, err := net.ParseCIDR("10.0.5.0/24")
+ if err != nil {
+ t.Fatalf("ParseCIDR: %v", err)
+ }
+ _, vpcRoute, err := net.ParseCIDR("10.0.0.0/16")
+ if err != nil {
+ t.Fatalf("ParseCIDR: %v", err)
+ }
+ return Subnet{
+ Name: "sn-000001",
+ VPC: "vp-admin",
+ Bridge: "br-000001",
+ Network: network,
+ InterfaceIP: net.ParseIP("10.0.5.1"),
+ VPCRoute: vpcRoute,
+ DefaultGateway: net.ParseIP("10.0.5.254"),
+ }
+}
+
+func configFor(backend string) *configuration.Config {
+ cfg := &configuration.Config{}
+ cfg.DHCP.Backend = backend
+ return cfg
+}
+
+func TestInstance_JoinsVPCAndBridge(t *testing.T) {
+ if got := testSubnet(t).Instance(); got != "vp-admin_br-000001" {
+ t.Errorf("Instance = %s, want vp-admin_br-000001", got)
+ }
+}
+
+func TestNew_DnsmasqIsTheDefault(t *testing.T) {
+ backend, err := New(configFor(configuration.BackendDnsmasq))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if _, ok := backend.(Dnsmasq); !ok {
+ t.Errorf("backend = %T, want Dnsmasq", backend)
+ }
+}
+
+func TestNew_ReturnsTheTwoBackendWhenAsked(t *testing.T) {
+ backend, err := New(configFor(configuration.BackendTwo))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if _, ok := backend.(Two); !ok {
+ t.Errorf("backend = %T, want Two", backend)
+ }
+}
+
+func TestNew_RejectsAnUnknownBackend(t *testing.T) {
+ if _, err := New(configFor("dhcpd")); err == nil {
+ t.Fatal("an unknown backend must be reported rather than silently defaulted")
+ }
+}
+
+func TestNew_RejectsAnEmptyBackend(t *testing.T) {
+ if _, err := New(configFor("")); err == nil {
+ t.Fatal("an empty backend must be reported: the config default is what fills it")
+ }
+}
+
+func TestNew_RejectsANilConfig(t *testing.T) {
+ if _, err := New(nil); err == nil {
+ t.Fatal("a nil config must be reported")
+ }
+}
+
+func TestUnit_NamesADistinctUnitPerBackend(t *testing.T) {
+ s := testSubnet(t)
+
+ if got := (Dnsmasq{}).Unit(s); got != "dnsmasq@vp-admin_br-000001.service" {
+ t.Errorf("dnsmasq unit = %s", got)
+ }
+ if got := (Two{}).Unit(s); got != "dhcp@vp-admin_br-000001.service" {
+ t.Errorf("two unit = %s", got)
+ }
+}
+
+func TestTag_IsPerInterfaceNotPerVM(t *testing.T) {
+ if tag("vm-web", 0) == tag("vm-web", 1) {
+ t.Error("two interfaces of the same vm must get distinct tags")
+ }
+ if got := tag("vm-web", 1); got != "vm-web-1" {
+ t.Errorf("tag = %s, want vm-web-1", got)
+ }
+}
diff --git a/internal/dhcpbackend/dnsmasq.go b/internal/dhcpbackend/dnsmasq.go
new file mode 100644
index 0000000..207ecee
--- /dev/null
+++ b/internal/dhcpbackend/dnsmasq.go
@@ -0,0 +1,132 @@
+package dhcpbackend
+
+import (
+ "fmt"
+ "path/filepath"
+
+ "git.g3e.fr/syonad/two/internal/dhcp"
+ "git.g3e.fr/syonad/two/pkg/systemd"
+)
+
+type Dnsmasq struct {
+ ConfDir string
+}
+
+func (b Dnsmasq) confDir() string {
+ if b.ConfDir == "" {
+ return dhcp.DefaultConfDir
+ }
+ return b.ConfDir
+}
+
+func tag(vmName string, index int) string {
+ return fmt.Sprintf("%s-%d", vmName, index)
+}
+
+func (Dnsmasq) Unit(s Subnet) string {
+ return dhcp.UnitName(s.Instance())
+}
+
+func (b Dnsmasq) config(s Subnet) dhcp.Config {
+ return dhcp.Config{
+ Network: s.Network,
+ Name: s.Instance(),
+ ConfDir: b.confDir(),
+ InterfaceIP: s.InterfaceIP,
+ VPCRoute: s.VPCRoute,
+ DefaultGateway: s.DefaultGateway,
+ }
+}
+
+func (b Dnsmasq) ConfigureSubnet(s Subnet) error {
+ if _, _, err := dhcp.GenerateConfig(b.config(s)); err != nil {
+ return fmt.Errorf("generate dhcp config: %w", err)
+ }
+
+ svc, err := systemd.New()
+ if err != nil {
+ return fmt.Errorf("connect to systemd: %w", err)
+ }
+ defer svc.Close()
+
+ if err := svc.Start(b.Unit(s)); err != nil {
+ return fmt.Errorf("start dnsmasq: %w", err)
+ }
+ return nil
+}
+
+func (b Dnsmasq) TeardownSubnet(s Subnet) error {
+ svc, err := systemd.New()
+ if err != nil {
+ return fmt.Errorf("connect to systemd: %w", err)
+ }
+ defer svc.Close()
+
+ unit := b.Unit(s)
+ if status, err := svc.Status(unit); err == nil && status.ActiveState == "active" {
+ if err := svc.Stop(unit); err != nil {
+ return fmt.Errorf("stop dnsmasq: %w", err)
+ }
+ }
+
+ if err := dhcp.RemoveConfig(b.confDir(), s.Instance()); err != nil {
+ return err
+ }
+ return dhcp.RemoveSubnetDirs(b.confDir(), s.Instance())
+}
+
+func (b Dnsmasq) SetVM(s Subnet, vmName string, res []Reservation) error {
+ instance := s.Instance()
+
+ reservations := make([]dhcp.Reservation, 0, len(res))
+ var tags []string
+ for _, r := range res {
+ reservations = append(reservations, dhcp.Reservation{
+ MAC: r.MAC, IP: r.IP, Tag: tag(vmName, r.Index),
+ })
+ if !r.DefaultRoute {
+ tags = append(tags, tag(vmName, r.Index))
+ }
+ }
+
+ if err := dhcp.WriteReservations(b.confDir(), instance, vmName, reservations); err != nil {
+ return fmt.Errorf("write dhcp reservations on %s: %w", instance, err)
+ }
+
+ options := dhcp.Config{InterfaceIP: s.InterfaceIP, VPCRoute: s.VPCRoute}
+ if err := dhcp.WriteVMOptions(b.confDir(), instance, vmName, tags, options); err != nil {
+ return fmt.Errorf("write dhcp options on %s: %w", instance, err)
+ }
+ return nil
+}
+
+func (b Dnsmasq) DelVM(s Subnet, vmName string, _ []Reservation) error {
+ if err := dhcp.RemoveReservations(b.confDir(), s.Instance(), vmName); err != nil {
+ return err
+ }
+
+ svc, err := systemd.New()
+ if err != nil {
+ return fmt.Errorf("connect to systemd: %w", err)
+ }
+ defer svc.Close()
+
+ unit := b.Unit(s)
+ status, err := svc.Status(unit)
+ if err != nil || status.ActiveState != "active" {
+ return nil
+ }
+ if err := svc.Restart(unit); err != nil {
+ return fmt.Errorf("restart %s: %w", unit, err)
+ }
+ if status, err := svc.Status(unit); err != nil {
+ return fmt.Errorf("status %s after restart: %w", unit, err)
+ } else if status.ActiveState != "active" {
+ return fmt.Errorf("%s is %s after restart", unit, status.ActiveState)
+ }
+ return nil
+}
+
+func (b Dnsmasq) ConfigPath(s Subnet) string {
+ return filepath.Join(b.confDir(), s.Instance()+".conf")
+}
diff --git a/internal/dhcpbackend/dnsmasq_test.go b/internal/dhcpbackend/dnsmasq_test.go
new file mode 100644
index 0000000..1bb2d7c
--- /dev/null
+++ b/internal/dhcpbackend/dnsmasq_test.go
@@ -0,0 +1,113 @@
+package dhcpbackend
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func dnsmasqBackend(t *testing.T) Dnsmasq {
+ t.Helper()
+ return Dnsmasq{ConfDir: t.TempDir()}
+}
+
+func readFile(t *testing.T, path string) string {
+ t.Helper()
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read %s: %v", path, err)
+ }
+ return string(raw)
+}
+
+func TestDnsmasq_ConfDirDefaultsToTheSystemOne(t *testing.T) {
+ if got := (Dnsmasq{}).confDir(); got != "/etc/dnsmasq.d" {
+ t.Errorf("confDir = %s, want /etc/dnsmasq.d", got)
+ }
+}
+
+func TestDnsmasqSetVM_WritesOneReservationPerInterface(t *testing.T) {
+ b := dnsmasqBackend(t)
+ s := testSubnet(t)
+
+ res := []Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true},
+ {Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"},
+ }
+ if err := b.SetVM(s, "vm-web", res); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ hosts := readFile(t, filepath.Join(b.ConfDir, s.Instance()+".hosts.d", "vm-web"))
+ for _, want := range []string{"00:22:33:00:00:0a,10.0.5.10,set:vm-web-0", "00:22:33:00:00:0b,10.0.5.11,set:vm-web-1"} {
+ if !strings.Contains(hosts, want) {
+ t.Errorf("hosts file missing %q:\n%s", want, hosts)
+ }
+ }
+}
+
+func TestDnsmasqSetVM_TagsOnlyTheInterfacesWithoutADefaultRoute(t *testing.T) {
+ b := dnsmasqBackend(t)
+ s := testSubnet(t)
+
+ res := []Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true},
+ {Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"},
+ }
+ if err := b.SetVM(s, "vm-web", res); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ opts := readFile(t, filepath.Join(b.ConfDir, s.Instance()+".opts.d", "vm-web"))
+ if strings.Contains(opts, "tag:vm-web-0") {
+ t.Errorf("the interface carrying the default route must get no override:\n%s", opts)
+ }
+ if !strings.Contains(opts, "tag:vm-web-1,3\n") {
+ t.Errorf("the secondary interface must get a bare option 3:\n%s", opts)
+ }
+ if !strings.Contains(opts, "tag:vm-web-1,121,") {
+ t.Errorf("the secondary interface must get its own option 121:\n%s", opts)
+ }
+}
+
+func TestDnsmasqSetVM_SecondaryOptionsKeepTheMetadataRoute(t *testing.T) {
+ b := dnsmasqBackend(t)
+ s := testSubnet(t)
+
+ res := []Reservation{{Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"}}
+ if err := b.SetVM(s, "vm-web", res); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ opts := readFile(t, filepath.Join(b.ConfDir, s.Instance()+".opts.d", "vm-web"))
+ if !strings.Contains(opts, "169.254.169.254/32,10.0.5.1") {
+ t.Errorf("overriding option 121 without the metadata route breaks cloud-init:\n%s", opts)
+ }
+ if strings.Contains(opts, "0.0.0.0/0") {
+ t.Errorf("a secondary interface must not receive a default route:\n%s", opts)
+ }
+}
+
+func TestDnsmasqSetVM_AllInterfacesDefaultRoutedWritesNoOptions(t *testing.T) {
+ b := dnsmasqBackend(t)
+ s := testSubnet(t)
+
+ res := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}}
+ if err := b.SetVM(s, "vm-web", res); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ path := filepath.Join(b.ConfDir, s.Instance()+".opts.d", "vm-web")
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Errorf("no options file expected, got %v: the subnet-wide options already carry the default route", err)
+ }
+}
+
+func TestDnsmasqSetVM_RejectsAVMWithoutReservation(t *testing.T) {
+ b := dnsmasqBackend(t)
+
+ if err := b.SetVM(testSubnet(t), "vm-web", nil); err == nil {
+ t.Fatal("a vm with no reservation would get no address: that must be reported")
+ }
+}
diff --git a/internal/dhcpbackend/two.go b/internal/dhcpbackend/two.go
new file mode 100644
index 0000000..71a4d37
--- /dev/null
+++ b/internal/dhcpbackend/two.go
@@ -0,0 +1,147 @@
+package dhcpbackend
+
+import (
+ "fmt"
+ "time"
+
+ dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp"
+ dhcpclient "git.g3e.fr/syonad/two/internal/client/dhcp"
+ "git.g3e.fr/syonad/two/pkg/db/statefile"
+ "git.g3e.fr/syonad/two/pkg/systemd"
+)
+
+const (
+ readyTimeout = 5 * time.Second
+ readyPoll = 50 * time.Millisecond
+)
+
+type Two struct {
+ RunDir string
+}
+
+func (b Two) runDir() string {
+ if b.RunDir == "" {
+ return dhcpapi.DefaultRunDir
+ }
+ return b.RunDir
+}
+
+func (b Two) Unit(s Subnet) string {
+ return dhcpapi.Unit(s.Instance())
+}
+
+func (b Two) client(s Subnet) *dhcpclient.Client {
+ return dhcpclient.New(dhcpapi.SocketPath(b.runDir(), s.Instance()))
+}
+
+func (b Two) statePath(s Subnet) string {
+ return dhcpapi.StatePath(b.runDir(), s.Instance())
+}
+
+func (b Two) waitReady(s Subnet, timeout, poll time.Duration) error {
+ client := b.client(s)
+ deadline := time.Now().Add(timeout)
+
+ var err error
+ for {
+ if _, _, err = client.GetState(); err == nil {
+ return nil
+ }
+ if time.Now().After(deadline) {
+ return fmt.Errorf("dhcp server for %s did not answer within %s: %w", s.Instance(), timeout, err)
+ }
+ time.Sleep(poll)
+ }
+}
+
+func (b Two) ConfigureSubnet(s Subnet) error {
+ if err := statefile.Remove(b.statePath(s)); err != nil {
+ return fmt.Errorf("remove residual state: %w", err)
+ }
+
+ svc, err := systemd.New()
+ if err != nil {
+ return fmt.Errorf("connect to systemd: %w", err)
+ }
+ defer svc.Close()
+
+ if err := svc.Start(b.Unit(s)); err != nil {
+ return fmt.Errorf("start dhcp: %w", err)
+ }
+ if err := b.waitReady(s, readyTimeout, readyPoll); err != nil {
+ return err
+ }
+
+ return b.pushSubnet(s)
+}
+
+func (b Two) pushSubnet(s Subnet) error {
+ subnet := dhcpapi.Subnet{
+ Network: s.Network.String(),
+ InterfaceIP: s.InterfaceIP.String(),
+ }
+ if s.VPCRoute != nil {
+ subnet.VPCRoute = s.VPCRoute.String()
+ }
+ if s.DefaultGateway != nil {
+ subnet.DefaultGateway = s.DefaultGateway.String()
+ }
+
+ if err := b.client(s).SetSubnet(subnet); err != nil {
+ return fmt.Errorf("configure dhcp for %s: %w", s.Instance(), err)
+ }
+ return nil
+}
+
+func (b Two) TeardownSubnet(s Subnet) error {
+ svc, err := systemd.New()
+ if err != nil {
+ return fmt.Errorf("connect to systemd: %w", err)
+ }
+ defer svc.Close()
+
+ unit := b.Unit(s)
+ if status, err := svc.Status(unit); err == nil && status.ActiveState == "active" {
+ if err := svc.Stop(unit); err != nil {
+ return fmt.Errorf("stop dhcp: %w", err)
+ }
+ }
+
+ return statefile.Remove(b.statePath(s))
+}
+
+func (b Two) SetVM(s Subnet, vmName string, res []Reservation) error {
+ client := b.client(s)
+
+ for _, r := range res {
+ host := dhcpapi.Host{
+ MAC: r.MAC,
+ IP: r.IP,
+ VM: vmName,
+ DefaultRoute: r.DefaultRoute,
+ }
+ if err := client.SetHost(host); err != nil {
+ return fmt.Errorf("reserve %s for vm %s on %s: %w", r.MAC, vmName, s.Instance(), err)
+ }
+ }
+ return nil
+}
+
+func (b Two) DelVM(s Subnet, vmName string, res []Reservation) error {
+ client := b.client(s)
+
+ for _, r := range res {
+ if err := client.DelHost(r.MAC); err != nil {
+ return fmt.Errorf("release %s of vm %s on %s: %w", r.MAC, vmName, s.Instance(), err)
+ }
+ }
+ return nil
+}
+
+func (b Two) StatePath(s Subnet) string {
+ return b.statePath(s)
+}
+
+func (b Two) State(s Subnet) (dhcpapi.State, string, error) {
+ return b.client(s).GetState()
+}
diff --git a/internal/dhcpbackend/two_test.go b/internal/dhcpbackend/two_test.go
new file mode 100644
index 0000000..cedbb16
--- /dev/null
+++ b/internal/dhcpbackend/two_test.go
@@ -0,0 +1,254 @@
+package dhcpbackend
+
+import (
+ "io"
+ "log/slog"
+ "net"
+ "os"
+ "testing"
+ "time"
+
+ dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp"
+ "git.g3e.fr/syonad/two/internal/dhcpd"
+)
+
+func shortTempDir(t *testing.T) string {
+ t.Helper()
+ dir, err := os.MkdirTemp("", "dhcpd")
+ if err != nil {
+ t.Fatalf("MkdirTemp: %v", err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ return dir
+}
+
+func twoBackend(t *testing.T) (Two, Subnet, *dhcpd.Store) {
+ t.Helper()
+
+ b := Two{RunDir: shortTempDir(t)}
+ s := testSubnet(t)
+
+ store := dhcpd.NewStore(dhcpapi.StatePath(b.RunDir, s.Instance()))
+ if err := store.Load(); err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ server, err := dhcpapi.Listen(store, dhcpapi.SocketPath(b.RunDir, s.Instance()), slog.New(slog.NewTextHandler(io.Discard, nil)))
+ if err != nil {
+ t.Fatalf("Listen: %v", err)
+ }
+ go server.Serve()
+ t.Cleanup(func() { server.Close() })
+
+ return b, s, store
+}
+
+func TestTwo_RunDirDefaultsToTheSharedOne(t *testing.T) {
+ if got := (Two{}).runDir(); got != dhcpapi.DefaultRunDir {
+ t.Errorf("runDir = %s, want %s", got, dhcpapi.DefaultRunDir)
+ }
+}
+
+func TestTwoWaitReady_ReturnsOnceTheServerAnswers(t *testing.T) {
+ b, s, _ := twoBackend(t)
+
+ if err := b.waitReady(s, time.Second, 10*time.Millisecond); err != nil {
+ t.Fatalf("waitReady: %v", err)
+ }
+}
+
+func TestTwoWaitReady_TimesOutWhenNothingListens(t *testing.T) {
+ b := Two{RunDir: shortTempDir(t)}
+ s := testSubnet(t)
+
+ start := time.Now()
+ err := b.waitReady(s, 200*time.Millisecond, 10*time.Millisecond)
+ if err == nil {
+ t.Fatal("waitReady must report a server that never came up")
+ }
+ if elapsed := time.Since(start); elapsed > 2*time.Second {
+ t.Errorf("waitReady took %s, want the 200ms budget to apply", elapsed)
+ }
+}
+
+func TestTwoPushSubnet_ReachesTheStore(t *testing.T) {
+ b, s, store := twoBackend(t)
+
+ if err := b.pushSubnet(s); err != nil {
+ t.Fatalf("pushSubnet: %v", err)
+ }
+
+ got, configured := store.Subnet()
+ if !configured {
+ t.Fatal("the subnet configuration did not reach the server")
+ }
+ if !got.InterfaceIP.Equal(net.ParseIP("10.0.5.1")) {
+ t.Errorf("interface ip = %s, want 10.0.5.1", got.InterfaceIP)
+ }
+ if got.VPCRoute == nil || got.VPCRoute.String() != "10.0.0.0/16" {
+ t.Errorf("vpc route = %v, want 10.0.0.0/16", got.VPCRoute)
+ }
+ if !got.DefaultGateway.Equal(net.ParseIP("10.0.5.254")) {
+ t.Errorf("default gateway = %s, want 10.0.5.254", got.DefaultGateway)
+ }
+}
+
+func TestTwoPushSubnet_OmitsAnAbsentVPCRouteAndGateway(t *testing.T) {
+ b, s, store := twoBackend(t)
+ s.VPCRoute = nil
+ s.DefaultGateway = nil
+
+ if err := b.pushSubnet(s); err != nil {
+ t.Fatalf("pushSubnet: %v", err)
+ }
+
+ got, _ := store.Subnet()
+ if got.VPCRoute != nil {
+ t.Errorf("vpc route = %v, want none", got.VPCRoute)
+ }
+ if got.DefaultGateway != nil {
+ t.Errorf("default gateway = %v, want none: a bridge subnet has no gateway of ours", got.DefaultGateway)
+ }
+}
+
+func TestTwoSetVM_ReservesEveryInterface(t *testing.T) {
+ b, s, store := twoBackend(t)
+ if err := b.pushSubnet(s); err != nil {
+ t.Fatalf("pushSubnet: %v", err)
+ }
+
+ res := []Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true},
+ {Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"},
+ }
+ if err := b.SetVM(s, "vm-web", res); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ hosts := store.Hosts()
+ if len(hosts) != 2 {
+ t.Fatalf("hosts = %d, want 2", len(hosts))
+ }
+ for _, h := range hosts {
+ if h.VM != "vm-web" {
+ t.Errorf("host %s carries vm %q, want vm-web", h.MAC, h.VM)
+ }
+ }
+}
+
+func TestTwoSetVM_CarriesTheDefaultRouteFlagPerInterface(t *testing.T) {
+ b, s, store := twoBackend(t)
+ if err := b.pushSubnet(s); err != nil {
+ t.Fatalf("pushSubnet: %v", err)
+ }
+
+ res := []Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true},
+ {Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"},
+ }
+ if err := b.SetVM(s, "vm-web", res); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ primary, known := store.Lookup(mustMAC(t, "00:22:33:00:00:0a"))
+ if !known || !primary.DefaultRoute {
+ t.Errorf("primary interface = %+v, want the default route", primary)
+ }
+ secondary, known := store.Lookup(mustMAC(t, "00:22:33:00:00:0b"))
+ if !known || secondary.DefaultRoute {
+ t.Errorf("secondary interface = %+v, want no default route", secondary)
+ }
+}
+
+func TestTwoSetVM_IsIdempotent(t *testing.T) {
+ b, s, store := twoBackend(t)
+ if err := b.pushSubnet(s); err != nil {
+ t.Fatalf("pushSubnet: %v", err)
+ }
+
+ res := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}}
+ for range 3 {
+ if err := b.SetVM(s, "vm-web", res); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+ }
+ if got := len(store.Hosts()); got != 1 {
+ t.Errorf("hosts = %d, want 1", got)
+ }
+}
+
+func TestTwoSetVM_RejectsAnInvalidMAC(t *testing.T) {
+ b, s, _ := twoBackend(t)
+ if err := b.pushSubnet(s); err != nil {
+ t.Fatalf("pushSubnet: %v", err)
+ }
+
+ res := []Reservation{{Index: 0, MAC: "nope", IP: "10.0.5.10"}}
+ if err := b.SetVM(s, "vm-web", res); err == nil {
+ t.Fatal("an invalid mac must be reported")
+ }
+}
+
+func TestTwoDelVM_ReleasesEveryInterface(t *testing.T) {
+ b, s, store := twoBackend(t)
+ if err := b.pushSubnet(s); err != nil {
+ t.Fatalf("pushSubnet: %v", err)
+ }
+
+ res := []Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true},
+ {Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"},
+ }
+ if err := b.SetVM(s, "vm-web", res); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+ if err := b.DelVM(s, "vm-web", res); err != nil {
+ t.Fatalf("DelVM: %v", err)
+ }
+
+ if got := len(store.Hosts()); got != 0 {
+ t.Errorf("hosts = %d, want 0", got)
+ }
+}
+
+func TestTwoDelVM_LeavesOtherVMsAlone(t *testing.T) {
+ b, s, store := twoBackend(t)
+ if err := b.pushSubnet(s); err != nil {
+ t.Fatalf("pushSubnet: %v", err)
+ }
+
+ web := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}}
+ db := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11", DefaultRoute: true}}
+ for name, res := range map[string][]Reservation{"vm-web": web, "vm-db": db} {
+ if err := b.SetVM(s, name, res); err != nil {
+ t.Fatalf("SetVM %s: %v", name, err)
+ }
+ }
+
+ if err := b.DelVM(s, "vm-web", web); err != nil {
+ t.Fatalf("DelVM: %v", err)
+ }
+
+ hosts := store.Hosts()
+ if len(hosts) != 1 || hosts[0].VM != "vm-db" {
+ t.Errorf("remaining hosts = %+v, want only vm-db", hosts)
+ }
+}
+
+func TestTwoDelVM_OnAnUnknownMACIsNotAnError(t *testing.T) {
+ b, s, _ := twoBackend(t)
+
+ res := []Reservation{{Index: 0, MAC: "00:22:33:ff:ff:ff", IP: "10.0.5.99"}}
+ if err := b.DelVM(s, "vm-gone", res); err != nil {
+ t.Errorf("releasing an absent reservation must be idempotent, got %v", err)
+ }
+}
+
+func mustMAC(t *testing.T, s string) net.HardwareAddr {
+ t.Helper()
+ m, err := net.ParseMAC(s)
+ if err != nil {
+ t.Fatalf("ParseMAC(%q): %v", s, err)
+ }
+ return m
+}
diff --git a/internal/dispatcher/agent/subnet_commands.go b/internal/dispatcher/agent/subnet_commands.go
index 8d71f0c..b2fb5f6 100644
--- a/internal/dispatcher/agent/subnet_commands.go
+++ b/internal/dispatcher/agent/subnet_commands.go
@@ -84,7 +84,7 @@ func (c CreateSubnetCommand) Execute(db *badger.DB, cfg *configuration.Config) e
case <-time.After(time.Duration(cfg.Dispatcher.PollSeconds) * time.Second):
}
}
- return subnet.CreateSubnet(db, c.Name)
+ return subnet.CreateSubnet(db, c.Name, cfg)
}
type DeleteSubnetCommand struct {
@@ -104,8 +104,8 @@ func (c DeleteSubnetCommand) Prepare(db *badger.DB, _ *configuration.Config) err
return state.Set(db, c.Key(), state.Deleting)
}
-func (c DeleteSubnetCommand) Execute(db *badger.DB, _ *configuration.Config) error {
- if err := subnet.DeleteSubnet(db, c.Name); err != nil {
+func (c DeleteSubnetCommand) Execute(db *badger.DB, cfg *configuration.Config) error {
+ if err := subnet.DeleteSubnet(db, c.Name, cfg); err != nil {
return err
}
current, err := state.Get(db, c.Key())
diff --git a/internal/netif/vxlan.go b/internal/netif/vxlan.go
index 70740bc..0930cb3 100644
--- a/internal/netif/vxlan.go
+++ b/internal/netif/vxlan.go
@@ -1,14 +1,31 @@
package netif
import (
+ "fmt"
+ "net"
+ "syscall"
+
"github.com/vishvananda/netlink"
)
+const (
+ scopeUniverse = 0
+ addressSecondary = 0x01
+)
+
func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error {
link, err := netlink.LinkByName(localIface)
if err != nil {
return err
}
+ addrs, err := netlink.AddrList(link, syscall.AF_INET)
+ if err != nil {
+ return err
+ }
+ local, err := vtepAddress(localIface, addrs)
+ if err != nil {
+ return err
+ }
vxlan := &netlink.Vxlan{
LinkAttrs: netlink.LinkAttrs{
Name: name,
@@ -17,7 +34,18 @@ func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error {
VxlanId: vxlanID,
Port: 4789,
VtepDevIndex: link.Attrs().Index,
+ SrcAddr: local,
Learning: false,
}
return netlink.LinkAdd(vxlan)
}
+
+func vtepAddress(iface string, addrs []netlink.Addr) (net.IP, error) {
+ for _, a := range addrs {
+ if a.IPNet == nil || a.IP.To4() == nil || a.Scope != scopeUniverse || a.Flags&addressSecondary != 0 {
+ continue
+ }
+ return a.IP.To4(), nil
+ }
+ return nil, fmt.Errorf("no primary global IPv4 address on %s for the VXLAN local endpoint", iface)
+}
diff --git a/internal/netif/vxlan_test.go b/internal/netif/vxlan_test.go
new file mode 100644
index 0000000..6ea7ca4
--- /dev/null
+++ b/internal/netif/vxlan_test.go
@@ -0,0 +1,64 @@
+package netif
+
+import (
+ "net"
+ "strings"
+ "testing"
+
+ "github.com/vishvananda/netlink"
+)
+
+func addr(cidr string, scope, flags int) netlink.Addr {
+ ip, network, err := net.ParseCIDR(cidr)
+ if err != nil {
+ panic(err)
+ }
+ network.IP = ip
+ return netlink.Addr{IPNet: network, Scope: scope, Flags: flags}
+}
+
+func TestVtepAddress_PicksThePrimaryGlobalIPv4(t *testing.T) {
+ addrs := []netlink.Addr{
+ addr("127.0.0.1/8", 254, 0),
+ addr("169.254.0.3/28", 0, 0x01),
+ addr("192.168.14.11/24", 0, 0x80),
+ addr("192.168.14.99/24", 0, 0x01),
+ }
+ got, err := vtepAddress("br-000000", addrs)
+ if err != nil || got.String() != "192.168.14.11" {
+ t.Errorf("vtepAddress = %v, %v, want 192.168.14.11", got, err)
+ }
+}
+
+func TestVtepAddress_SkipsLinkScopeAndSecondaryAddresses(t *testing.T) {
+ addrs := []netlink.Addr{
+ addr("169.254.0.3/28", 253, 0),
+ addr("192.168.14.99/24", 0, 0x01),
+ addr("192.168.14.12/24", 0, 0),
+ }
+ got, err := vtepAddress("br-000000", addrs)
+ if err != nil || got.String() != "192.168.14.12" {
+ t.Errorf("vtepAddress = %v, %v, want 192.168.14.12", got, err)
+ }
+}
+
+func TestVtepAddress_RefusesAnInterfaceWithoutUsableAddress(t *testing.T) {
+ for name, addrs := range map[string][]netlink.Addr{
+ "no address": nil,
+ "only secondary": {addr("192.168.14.99/24", 0, 0x01)},
+ "only link": {addr("169.254.0.3/28", 253, 0)},
+ "only IPv6": {addr("fd00::11/64", 0, 0)},
+ } {
+ _, err := vtepAddress("br-000000", addrs)
+ if err == nil || !strings.Contains(err.Error(), "no primary global IPv4 address on br-000000") {
+ t.Errorf("%s: error = %v", name, err)
+ }
+ }
+}
+
+func TestVtepAddress_ReturnsAFourByteAddress(t *testing.T) {
+ got, err := vtepAddress("br-000000", []netlink.Addr{addr("192.168.14.11/24", 0, 0)})
+ if err != nil || len(got) != 4 {
+ t.Errorf("vtepAddress = %v (%d bytes), %v", got, len(got), err)
+ }
+}
diff --git a/internal/subnet/create.go b/internal/subnet/create.go
index e37b865..1164219 100644
--- a/internal/subnet/create.go
+++ b/internal/subnet/create.go
@@ -3,17 +3,18 @@ package subnet
import (
"fmt"
+ configuration "git.g3e.fr/syonad/two/internal/config/agent"
"git.g3e.fr/syonad/two/internal/dhcp"
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
"git.g3e.fr/syonad/two/internal/ebtables"
"git.g3e.fr/syonad/two/internal/netif"
"git.g3e.fr/syonad/two/internal/netns"
"git.g3e.fr/syonad/two/internal/state"
- "git.g3e.fr/syonad/two/pkg/systemd"
"github.com/dgraph-io/badger/v4"
)
-func CreateSubnet(db *badger.DB, subnetName string) error {
+func CreateSubnet(db *badger.DB, subnetName string, cfg *configuration.Config) error {
current, err := state.Get(db, "subnet/"+subnetName)
if err != nil {
return err
@@ -27,14 +28,19 @@ func CreateSubnet(db *badger.DB, subnetName string) error {
return err
}
- if err := createSubnet(db, subnetName, d); err != nil {
+ backend, err := dhcpbackend.New(cfg)
+ if err != nil {
+ return err
+ }
+
+ if err := createSubnet(db, subnetName, d, backend); err != nil {
return err
}
return state.Set(db, "subnet/"+subnetName, state.Running)
}
-func createSubnet(db *badger.DB, subnetName string, d subnetData) error {
+func createSubnet(db *badger.DB, subnetName string, d subnetData, backend dhcpbackend.Backend) error {
vethE := "v-" + d.subnetID + "-e"
vethI := "v-" + d.subnetID + "-i"
@@ -110,7 +116,7 @@ func createSubnet(db *badger.DB, subnetName string, d subnetData) error {
}
}
- return startDHCP(db, subnetName, d)
+ return startDHCP(db, subnetName, d, backend)
}
func setupVxlanHost(d subnetData, vethE string) error {
@@ -136,35 +142,23 @@ func setupVxlanHost(d subnetData, vethE string) error {
return nil
}
-func startDHCP(db *badger.DB, subnetName string, d subnetData) error {
- conf := dhcp.Config{
- Network: d.cidr,
- Name: d.vpc + "_" + d.bridge,
- ConfDir: dhcp.DefaultConfDir,
- InterfaceIP: d.interfaceIP,
- }
+func startDHCP(db *badger.DB, subnetName string, d subnetData, backend dhcpbackend.Backend) error {
defaultGateway, vpcRoute, err := dhcpRouting(d, netif.GetDefaultGateway)
if err != nil {
return err
}
- conf.DefaultGateway = defaultGateway
- conf.VPCRoute = vpcRoute
- _, entries, err := dhcp.GenerateConfig(conf)
- if err != nil {
- return fmt.Errorf("generate dhcp config: %w", err)
- }
- if err := dhcp.StoreDHCPEntries(db, subnetName, entries); err != nil {
+
+ if err := dhcp.StoreDHCPEntries(db, subnetName, dhcp.Entries(d.cidr)); err != nil {
return fmt.Errorf("store dhcp entries: %w", err)
}
- svc, err := systemd.New()
- if err != nil {
- return fmt.Errorf("connect to systemd: %w", err)
- }
- defer svc.Close()
-
- if err := svc.Start("dnsmasq@" + conf.Name + ".service"); err != nil {
- return fmt.Errorf("start dnsmasq: %w", err)
- }
- return nil
+ return backend.ConfigureSubnet(dhcpbackend.Subnet{
+ Name: subnetName,
+ VPC: d.vpc,
+ Bridge: d.bridge,
+ Network: d.cidr,
+ InterfaceIP: d.interfaceIP,
+ VPCRoute: vpcRoute,
+ DefaultGateway: defaultGateway,
+ })
}
diff --git a/internal/subnet/delete.go b/internal/subnet/delete.go
index 7fdf67a..a8f12d0 100644
--- a/internal/subnet/delete.go
+++ b/internal/subnet/delete.go
@@ -2,21 +2,19 @@ package subnet
import (
"fmt"
- "os"
- "path/filepath"
- "git.g3e.fr/syonad/two/internal/dhcp"
+ configuration "git.g3e.fr/syonad/two/internal/config/agent"
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
"git.g3e.fr/syonad/two/internal/ebtables"
"git.g3e.fr/syonad/two/internal/netif"
"git.g3e.fr/syonad/two/internal/netns"
"git.g3e.fr/syonad/two/internal/state"
"git.g3e.fr/syonad/two/pkg/db/kv"
- "git.g3e.fr/syonad/two/pkg/systemd"
"github.com/dgraph-io/badger/v4"
)
-func DeleteSubnet(db *badger.DB, subnetName string) error {
+func DeleteSubnet(db *badger.DB, subnetName string, cfg *configuration.Config) error {
current, err := state.Get(db, "subnet/"+subnetName)
if err != nil {
return err
@@ -30,7 +28,12 @@ func DeleteSubnet(db *badger.DB, subnetName string) error {
return err
}
- if err := stopDHCP(db, subnetName, d); err != nil {
+ backend, err := dhcpbackend.New(cfg)
+ if err != nil {
+ return err
+ }
+
+ if err := stopDHCP(db, subnetName, d, backend); err != nil {
return err
}
@@ -50,26 +53,13 @@ func DeleteSubnet(db *badger.DB, subnetName string) error {
return state.Set(db, "subnet/"+subnetName, state.Deleted)
}
-func stopDHCP(db *badger.DB, subnetName string, d subnetData) error {
- svc, err := systemd.New()
- if err != nil {
- return fmt.Errorf("connect to systemd: %w", err)
- }
- defer svc.Close()
-
- svcName := "dnsmasq@" + d.vpc + "_" + d.bridge + ".service"
- if status, err := svc.Status(svcName); err == nil && status.ActiveState == "active" {
- if err := svc.Stop(svcName); err != nil {
- return fmt.Errorf("stop dnsmasq: %w", err)
- }
- }
-
- if err := os.Remove(filepath.Join(dhcp.DefaultConfDir, d.vpc+"_"+d.bridge+".conf")); err != nil && !os.IsNotExist(err) {
- return fmt.Errorf("remove dnsmasq config: %w", err)
- }
-
- if err := dhcp.RemoveSubnetDirs(dhcp.DefaultConfDir, d.vpc+"_"+d.bridge); err != nil {
- return fmt.Errorf("remove dnsmasq dirs: %w", err)
+func stopDHCP(db *badger.DB, subnetName string, d subnetData, backend dhcpbackend.Backend) error {
+ if err := backend.TeardownSubnet(dhcpbackend.Subnet{
+ Name: subnetName,
+ VPC: d.vpc,
+ Bridge: d.bridge,
+ }); err != nil {
+ return err
}
if err := kv.DeleteInDB(db, "subnet/"+subnetName+"/dhcp"); err != nil {
diff --git a/internal/vm/create.go b/internal/vm/create.go
index c976e9c..692cc25 100644
--- a/internal/vm/create.go
+++ b/internal/vm/create.go
@@ -8,7 +8,7 @@ import (
"path/filepath"
configuration "git.g3e.fr/syonad/two/internal/config/agent"
- "git.g3e.fr/syonad/two/internal/dhcp"
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
"git.g3e.fr/syonad/two/internal/iptables"
"git.g3e.fr/syonad/two/internal/metadata"
"git.g3e.fr/syonad/two/internal/netif"
@@ -34,6 +34,11 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error {
}
nic := d.primary()
+ backend, err := dhcpbackend.New(cfg)
+ if err != nil {
+ return err
+ }
+
for _, n := range d.nics {
if err := netif.CreateTap(n.tapID, n.bridge, n.vpcName); err != nil {
return fmt.Errorf("create tap of interface %d: %w", n.index, err)
@@ -54,7 +59,7 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error {
return fmt.Errorf("add metadata redirect: %w", err)
}
- if err := writeDHCPFiles(d, name); err != nil {
+ if err := writeDHCPFiles(d, name, backend); err != nil {
return err
}
@@ -109,53 +114,37 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error {
return state.Set(db, "vm/"+name, state.Running)
}
-// writeDHCPFiles écrit, pour chaque subnet touché par la VM, les réservations
-// de ses interfaces et les options qui suppriment la route par défaut sur les
-// interfaces non primaires. Le subnet de l'interface primaire ne reçoit aucune
-// option : les options non taggées du subnet portent déjà la route par défaut.
-func writeDHCPFiles(d vmData, name string) error {
- type subnetFiles struct {
- nic nicData
- reservations []dhcp.Reservation
- tags []string
- }
- bySubnet := make(map[string]*subnetFiles)
+func dhcpReservations(d vmData) map[string]*subnetReservations {
+ bySubnet := make(map[string]*subnetReservations)
for _, n := range d.nics {
- confName := n.vpcName + "_" + n.bridge
- if bySubnet[confName] == nil {
- bySubnet[confName] = &subnetFiles{nic: n}
+ key := n.vpcName + "_" + n.bridge
+ if bySubnet[key] == nil {
+ bySubnet[key] = &subnetReservations{subnet: dhcpbackend.Subnet{
+ Name: n.subnetName,
+ VPC: n.vpcName,
+ Bridge: n.bridge,
+ InterfaceIP: net.ParseIP(n.interfaceIP),
+ VPCRoute: n.vpcCIDR,
+ }}
}
- f := bySubnet[confName]
- f.reservations = append(f.reservations, dhcp.Reservation{
- MAC: n.mac, IP: n.ip, Tag: nicTag(name, n.index),
+ f := bySubnet[key]
+ f.reservations = append(f.reservations, dhcpbackend.Reservation{
+ Index: n.index, MAC: n.mac, IP: n.ip, DefaultRoute: n.primary,
})
- if !n.primary {
- f.tags = append(f.tags, nicTag(name, n.index))
- }
}
+ return bySubnet
+}
- for confName, f := range bySubnet {
- if err := dhcp.WriteReservations(dhcp.DefaultConfDir, confName, name, f.reservations); err != nil {
- return fmt.Errorf("write dhcp reservations on %s: %w", confName, err)
- }
- if err := dhcp.WriteVMOptions(dhcp.DefaultConfDir, confName, name, f.tags, dhcp.Config{
- InterfaceIP: net.ParseIP(f.nic.interfaceIP),
- VPCRoute: f.nic.vpcCIDR,
- }); err != nil {
- return fmt.Errorf("write dhcp options on %s: %w", confName, err)
+func writeDHCPFiles(d vmData, name string, backend dhcpbackend.Backend) error {
+ for _, f := range dhcpReservations(d) {
+ if err := backend.SetVM(f.subnet, name, f.reservations); err != nil {
+ return err
}
}
return nil
}
-// nicTag identifie une interface auprès de dnsmasq. Il est par interface et non
-// par VM : deux interfaces d'une même VM peuvent partager un subnet, et n'y
-// avoir pas le même rôle.
-func nicTag(vmName string, index int) string {
- return fmt.Sprintf("%s-%d", vmName, index)
-}
-
func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
diff --git a/internal/vm/delete.go b/internal/vm/delete.go
index 96f921c..9b2a50c 100644
--- a/internal/vm/delete.go
+++ b/internal/vm/delete.go
@@ -7,14 +7,13 @@ import (
"time"
configuration "git.g3e.fr/syonad/two/internal/config/agent"
- "git.g3e.fr/syonad/two/internal/dhcp"
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
"git.g3e.fr/syonad/two/internal/iptables"
"git.g3e.fr/syonad/two/internal/metadata"
"git.g3e.fr/syonad/two/internal/netif"
"git.g3e.fr/syonad/two/internal/netns"
"git.g3e.fr/syonad/two/internal/qmp"
"git.g3e.fr/syonad/two/internal/state"
- "git.g3e.fr/syonad/two/pkg/systemd"
"github.com/dgraph-io/badger/v4"
)
@@ -34,6 +33,11 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error {
}
nic := d.primary()
+ backend, err := dhcpbackend.New(cfg)
+ if err != nil {
+ return err
+ }
+
socketPath := filepath.Join(cfg.QEMU.QMPDir, name+".sock")
if _, err := os.Stat(socketPath); err == nil {
@@ -69,7 +73,7 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error {
}
}
- if err := removeDHCPFiles(d, name); err != nil {
+ if err := removeDHCPFiles(d, name, backend); err != nil {
return err
}
@@ -81,52 +85,15 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error {
return state.Set(db, "vm/"+name, state.Deleted)
}
-// removeDHCPFiles retire les fichiers de la VM dans chaque subnet qu'elle
-// touche, puis redémarre les dnsmasq concernés : un fichier ajouté dans un
-// dhcp-hostsdir est relu à chaud, un fichier retiré ne l'est pas (vérifié sur
-// dnsmasq 2.90).
-func removeDHCPFiles(d vmData, name string) error {
- seen := make(map[string]bool)
- for _, n := range d.nics {
- confName := n.vpcName + "_" + n.bridge
- if seen[confName] {
- continue
- }
- seen[confName] = true
- if err := removeDHCPReservation(confName, name); err != nil {
+func removeDHCPFiles(d vmData, name string, backend dhcpbackend.Backend) error {
+ for _, f := range dhcpReservations(d) {
+ if err := backend.DelVM(f.subnet, name, f.reservations); err != nil {
return err
}
}
return nil
}
-func removeDHCPReservation(confName, name string) error {
- if err := dhcp.RemoveReservations(dhcp.DefaultConfDir, confName, name); err != nil {
- return err
- }
-
- svc, err := systemd.New()
- if err != nil {
- return fmt.Errorf("connect to systemd: %w", err)
- }
- defer svc.Close()
-
- unit := dhcp.UnitName(confName)
- status, err := svc.Status(unit)
- if err != nil || status.ActiveState != "active" {
- return nil
- }
- if err := svc.Restart(unit); err != nil {
- return fmt.Errorf("restart %s: %w", unit, err)
- }
- if status, err := svc.Status(unit); err != nil {
- return fmt.Errorf("status %s after restart: %w", unit, err)
- } else if status.ActiveState != "active" {
- return fmt.Errorf("%s is %s after restart", unit, status.ActiveState)
- }
- return nil
-}
-
func waitQMPDead(socketPath string, timeout, poll time.Duration) {
timer := time.After(timeout)
for {
diff --git a/internal/vm/dhcp.go b/internal/vm/dhcp.go
new file mode 100644
index 0000000..6acc2ae
--- /dev/null
+++ b/internal/vm/dhcp.go
@@ -0,0 +1,10 @@
+package vm
+
+import (
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
+)
+
+type subnetReservations struct {
+ subnet dhcpbackend.Subnet
+ reservations []dhcpbackend.Reservation
+}
diff --git a/internal/vm/dhcp_test.go b/internal/vm/dhcp_test.go
new file mode 100644
index 0000000..6f6e487
--- /dev/null
+++ b/internal/vm/dhcp_test.go
@@ -0,0 +1,122 @@
+package vm
+
+import (
+ "net"
+ "testing"
+)
+
+func nic(idx int, subnet, vpc, bridge, ip, mac string, primary bool) nicData {
+ return nicData{
+ index: idx,
+ subnetName: subnet,
+ vpcName: vpc,
+ bridge: bridge,
+ interfaceIP: "10.0.5.1",
+ ip: ip,
+ mac: mac,
+ primary: primary,
+ }
+}
+
+func group(t *testing.T, groups map[string]*subnetReservations, key string) *subnetReservations {
+ t.Helper()
+ g, ok := groups[key]
+ if !ok || g == nil {
+ t.Fatalf("no group %q, got %v", key, keysOf(groups))
+ }
+ return g
+}
+
+func keysOf(groups map[string]*subnetReservations) []string {
+ keys := make([]string, 0, len(groups))
+ for k := range groups {
+ keys = append(keys, k)
+ }
+ return keys
+}
+
+func TestDHCPReservations_GroupsInterfacesBySubnet(t *testing.T) {
+ d := vmData{nics: []nicData{
+ nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true),
+ nic(1, "sn-000001", "vp-admin", "br-000001", "10.0.5.11", "00:22:33:00:00:0b", false),
+ nic(2, "sn-000002", "vp-admin", "br-000002", "10.0.6.10", "00:22:33:00:00:0c", false),
+ }}
+
+ got := dhcpReservations(d)
+ if len(got) != 2 {
+ t.Fatalf("groups = %d, want one per subnet", len(got))
+ }
+ if n := len(group(t, got, "vp-admin_br-000001").reservations); n != 2 {
+ t.Errorf("br-000001 carries %d reservations, want 2", n)
+ }
+ if n := len(group(t, got, "vp-admin_br-000002").reservations); n != 1 {
+ t.Errorf("br-000002 carries %d reservations, want 1", n)
+ }
+}
+
+func TestDHCPReservations_OnlyThePrimaryCarriesTheDefaultRoute(t *testing.T) {
+ d := vmData{nics: []nicData{
+ nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true),
+ nic(1, "sn-000001", "vp-admin", "br-000001", "10.0.5.11", "00:22:33:00:00:0b", false),
+ }}
+
+ res := group(t, dhcpReservations(d), "vp-admin_br-000001").reservations
+ byMAC := map[string]bool{}
+ for _, r := range res {
+ byMAC[r.MAC] = r.DefaultRoute
+ }
+ if !byMAC["00:22:33:00:00:0a"] {
+ t.Error("the primary interface must carry the default route")
+ }
+ if byMAC["00:22:33:00:00:0b"] {
+ t.Error("a secondary interface must not carry the default route")
+ }
+}
+
+func TestDHCPReservations_KeepsTheInterfaceIndex(t *testing.T) {
+ d := vmData{nics: []nicData{
+ nic(3, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true),
+ }}
+
+ res := group(t, dhcpReservations(d), "vp-admin_br-000001").reservations
+ if res[0].Index != 3 {
+ t.Errorf("index = %d, want 3: the dnsmasq tag is derived from it", res[0].Index)
+ }
+}
+
+func TestDHCPReservations_CarriesTheSubnetIdentity(t *testing.T) {
+ _, vpcCIDR, err := net.ParseCIDR("10.0.0.0/16")
+ if err != nil {
+ t.Fatalf("ParseCIDR: %v", err)
+ }
+ n := nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true)
+ n.vpcCIDR = vpcCIDR
+
+ g := group(t, dhcpReservations(vmData{nics: []nicData{n}}), "vp-admin_br-000001")
+ if g.subnet.Name != "sn-000001" {
+ t.Errorf("subnet name = %s, want sn-000001", g.subnet.Name)
+ }
+ if !g.subnet.InterfaceIP.Equal(net.ParseIP("10.0.5.1")) {
+ t.Errorf("interface ip = %s, want 10.0.5.1", g.subnet.InterfaceIP)
+ }
+ if g.subnet.VPCRoute == nil || g.subnet.VPCRoute.String() != "10.0.0.0/16" {
+ t.Errorf("vpc route = %v, want 10.0.0.0/16", g.subnet.VPCRoute)
+ }
+}
+
+func TestDHCPReservations_SameBridgeInTwoVPCsStaysSeparate(t *testing.T) {
+ d := vmData{nics: []nicData{
+ nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true),
+ nic(1, "sn-000009", "vp-other", "br-000001", "10.9.5.10", "00:22:33:00:00:0d", false),
+ }}
+
+ if got := len(dhcpReservations(d)); got != 2 {
+ t.Errorf("groups = %d, want 2: the vpc is part of the instance identity", got)
+ }
+}
+
+func TestDHCPReservations_NoNICYieldsNoGroup(t *testing.T) {
+ if got := len(dhcpReservations(vmData{})); got != 0 {
+ t.Errorf("groups = %d, want none", got)
+ }
+}
diff --git a/internal/watchdog/check_dhcp.go b/internal/watchdog/check_dhcp.go
new file mode 100644
index 0000000..5e03e57
--- /dev/null
+++ b/internal/watchdog/check_dhcp.go
@@ -0,0 +1,189 @@
+package watchdog
+
+import (
+ "fmt"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+
+ dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp"
+ "git.g3e.fr/syonad/two/internal/dhcp"
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
+ "git.g3e.fr/syonad/two/internal/state"
+ "git.g3e.fr/syonad/two/internal/watchdog/notify"
+ "git.g3e.fr/syonad/two/pkg/db/kv"
+
+ "github.com/dgraph-io/badger/v4"
+)
+
+type configFileReporter interface {
+ ConfigPath(s dhcpbackend.Subnet) string
+}
+
+type stateReporter interface {
+ State(s dhcpbackend.Subnet) (dhcpapi.State, string, error)
+}
+
+func expectedHosts(db *badger.DB, subnetName string) ([]dhcpapi.Host, error) {
+ pairs, err := kv.ListByPrefix(db, prefixVM)
+ if err != nil {
+ return nil, fmt.Errorf("listing vms: %w", err)
+ }
+
+ hosts := make([]dhcpapi.Host, 0)
+ for _, vmName := range resourceNames(pairs, prefixVM) {
+ st, err := state.Get(db, prefixVM+vmName)
+ if err != nil || st != state.Running {
+ continue
+ }
+
+ vmHosts, err := expectedVMHosts(db, vmName, subnetName)
+ if err != nil {
+ return nil, err
+ }
+ hosts = append(hosts, vmHosts...)
+ }
+
+ dhcpapi.SortHosts(hosts)
+ return hosts, nil
+}
+
+func expectedVMHosts(db *badger.DB, vmName, subnetName string) ([]dhcpapi.Host, error) {
+ prefix := prefixVM + vmName + "/nic/"
+ entries, err := kv.ListByPrefix(db, prefix)
+ if err != nil {
+ return nil, fmt.Errorf("listing nics of vm %s: %w", vmName, err)
+ }
+
+ indexes := make([]int, 0)
+ for key := range entries {
+ parts := strings.Split(strings.TrimPrefix(key, prefix), "/")
+ if len(parts) != 2 || parts[1] != "subnet" {
+ continue
+ }
+ idx, err := strconv.Atoi(parts[0])
+ if err != nil {
+ return nil, fmt.Errorf("invalid nic index %q for vm %s", parts[0], vmName)
+ }
+ indexes = append(indexes, idx)
+ }
+ sort.Ints(indexes)
+
+ hosts := make([]dhcpapi.Host, 0, len(indexes))
+ for _, idx := range indexes {
+ nic := fmt.Sprintf("%s%d/", prefix, idx)
+ if entries[nic+"subnet"] != subnetName {
+ continue
+ }
+
+ ip := entries[nic+"ip"]
+ if ip == "" {
+ return nil, fmt.Errorf("nic %d of vm %s has no ip", idx, vmName)
+ }
+ mac, err := dhcp.GetMACForIP(db, subnetName, ip)
+ if err != nil {
+ return nil, fmt.Errorf("get mac for ip %s: %w", ip, err)
+ }
+
+ hosts = append(hosts, dhcpapi.Host{
+ MAC: mac,
+ IP: ip,
+ VM: vmName,
+ DefaultRoute: entries[nic+"primary"] == "true",
+ })
+ }
+ return hosts, nil
+}
+
+func checkDHCP(db *badger.DB, name string, s dhcpbackend.Subnet, backend dhcpbackend.Backend, u unitChecker, n notify.Notifier) {
+ if backend == nil {
+ return
+ }
+ if r, ok := backend.(configFileReporter); ok {
+ checkDHCPConfigFile(name, r.ConfigPath(s), n)
+ }
+ if r, ok := backend.(stateReporter); ok {
+ checkDHCPState(db, name, s, r, n)
+ }
+ checkUnit(kindSubnet, name, backend.Unit(s), u, n)
+}
+
+func checkDHCPConfigFile(name, path string, n notify.Notifier) {
+ if _, err := os.Stat(path); err != nil {
+ n.Notify(kindSubnet, name, fmt.Sprintf("dnsmasq config missing (%s): %v", path, err))
+ }
+}
+
+func checkDHCPState(db *badger.DB, name string, s dhcpbackend.Subnet, r stateReporter, n notify.Notifier) {
+ served, _, err := r.State(s)
+ if err != nil {
+ n.Notify(kindSubnet, name, fmt.Sprintf("dhcp server unreachable: %v", err))
+ return
+ }
+ if served.Subnet == nil {
+ n.Notify(kindSubnet, name, "dhcp server has no subnet configuration: it serves nothing")
+ }
+
+ expected, err := expectedHosts(db, name)
+ if err != nil {
+ n.Notify(kindSubnet, name, fmt.Sprintf("expected dhcp reservations unreadable in database: %v", err))
+ return
+ }
+
+ expectedDigest, err := dhcpapi.Digest(dhcpapi.State{Hosts: expected})
+ if err != nil {
+ n.Notify(kindSubnet, name, fmt.Sprintf("expected dhcp reservations inconsistent in database: %v", err))
+ return
+ }
+ servedDigest, err := dhcpapi.Digest(dhcpapi.State{Hosts: served.Hosts})
+ if err != nil {
+ n.Notify(kindSubnet, name, fmt.Sprintf("dhcp reservations reported by the server are inconsistent: %v", err))
+ return
+ }
+ if expectedDigest == servedDigest {
+ return
+ }
+
+ for _, gap := range reservationGaps(expected, served.Hosts) {
+ n.Notify(kindSubnet, name, gap)
+ }
+}
+
+func reservationGaps(expected, served []dhcpapi.Host) []string {
+ index := func(hosts []dhcpapi.Host) map[string]dhcpapi.Host {
+ byMAC := make(map[string]dhcpapi.Host, len(hosts))
+ for _, h := range hosts {
+ if c, err := dhcpapi.CanonicalHost(h); err == nil {
+ byMAC[c.MAC] = c
+ } else {
+ byMAC[h.MAC] = h
+ }
+ }
+ return byMAC
+ }
+
+ want, got := index(expected), index(served)
+
+ gaps := make([]string, 0)
+ for mac, h := range want {
+ s, ok := got[mac]
+ if !ok {
+ gaps = append(gaps, fmt.Sprintf("dhcp reservation missing on the server: %s → %s (vm %s)", mac, h.IP, h.VM))
+ continue
+ }
+ if s.IP != h.IP {
+ gaps = append(gaps, fmt.Sprintf("dhcp reservation diverges for %s: server serves %s, database says %s (vm %s)", mac, s.IP, h.IP, h.VM))
+ }
+ if s.DefaultRoute != h.DefaultRoute {
+ gaps = append(gaps, fmt.Sprintf("dhcp default route diverges for %s: server says %t, database says %t (vm %s)", mac, s.DefaultRoute, h.DefaultRoute, h.VM))
+ }
+ }
+ for mac, h := range got {
+ if _, ok := want[mac]; !ok {
+ gaps = append(gaps, fmt.Sprintf("stale dhcp reservation on the server: %s → %s (vm %s)", mac, h.IP, h.VM))
+ }
+ }
+ sort.Strings(gaps)
+ return gaps
+}
diff --git a/internal/watchdog/check_dhcp_test.go b/internal/watchdog/check_dhcp_test.go
new file mode 100644
index 0000000..a9c4801
--- /dev/null
+++ b/internal/watchdog/check_dhcp_test.go
@@ -0,0 +1,348 @@
+package watchdog
+
+import (
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+
+ dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp"
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
+ "git.g3e.fr/syonad/two/internal/dhcpd"
+ "git.g3e.fr/syonad/two/internal/state"
+
+ "github.com/dgraph-io/badger/v4"
+)
+
+const (
+ testSubnetName = "sn-000001"
+ testVPC = "vp-admin"
+ testBridge = "br-000001"
+)
+
+func twoSubnet() dhcpbackend.Subnet {
+ return dhcpbackend.Subnet{Name: testSubnetName, VPC: testVPC, Bridge: testBridge}
+}
+
+func shortTempDir(t *testing.T) string {
+ t.Helper()
+ dir, err := os.MkdirTemp("", "dhcpd")
+ if err != nil {
+ t.Fatalf("MkdirTemp: %v", err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ return dir
+}
+
+func servedBy(t *testing.T) (dhcpbackend.Two, *dhcpd.Store) {
+ t.Helper()
+
+ b := dhcpbackend.Two{RunDir: shortTempDir(t)}
+ s := twoSubnet()
+
+ store := dhcpd.NewStore(dhcpapi.StatePath(b.RunDir, s.Instance()))
+ if err := store.Load(); err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ server, err := dhcpapi.Listen(store, dhcpapi.SocketPath(b.RunDir, s.Instance()), slog.New(slog.NewTextHandler(io.Discard, nil)))
+ if err != nil {
+ t.Fatalf("Listen: %v", err)
+ }
+ go server.Serve()
+ t.Cleanup(func() { server.Close() })
+
+ _, network, err := net.ParseCIDR("10.0.5.0/24")
+ if err != nil {
+ t.Fatalf("ParseCIDR: %v", err)
+ }
+ if err := store.SetSubnet(dhcpd.SubnetConfig{Network: network, InterfaceIP: net.ParseIP("10.0.5.1")}); err != nil {
+ t.Fatalf("SetSubnet: %v", err)
+ }
+ return b, store
+}
+
+func seedSubnetWithVM(t *testing.T, db *badger.DB, vmName, ip, mac string, primary bool) {
+ t.Helper()
+
+ seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC)
+ seedKV(t, db, "subnet/"+testSubnetName+"/dhcp/"+ip, mac)
+
+ if err := state.Set(db, "vm/"+vmName, state.Running); err != nil {
+ t.Fatalf("state.Set: %v", err)
+ }
+ seedKV(t, db, "vm/"+vmName+"/nic/0/subnet", testSubnetName)
+ seedKV(t, db, "vm/"+vmName+"/nic/0/ip", ip)
+ seedKV(t, db, "vm/"+vmName+"/nic/0/primary", fmt.Sprintf("%t", primary))
+}
+
+func TestExpectedHosts_ReadsRunningVMsOnThatSubnet(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ hosts, err := expectedHosts(db, testSubnetName)
+ if err != nil {
+ t.Fatalf("expectedHosts: %v", err)
+ }
+ if len(hosts) != 1 {
+ t.Fatalf("hosts = %+v, want one", hosts)
+ }
+ if hosts[0].MAC != "00:22:33:00:00:0A" || hosts[0].IP != "10.0.5.10" {
+ t.Errorf("host = %+v, want the mac derived from the address plan", hosts[0])
+ }
+ if hosts[0].VM != "vm-web" || !hosts[0].DefaultRoute {
+ t.Errorf("host = %+v, want vm-web carrying the default route", hosts[0])
+ }
+}
+
+func TestExpectedHosts_IgnoresVMsThatAreNotRunning(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+ if err := state.Set(db, "vm/vm-web", state.Deleting); err != nil {
+ t.Fatalf("state.Set: %v", err)
+ }
+
+ hosts, err := expectedHosts(db, testSubnetName)
+ if err != nil {
+ t.Fatalf("expectedHosts: %v", err)
+ }
+ if len(hosts) != 0 {
+ t.Errorf("hosts = %+v, want none: a vm being deleted is not expected to be served", hosts)
+ }
+}
+
+func TestExpectedHosts_IgnoresInterfacesOnOtherSubnets(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+ seedKV(t, db, "vm/vm-web/nic/1/subnet", "sn-000002")
+ seedKV(t, db, "vm/vm-web/nic/1/ip", "10.0.6.10")
+ seedKV(t, db, "vm/vm-web/nic/1/primary", "false")
+
+ hosts, err := expectedHosts(db, testSubnetName)
+ if err != nil {
+ t.Fatalf("expectedHosts: %v", err)
+ }
+ if len(hosts) != 1 {
+ t.Errorf("hosts = %+v, want only the interface on this subnet", hosts)
+ }
+}
+
+func TestExpectedHosts_SortsByMAC(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-b", "10.0.5.11", "00:22:33:00:00:0B", true)
+ seedSubnetWithVM(t, db, "vm-a", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ hosts, err := expectedHosts(db, testSubnetName)
+ if err != nil {
+ t.Fatalf("expectedHosts: %v", err)
+ }
+ if len(hosts) != 2 || hosts[0].MAC != "00:22:33:00:00:0A" {
+ t.Errorf("hosts = %+v, want sorted by mac", hosts)
+ }
+}
+
+func TestExpectedHosts_ReportsAnIPWithoutAMAC(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+ seedKV(t, db, "vm/vm-web/nic/0/ip", "10.0.5.99")
+
+ if _, err := expectedHosts(db, testSubnetName); err == nil {
+ t.Fatal("an ip absent from the address plan must be reported, not skipped")
+ }
+}
+
+func TestCheckDHCPState_MACCaseAloneIsNotADivergence(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ b, _ := servedBy(t)
+ if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: true},
+ }); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if len(r.calls) != 0 {
+ t.Errorf("dhcp.Entries stores uppercase macs and the server normalizes to lowercase: that alone must not read as drift, got %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPState_ADivergenceIsReportedOnceNotAsBothMissingAndStale(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ b, _ := servedBy(t)
+ if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.99", DefaultRoute: true},
+ }); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if len(r.calls) != 1 {
+ t.Errorf("notifications = %+v, want a single diverging-reservation report", r.calls)
+ }
+ if r.hasProblemContaining("missing on the server") || r.hasProblemContaining("stale dhcp") {
+ t.Errorf("without mac normalization the same host reads as both missing and stale: %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPState_SilentWhenServerMatchesDatabase(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ b, _ := servedBy(t)
+ if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: true},
+ }); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if len(r.calls) != 0 {
+ t.Errorf("notifications = %+v, want none when the server agrees with the database", r.calls)
+ }
+}
+
+func TestCheckDHCPState_ReportsAReservationTheServerNeverGot(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ b, _ := servedBy(t)
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if !r.hasProblemContaining("dhcp reservation missing on the server") {
+ t.Errorf("a lost set-host order must be reported, got %+v", r.calls)
+ }
+ if !r.hasProblemContaining("vm vm-web") {
+ t.Errorf("the report must name the vm, got %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPState_ReportsAStaleReservation(t *testing.T) {
+ db := newTestDB(t)
+ seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC)
+
+ b, _ := servedBy(t)
+ if err := b.SetVM(twoSubnet(), "vm-gone", []dhcpbackend.Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: true},
+ }); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if !r.hasProblemContaining("stale dhcp reservation on the server") {
+ t.Errorf("a lost del-host order must be reported, got %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPState_ReportsADivergingIP(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ b, _ := servedBy(t)
+ if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.99", DefaultRoute: true},
+ }); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if !r.hasProblemContaining("dhcp reservation diverges") {
+ t.Errorf("a mac served with the wrong address must be reported, got %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPState_ReportsADivergingDefaultRoute(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ b, _ := servedBy(t)
+ if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: false},
+ }); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if !r.hasProblemContaining("dhcp default route diverges") {
+ t.Errorf("a wrong default route would break multi-subnet routing, got %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPState_ReportsAnUnreachableServer(t *testing.T) {
+ db := newTestDB(t)
+ seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC)
+
+ b := dhcpbackend.Two{RunDir: shortTempDir(t)}
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if !r.hasProblemContaining("dhcp server unreachable") {
+ t.Errorf("a dead server must be reported, got %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPState_ReportsAServerWithNoSubnetConfiguration(t *testing.T) {
+ db := newTestDB(t)
+ seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC)
+
+ b := dhcpbackend.Two{RunDir: shortTempDir(t)}
+ s := twoSubnet()
+ store := dhcpd.NewStore(dhcpapi.StatePath(b.RunDir, s.Instance()))
+ if err := store.Load(); err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ server, err := dhcpapi.Listen(store, dhcpapi.SocketPath(b.RunDir, s.Instance()), slog.New(slog.NewTextHandler(io.Discard, nil)))
+ if err != nil {
+ t.Fatalf("Listen: %v", err)
+ }
+ go server.Serve()
+ t.Cleanup(func() { server.Close() })
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, s, b, r)
+
+ if !r.hasProblemContaining("no subnet configuration") {
+ t.Errorf("a server that was never configured serves nothing, got %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPConfigFile_ReportsAMissingDnsmasqConfig(t *testing.T) {
+ r := &recorder{}
+ checkDHCPConfigFile(testSubnetName, filepath.Join(t.TempDir(), "absent.conf"), r)
+
+ if !r.hasProblemContaining("dnsmasq config missing") {
+ t.Errorf("notifications = %+v, want the missing config reported", r.calls)
+ }
+}
+
+func TestCheckDHCP_WithoutABackendChecksNothing(t *testing.T) {
+ db := newTestDB(t)
+ r := &recorder{}
+
+ checkDHCP(db, testSubnetName, twoSubnet(), nil, nil, r)
+
+ if len(r.calls) != 0 {
+ t.Errorf("notifications = %+v, want none: the caller already reported the unusable backend", r.calls)
+ }
+}
diff --git a/internal/watchdog/check_subnet.go b/internal/watchdog/check_subnet.go
index fafb2ff..99dac17 100644
--- a/internal/watchdog/check_subnet.go
+++ b/internal/watchdog/check_subnet.go
@@ -2,12 +2,11 @@ package watchdog
import (
"fmt"
- "os"
- "path/filepath"
"strconv"
"strings"
- "git.g3e.fr/syonad/two/internal/dhcp"
+ configuration "git.g3e.fr/syonad/two/internal/config/agent"
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
"git.g3e.fr/syonad/two/internal/netns"
"git.g3e.fr/syonad/two/internal/state"
"git.g3e.fr/syonad/two/internal/watchdog/notify"
@@ -30,16 +29,14 @@ func subnetIfaceNames(subnetName string) (hostVeth, nsVeth, bridge string, err e
return "v-" + id + "-e", "v-" + id + "-i", "br-" + id, nil
}
-func dnsmasqName(vpc, bridge string) string {
- return vpc + "_" + bridge
-}
-
-func CheckSubnets(db *badger.DB, u unitChecker, n notify.Notifier) error {
+func CheckSubnets(db *badger.DB, cfg *configuration.Config, u unitChecker, n notify.Notifier) error {
pairs, err := kv.ListByPrefix(db, prefixSubnet)
if err != nil {
return fmt.Errorf("watchdog: listing subnets: %w", err)
}
+ backend, backendErr := dhcpbackend.New(cfg)
+
for _, name := range resourceNames(pairs, prefixSubnet) {
st, err := state.Get(db, prefixSubnet+name)
if err != nil {
@@ -49,12 +46,15 @@ func CheckSubnets(db *badger.DB, u unitChecker, n notify.Notifier) error {
if st != state.Running {
continue
}
- checkSubnet(db, name, u, n)
+ checkSubnet(db, name, backend, u, n)
+ if backendErr != nil {
+ n.Notify(kindSubnet, name, fmt.Sprintf("dhcp checks skipped, backend unusable: %v", backendErr))
+ }
}
return nil
}
-func checkSubnet(db *badger.DB, name string, u unitChecker, n notify.Notifier) {
+func checkSubnet(db *badger.DB, name string, backend dhcpbackend.Backend, u unitChecker, n notify.Notifier) {
hostVeth, nsVeth, bridge, err := subnetIfaceNames(name)
if err != nil {
n.Notify(kindSubnet, name, err.Error())
@@ -90,13 +90,7 @@ func checkSubnet(db *badger.DB, name string, u unitChecker, n notify.Notifier) {
checkSubnetNetns(name, vpc, nsVeth, bridge, n)
- dnsName := dnsmasqName(vpc, bridge)
- conf := filepath.Join(dhcp.DefaultConfDir, dnsName+".conf")
- if _, err := os.Stat(conf); err != nil {
- n.Notify(kindSubnet, name, fmt.Sprintf("dnsmasq config missing (%s): %v", conf, err))
- }
-
- checkUnit(kindSubnet, name, "dnsmasq@"+dnsName+".service", u, n)
+ checkDHCP(db, name, dhcpbackend.Subnet{Name: name, VPC: vpc, Bridge: bridge}, backend, u, n)
}
func checkVxlanIface(db *badger.DB, name string, n notify.Notifier) {
diff --git a/internal/watchdog/check_subnet_test.go b/internal/watchdog/check_subnet_test.go
index 6928e6f..ec4acd4 100644
--- a/internal/watchdog/check_subnet_test.go
+++ b/internal/watchdog/check_subnet_test.go
@@ -39,17 +39,11 @@ func TestSubnetIfaceNames_TiretFinal(t *testing.T) {
}
}
-func TestDnsmasqName(t *testing.T) {
- if got := dnsmasqName("vp-admin", "br-000000"); got != "vp-admin_br-000000" {
- t.Errorf("dnsmasqName = %q, attendu vp-admin_br-000000", got)
- }
-}
-
func TestCheckSubnets_BaseVide(t *testing.T) {
db := newTestDB(t)
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
if len(r.calls) != 0 {
@@ -64,7 +58,7 @@ func TestCheckSubnets_IgnoreLesEtatsNonRunning(t *testing.T) {
}
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
if len(r.calls) != 0 {
@@ -77,7 +71,7 @@ func TestCheckSubnets_VPCManquantEnBase(t *testing.T) {
seedResource(t, db, prefixSubnet, "br-000042", state.Running)
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -96,7 +90,7 @@ func TestCheckSubnets_ModeManquantEnBase(t *testing.T) {
seedKV(t, db, prefixSubnet+"br-000042/vpc", "vp-admin")
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -110,7 +104,7 @@ func TestCheckSubnets_ModeInconnu(t *testing.T) {
seedSubnet(t, db, "br-000042", "vp-admin", "macvlan")
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -124,7 +118,7 @@ func TestCheckSubnets_ModeBridgeNeVerifiePasDeVxlan(t *testing.T) {
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -143,7 +137,7 @@ func TestCheckSubnets_ModeVxlanSansVxlanID(t *testing.T) {
seedSubnet(t, db, "br-000042", "vp-admin", modeVxlan)
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -158,7 +152,7 @@ func TestCheckSubnets_ModeVxlanVxlanIDInvalide(t *testing.T) {
seedKV(t, db, prefixSubnet+"br-000042/vxlan_id", "pas-un-nombre")
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -173,7 +167,7 @@ func TestCheckSubnets_ModeVxlanVerifieLInterfaceVxlan(t *testing.T) {
seedKV(t, db, prefixSubnet+"br-000042/vxlan_id", "42")
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -187,7 +181,7 @@ func TestCheckSubnets_ConfigDnsmasqAbsente(t *testing.T) {
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -205,7 +199,7 @@ func TestCheckSubnets_UnitDnsmasqInterrogee(t *testing.T) {
u := newFakeUnits().active("dnsmasq@vp-admin_br-000042.service")
r := &recorder{}
- if err := CheckSubnets(db, u, r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -223,7 +217,7 @@ func TestCheckSubnets_UnitDnsmasqInactive(t *testing.T) {
u := newFakeUnits().inactive("dnsmasq@vp-admin_br-000042.service", "failed")
r := &recorder{}
- if err := CheckSubnets(db, u, r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -238,7 +232,7 @@ func TestCheckSubnets_UnitIllisible(t *testing.T) {
u := newFakeUnits().failing("dnsmasq@vp-admin_br-000042.service", errors.New("dbus indisponible"))
r := &recorder{}
- if err := CheckSubnets(db, u, r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -252,7 +246,7 @@ func TestCheckSubnets_SansUnitCheckerPasDeVerificationDUnit(t *testing.T) {
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
r := &recorder{}
- if err := CheckSubnets(db, nil, r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), nil, r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -267,7 +261,7 @@ func TestCheckSubnets_EtatCorrompuNInterrompPasLaBoucle(t *testing.T) {
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("un état corrompu ne doit pas faire échouer CheckSubnets: %v", err)
}
diff --git a/internal/watchdog/helpers_test.go b/internal/watchdog/helpers_test.go
index 891eeba..84e9c79 100644
--- a/internal/watchdog/helpers_test.go
+++ b/internal/watchdog/helpers_test.go
@@ -4,6 +4,7 @@ import (
"strings"
"testing"
+ configuration "git.g3e.fr/syonad/two/internal/config/agent"
"git.g3e.fr/syonad/two/internal/state"
"git.g3e.fr/syonad/two/internal/watchdog/notify"
"git.g3e.fr/syonad/two/pkg/db/kv"
@@ -108,3 +109,9 @@ func seedResource(t *testing.T, db *badger.DB, prefix, name string, s state.Stat
t.Fatalf("seedResource %s%s: %v", prefix, name, err)
}
}
+
+func dnsmasqConfig() *configuration.Config {
+ cfg := &configuration.Config{}
+ cfg.DHCP.Backend = configuration.BackendDnsmasq
+ return cfg
+}
diff --git a/internal/watchdog/watchdog.go b/internal/watchdog/watchdog.go
index 3ab1d52..6da33b8 100644
--- a/internal/watchdog/watchdog.go
+++ b/internal/watchdog/watchdog.go
@@ -65,7 +65,7 @@ func (w *Watchdog) tick() {
if err := CheckVPCs(w.db, w.notifier); err != nil {
w.logger.Error("watchdog: vpc check failed", "err", err)
}
- if err := CheckSubnets(w.db, u, w.notifier); err != nil {
+ if err := CheckSubnets(w.db, w.cfg, u, w.notifier); err != nil {
w.logger.Error("watchdog: subnet check failed", "err", err)
}
if err := CheckVMs(w.db, w.cfg, u, w.notifier); err != nil {
diff --git a/pkg/db/statefile/statefile.go b/pkg/db/statefile/statefile.go
index fc62524..0d8ba51 100644
--- a/pkg/db/statefile/statefile.go
+++ b/pkg/db/statefile/statefile.go
@@ -85,8 +85,12 @@ func (f *File[T]) Save(value T) error {
}
func (f *File[T]) Remove() error {
- if err := os.Remove(f.path); err != nil && !errors.Is(err, os.ErrNotExist) {
- return fmt.Errorf("remove %s: %w", f.path, err)
+ return Remove(f.path)
+}
+
+func Remove(path string) error {
+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("remove %s: %w", path, err)
}
return nil
}
diff --git a/release_notes/0.2.0.md b/release_notes/0.2.0.md
new file mode 100644
index 0000000..5f42489
--- /dev/null
+++ b/release_notes/0.2.0.md
@@ -0,0 +1,87 @@
+# Bael
+
+Serveur DHCP intégré, en coexistence avec dnsmasq, et documentation du projet.
+
+## Fonctionnalités
+
+**Serveur DHCP intégré**
+
+- Nouveau binaire `dhcp`, une instance par subnet lancée dans le netns du VPC par l'unit
+ `dhcp@_`. Il reçoit son bridge et ses deux chemins de fichiers en paramètres :
+ il ne compose aucun chemin et ignore le netns dans lequel il tourne
+- Piloté par l'agent sur une **socket Unix** `/run/two/dhcp/_.sock`, en JSON par
+ ligne. Ordres idempotents en remplacement intégral : configuration du subnet à sa création,
+ une réservation par interface à chaque création ou suppression de VM
+- **Réservations statiques uniquement**, pas de baux : une MAC inconnue n'obtient rien, et le
+ serveur reste silencieux plutôt que de répondre par un refus. Aucun `DHCPNAK` n'est émis
+- État auto-persisté dans `/run/two/dhcp/_.state`, en écriture atomique et lisible
+ par le seul `root`. Le fichier appartient au processus, qui le relit à son démarrage ; l'agent
+ ne l'écrit jamais et se borne à le supprimer — à la création du subnet pour écarter un résidu,
+ à sa suppression après avoir arrêté l'unit
+- La route par défaut est décidée **par interface** et non plus par subnet, ce qui permet de ne
+ l'annoncer que sur une interface d'une VM multi-réseaux
+- L'encodage RFC 3442 de l'option 121 est délégué à `github.com/insomniacslk/dhcp`
+
+**Coexistence avec dnsmasq**
+
+- Nouvelle clé `dhcp.backend`, `dnsmasq` ou `two`, qui choisit le serveur des subnets **créés par
+ cet agent**. Le défaut est `dnsmasq` : un fichier de configuration de la 0.1.0, non modifié, se
+ comporte exactement comme avant
+- La bascule est une **opération manuelle** sur un hyperviseur vide — l'option ne migre rien, un
+ subnet déjà créé reste servi par le serveur qui l'a été. La procédure est documentée
+- Toute valeur autre que `dnsmasq` ou `two` fait échouer le démarrage de l'agent
+
+**Exploitation**
+
+- Le watchdog interroge le serveur DHCP intégré et compare les réservations servies à celles que
+ la base implique. Il nomme ce qui diverge — ordre perdu à la création, ordre perdu à la
+ suppression, adresse ou route par défaut divergente — et reste en lecture seule
+- La socket de contrôle répond à `get-state` et à `probe`, ce dernier montrant sans effet de bord
+ ce qui serait envoyé à une MAC donnée : adresse, masque, routeur, DNS et routes
+- Documentation Sphinx du projet : concepts, architecture, déploiement et exploitation
+
+## Correctifs
+
+- **Un fichier de configuration présent mais invalide fait désormais échouer le démarrage.**
+ Jusqu'en 0.1.0 l'erreur de lecture était ignorée : l'agent tournait alors entièrement sur ses
+ valeurs par défaut sans le dire, ce qui rendait indétectable une simple tabulation d'indentation
+- `probe` annonce explicitement `"served": false` pour une MAC non réservée, au lieu d'omettre le
+ champ et de le rendre indistinguable d'une réponse tronquée
+
+## Changements internes
+
+- Le module passe à **Go 1.25**, exigé par la bibliothèque DHCP retenue. La version de Go du
+ workflow de build, restée à 1.21 alors que le module en demandait davantage, est alignée
+- Nouveau paquet `pkg/db/statefile` : persistance générique d'un état de composant dans un
+ fichier, en écriture atomique. Badger a été écarté pour cet usage — une instance par subnet
+ coûterait une memtable de 64 Mio et quatre goroutines de compaction pour environ un kilo-octet
+ d'état, dans un `tmpfs`, et laisserait un verrou résiduel après un arrêt brutal
+- `internal/subnet` et `internal/vm` ne parlent plus à dnsmasq en direct mais à une interface
+ `Backend` à deux implémentations
+
+## Périmètre et limites connues
+
+Celles de la 0.1.0 restent valables, sauf mention contraire ci-dessus. S'y ajoutent :
+
+- **dnsmasq n'est pas retiré** et reste un paquet requis : le backend intégré ne sert que les
+ subnets créés après la bascule, et le retour arrière suppose dnsmasq installé
+- Pas de DNS, pas de pool dynamique, pas de PXE, pas de DHCPv6 dans le serveur intégré
+- La comparaison faite par le watchdog porte sur les **réservations** et non sur la configuration
+ du subnet : celle-ci dépend de la route par défaut de l'host, dont la lecture au moment du
+ contrôle produirait de faux écarts. Un serveur dépourvu de configuration est en revanche signalé
+- L'option 249 (routes classless de Microsoft) n'est pas émise, comme dnsmasq ne l'émet pas
+- Le serveur intégré écoute UDP/67 sans authentification, comme tout serveur DHCP : l'isolation
+ entre locataires d'un même subnet repose sur les règles ebtables anti-usurpation, inchangées
+- `internal/dhcpbackend` n'est testé sous Linux que pour ses appels systemd ; le reste, y compris
+ le dialogue avec le serveur intégré, est couvert sur toute plateforme
+
+## Mise à jour depuis la 0.1.0
+
+```bash
+curl -O https://git.g3e.fr/syonad/two/raw/branch/main/scripts/deploy.sh
+bash ./deploy.sh -t 0.2.0
+```
+
+Aucune action n'est requise : sans `dhcp.backend` dans `/etc/two/agent.yml`, le comportement est
+celui de la 0.1.0. Pour passer au serveur intégré, suivre la procédure de bascule dans la
+documentation d'exploitation — elle suppose un hyperviseur vidé.
diff --git a/release_notes/codenames.md b/release_notes/codenames.md
index 17161c2..fe77557 100644
--- a/release_notes/codenames.md
+++ b/release_notes/codenames.md
@@ -21,7 +21,7 @@ prochain nom disponible sans tenir de compteur ailleurs : c'est la première lig
| # | Nom | Nature | Version | Date |
|---|---|---|---|---|
| 1 | Michael | ange | [0.1.0](0.1.0.md) | 2026-08-26 |
-| 2 | Bael | démon | 0.2.0 | |
+| 2 | Bael | démon | [0.2.0](0.2.0.md) | |
| 3 | Gabriel | ange | | |
| 4 | Agares | démon | | |
| 5 | Raphael | ange | | |