Merge branch 'feature-46'
All checks were successful
Documentation / publish (push) Successful in 36s

This commit is contained in:
GnomeZworc 2026-09-10 22:10:11 +02:00
commit 01c67bd0f9
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
71 changed files with 5497 additions and 235 deletions

View file

@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-go@v5
with:
go-version: "1.21"
go-version: "1.25.14"
- name: Build du projet
run: |
echo "Building for ${BINARI}/${GOOS}/${GOARCH} (release: ${RELEASE_CIBLE})"

168
.forgejo/workflows/docs.yml Normal file
View file

@ -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" <<HTML
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>two — documentation</title>
<meta http-equiv="refresh" content="0; url=./${preferred}/">
</head>
<body><p><a href="./${preferred}/">Documentation de two</a></p></body>
</html>
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"

View file

@ -51,6 +51,7 @@ jobs:
binaries:
- metadata
- agent
- dhcp
uses: ./.forgejo/workflows/build.yml
with:
tag: ${{ needs.set-release-target.outputs.release_cible }}
@ -69,10 +70,14 @@ jobs:
include:
- path: scripts/run-dnsmasq-in-netns.sh
name: run-dnsmasq-in-netns.sh
- path: scripts/run-dhcp-in-netns.sh
name: run-dhcp-in-netns.sh
- path: systemd/agent.service
name: agent.service
- path: systemd/dnsmasq@.service
name: dnsmasq@.service
- path: systemd/dhcp@.service
name: dhcp@.service
- path: systemd/metadata@.service
name: metadata@.service
steps:

3
.gitignore vendored
View file

@ -30,3 +30,6 @@ go.work.sum
# ignore local info
data/
# Sphinx build output
docs/_build/

View file

@ -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

78
cmd/dhcp/main.go Normal file
View file

@ -0,0 +1,78 @@
package main
import (
"flag"
"fmt"
"net"
"os"
dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp"
configuration "git.g3e.fr/syonad/two/internal/config/agent"
"git.g3e.fr/syonad/two/internal/dhcpd"
"git.g3e.fr/syonad/two/pkg/logger"
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/server4"
)
var (
confFile = flag.String("conf", "/etc/two/agent.yml", "configuration file")
iface = flag.String("interface", "", "bridge to serve, already present in the current network namespace")
statePath = flag.String("state", "", "state file owned by this process")
socketPath = flag.String("socket", "", "control socket the agent talks to")
)
func main() {
flag.Parse()
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "dhcp: %v\n", err)
os.Exit(1)
}
}
func run() error {
for name, value := range map[string]string{
"-interface": *iface,
"-state": *statePath,
"-socket": *socketPath,
} {
if value == "" {
return fmt.Errorf("%s is required", name)
}
}
cfg, err := configuration.LoadConfig(*confFile)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
log := logger.New(cfg.Logger.Level, cfg.Logger.Debug).With("bridge", *iface)
store := dhcpd.NewStore(*statePath)
if err := store.Load(); err != nil {
return fmt.Errorf("load state: %w", err)
}
control, err := dhcpapi.Listen(store, *socketPath, log)
if err != nil {
return fmt.Errorf("listen on the control socket: %w", err)
}
defer control.Close()
go func() {
if err := control.Serve(); err != nil {
log.Error("control socket stopped", "error", err)
}
}()
conn, err := server4.NewIPv4UDPConn(*iface, &net.UDPAddr{Port: dhcpv4.ServerPort})
if err != nil {
return fmt.Errorf("bind udp/%d on %s: %w", dhcpv4.ServerPort, *iface, err)
}
defer conn.Close()
log.Info("dhcp server started", "state", store.Path(), "socket", control.Addr())
return store.Serve(conn, log)
}

View file

@ -39,6 +39,16 @@ interfaces:
metadata:
run_dir: "/run/two/metadata"
# DHCP backend used for the subnets created by this agent.
# dnsmasq : dnsmasq@ instances driven by generated config files
# two : the built-in dhcp binary, driven over a unix socket
# Switching backends is a manual operation: drain the hypervisor, change this
# value, restart the agent. There is no hot migration.
# The per-subnet control socket and state file live in /run/two/dhcp, which is
# not configurable: the wrapper script hardcodes it too.
dhcp:
backend: dnsmasq
# QEMU runtime paths
qemu:
# UEFI firmware (requires apt install ovmf on Debian/Ubuntu)

View file

@ -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``

View file

@ -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

View file

@ -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',
]
}

View file

@ -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
-----------------------

View file

@ -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

View file

@ -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/<name>
A-->>C: 200 + state

View file

@ -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@<netns>_<bridge>``
* - ``two``
- le binaire ``dhcp``, piloté par socket Unix
- ``dhcp@<netns>_<bridge>``
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
--------

View file

@ -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-<netns>_<bridge>.leases
cat /etc/dnsmasq.d/<netns>_<bridge>.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@<netns>_<bridge>'
journalctl -u 'dhcp@<netns>_<bridge>' -n 50
# Ce que le serveur a réellement en mémoire
echo '{"verb":"get-state"}' \
| socat - UNIX-CONNECT:/run/two/dhcp/<netns>_<bridge>.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/<netns>_<bridge>.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

View file

@ -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
---------------------

View file

@ -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``
- ``<netns>_<bridge>``
- dnsmasq lancé dans le netns du VPC, un par subnet
- dnsmasq lancé dans le netns du VPC, un par subnet — backend ``dnsmasq``
* - ``dhcp@.service``
- ``<netns>_<bridge>``
- serveur DHCP intégré, un par subnet — backend ``two``
* - ``metadata@.service``
- ``<nom de la VM>``
- 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/<netns>_<bridge>.sock``
* - État
- ``/run/two/dhcp/<netns>_<bridge>.state``
* - Journal
- ``journalctl -u 'dhcp@<netns>_<bridge>'``
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.

2
docs/versions/0.2.0.md Normal file
View file

@ -0,0 +1,2 @@
```{include} ../../release_notes/0.2.0.md
```

View file

@ -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

31
go.mod
View file

@ -1,14 +1,24 @@
module git.g3e.fr/syonad/two
go 1.24.0
go 1.25.0
toolchain go1.24.11
toolchain go1.25.14
require (
github.com/coreos/go-systemd/v22 v22.6.0
github.com/dgraph-io/badger/v4 v4.8.0
github.com/insomniacslk/dhcp v0.0.0-20260831074340-8416b400a2b2
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/spf13/viper v1.21.0
github.com/vishvananda/netlink v1.3.1
github.com/vishvananda/netns v0.0.5
golang.org/x/sys v0.45.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/dgraph-io/badger/v4 v4.8.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
@ -17,11 +27,11 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/pierrec/lz4/v4 v4.1.14 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
@ -29,18 +39,15 @@ require (
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)

54
go.sum
View file

@ -4,12 +4,18 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@ -23,12 +29,29 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/insomniacslk/dhcp v0.0.0-20260831074340-8416b400a2b2 h1:rhbGNh5bIA6JYahHdphjFiIbavqVspsvXFvTpBR7dQE=
github.com/insomniacslk/dhcp v0.0.0-20260831074340-8416b400a2b2/go.mod h1:tGfUTcnFYGYvVNCaZZhwlJySU/fQQxh9TmpsFzWXnnY=
github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@ -37,6 +60,8 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
@ -49,8 +74,12 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
@ -63,24 +92,25 @@ go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/Wgbsd
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,83 @@
package dhcpapi
import (
"fmt"
"net"
"git.g3e.fr/syonad/two/internal/dhcpd"
)
func (s Subnet) toConfig() (dhcpd.SubnetConfig, error) {
_, network, err := net.ParseCIDR(s.Network)
if err != nil {
return dhcpd.SubnetConfig{}, fmt.Errorf("invalid network %q: %w", s.Network, err)
}
interfaceIP := net.ParseIP(s.InterfaceIP)
if interfaceIP == nil {
return dhcpd.SubnetConfig{}, fmt.Errorf("invalid interface ip %q", s.InterfaceIP)
}
c := dhcpd.SubnetConfig{Network: network, InterfaceIP: interfaceIP}
if s.VPCRoute != "" {
if _, c.VPCRoute, err = net.ParseCIDR(s.VPCRoute); err != nil {
return dhcpd.SubnetConfig{}, fmt.Errorf("invalid vpc route %q: %w", s.VPCRoute, err)
}
}
if s.DefaultGateway != "" {
if c.DefaultGateway = net.ParseIP(s.DefaultGateway); c.DefaultGateway == nil {
return dhcpd.SubnetConfig{}, fmt.Errorf("invalid default gateway %q", s.DefaultGateway)
}
}
return c, nil
}
func (h Host) toHost() (dhcpd.Host, error) {
mac, err := net.ParseMAC(h.MAC)
if err != nil {
return dhcpd.Host{}, fmt.Errorf("invalid mac %q: %w", h.MAC, err)
}
ip := net.ParseIP(h.IP)
if ip == nil {
return dhcpd.Host{}, fmt.Errorf("invalid host ip %q", h.IP)
}
return dhcpd.Host{MAC: mac, IP: ip, VM: h.VM, DefaultRoute: h.DefaultRoute}, nil
}
func subnetFromConfig(c dhcpd.SubnetConfig) Subnet {
s := Subnet{
Network: c.Network.String(),
InterfaceIP: c.InterfaceIP.String(),
}
if c.VPCRoute != nil {
s.VPCRoute = c.VPCRoute.String()
}
if c.DefaultGateway != nil {
s.DefaultGateway = c.DefaultGateway.String()
}
return s
}
func hostFromHost(h dhcpd.Host) Host {
return Host{
MAC: h.MAC.String(),
IP: h.IP.String(),
VM: h.VM,
DefaultRoute: h.DefaultRoute,
}
}
func stateFromStore(store *dhcpd.Store) State {
state := State{Hosts: make([]Host, 0)}
if config, configured := store.Subnet(); configured {
subnet := subnetFromConfig(config)
state.Subnet = &subnet
}
for _, h := range store.Hosts() {
state.Hosts = append(state.Hosts, hostFromHost(h))
}
SortHosts(state.Hosts)
return state
}

116
internal/api/dhcp/digest.go Normal file
View file

@ -0,0 +1,116 @@
package dhcpapi
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"sort"
)
func canonicalMAC(s string) (string, error) {
mac, err := net.ParseMAC(s)
if err != nil {
return "", fmt.Errorf("invalid mac %q: %w", s, err)
}
return mac.String(), nil
}
func canonicalIP(s string) (string, error) {
ip := net.ParseIP(s)
if ip == nil {
return "", fmt.Errorf("invalid ip %q", s)
}
return ip.String(), nil
}
func canonicalCIDR(s string) (string, error) {
_, network, err := net.ParseCIDR(s)
if err != nil {
return "", fmt.Errorf("invalid cidr %q: %w", s, err)
}
return network.String(), nil
}
func SortHosts(hosts []Host) {
sort.Slice(hosts, func(i, j int) bool { return hosts[i].MAC < hosts[j].MAC })
}
func CanonicalSubnet(s Subnet) (Subnet, error) {
network, err := canonicalCIDR(s.Network)
if err != nil {
return Subnet{}, err
}
interfaceIP, err := canonicalIP(s.InterfaceIP)
if err != nil {
return Subnet{}, err
}
out := Subnet{Network: network, InterfaceIP: interfaceIP}
if s.VPCRoute != "" {
if out.VPCRoute, err = canonicalCIDR(s.VPCRoute); err != nil {
return Subnet{}, err
}
}
if s.DefaultGateway != "" {
if out.DefaultGateway, err = canonicalIP(s.DefaultGateway); err != nil {
return Subnet{}, err
}
}
return out, nil
}
func CanonicalHost(h Host) (Host, error) {
mac, err := canonicalMAC(h.MAC)
if err != nil {
return Host{}, err
}
ip, err := canonicalIP(h.IP)
if err != nil {
return Host{}, err
}
return Host{MAC: mac, IP: ip, VM: h.VM, DefaultRoute: h.DefaultRoute}, nil
}
func Canonical(s State) (State, error) {
out := State{Hosts: make([]Host, 0, len(s.Hosts))}
if s.Subnet != nil {
subnet, err := CanonicalSubnet(*s.Subnet)
if err != nil {
return State{}, err
}
out.Subnet = &subnet
}
seen := make(map[string]struct{}, len(s.Hosts))
for _, h := range s.Hosts {
host, err := CanonicalHost(h)
if err != nil {
return State{}, err
}
if _, dup := seen[host.MAC]; dup {
return State{}, fmt.Errorf("duplicate mac %s", host.MAC)
}
seen[host.MAC] = struct{}{}
out.Hosts = append(out.Hosts, host)
}
SortHosts(out.Hosts)
return out, nil
}
func Digest(s State) (string, error) {
canonical, err := Canonical(s)
if err != nil {
return "", err
}
raw, err := json.Marshal(canonical)
if err != nil {
return "", fmt.Errorf("encode state: %w", err)
}
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:]), nil
}

View file

@ -0,0 +1,185 @@
package dhcpapi
import (
"encoding/json"
"strings"
"testing"
)
func subnet() Subnet {
return Subnet{
Network: "10.0.5.0/24",
InterfaceIP: "10.0.5.1",
VPCRoute: "10.0.0.0/16",
DefaultGateway: "10.0.5.254",
}
}
func hosts() []Host {
return []Host{
{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", VM: "vm-a", DefaultRoute: true},
{MAC: "00:22:33:00:00:0b", IP: "10.0.5.11", VM: "vm-b"},
}
}
func digestOf(t *testing.T, s State) string {
t.Helper()
d, err := Digest(s)
if err != nil {
t.Fatalf("Digest: %v", err)
}
return d
}
func TestDigest_IsStableAcrossHostOrder(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
reversed := hosts()
reversed[0], reversed[1] = reversed[1], reversed[0]
b := State{Subnet: &sub, Hosts: reversed}
if digestOf(t, a) != digestOf(t, b) {
t.Error("host order must not change the digest: the watchdog would report a phantom drift")
}
}
func TestDigest_IsStableAcrossMACCase(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
upper := hosts()
upper[0].MAC = "00:22:33:00:00:0A"
b := State{Subnet: &sub, Hosts: upper}
if digestOf(t, a) != digestOf(t, b) {
t.Error("mac case must not change the digest")
}
}
func TestDigest_IsStableAcrossIPv4InIPv6Notation(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
mapped := subnet()
mapped.InterfaceIP = "::ffff:10.0.5.1"
b := State{Subnet: &mapped, Hosts: hosts()}
if digestOf(t, a) != digestOf(t, b) {
t.Error("the same address written in ipv4-mapped form must hash alike")
}
}
func TestDigest_ChangesWhenAHostIPChanges(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
moved := hosts()
moved[0].IP = "10.0.5.99"
b := State{Subnet: &sub, Hosts: moved}
if digestOf(t, a) == digestOf(t, b) {
t.Error("a changed reservation must change the digest")
}
}
func TestDigest_ChangesWhenTheDefaultRouteFlagChanges(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
flipped := hosts()
flipped[0].DefaultRoute = false
b := State{Subnet: &sub, Hosts: flipped}
if digestOf(t, a) == digestOf(t, b) {
t.Error("the default route flag is part of the served state")
}
}
func TestDigest_ChangesWhenTheSubnetChanges(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
other := subnet()
other.DefaultGateway = "10.0.5.253"
b := State{Subnet: &other, Hosts: hosts()}
if digestOf(t, a) == digestOf(t, b) {
t.Error("the subnet configuration is part of the served state")
}
}
func TestDigest_DistinguishesNoSubnetFromAConfiguredOne(t *testing.T) {
sub := subnet()
configured := State{Subnet: &sub}
bare := State{}
if digestOf(t, configured) == digestOf(t, bare) {
t.Error("an unconfigured subnet must not hash like a configured one")
}
}
func TestDigest_EmptyAndNilHostsHashAlike(t *testing.T) {
if digestOf(t, State{Hosts: nil}) != digestOf(t, State{Hosts: []Host{}}) {
t.Error("nil and empty host lists describe the same state")
}
}
func TestDigest_RejectsAnInvalidMAC(t *testing.T) {
if _, err := Digest(State{Hosts: []Host{{MAC: "nope", IP: "10.0.5.10"}}}); err == nil {
t.Fatal("an invalid mac must be reported, not hashed")
}
}
func TestDigest_RejectsAnInvalidIP(t *testing.T) {
if _, err := Digest(State{Hosts: []Host{{MAC: "00:22:33:00:00:0a", IP: "10.0.5.300"}}}); err == nil {
t.Fatal("an invalid ip must be reported, not hashed")
}
}
func TestCanonical_RejectsADuplicateMAC(t *testing.T) {
dup := []Host{
{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10"},
{MAC: "00:22:33:00:00:0A", IP: "10.0.5.11"},
}
if _, err := Canonical(State{Hosts: dup}); err == nil {
t.Fatal("the same mac twice is an inconsistent state, not something to hash")
}
}
func TestCanonical_NormalizesTheNetworkToItsBaseAddress(t *testing.T) {
sub := subnet()
sub.Network = "10.0.5.42/24"
got, err := CanonicalSubnet(sub)
if err != nil {
t.Fatalf("CanonicalSubnet: %v", err)
}
if got.Network != "10.0.5.0/24" {
t.Errorf("network = %s, want 10.0.5.0/24", got.Network)
}
}
func TestCanonical_SortsHostsByMAC(t *testing.T) {
unsorted := []Host{
{MAC: "00:22:33:00:00:0c", IP: "10.0.5.12"},
{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10"},
}
got, err := Canonical(State{Hosts: unsorted})
if err != nil {
t.Fatalf("Canonical: %v", err)
}
if got.Hosts[0].MAC != "00:22:33:00:00:0a" {
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)
}
}

View file

@ -0,0 +1,58 @@
package dhcpapi
type Verb string
const (
VerbSetSubnet Verb = "set-subnet"
VerbSetHost Verb = "set-host"
VerbDelHost Verb = "del-host"
VerbGetState Verb = "get-state"
VerbProbe Verb = "probe"
)
const MaxMessageBytes = 64 * 1024
type Subnet struct {
Network string `json:"network"`
InterfaceIP string `json:"interface_ip"`
VPCRoute string `json:"vpc_route,omitempty"`
DefaultGateway string `json:"default_gateway,omitempty"`
}
type Host struct {
MAC string `json:"mac"`
IP string `json:"ip"`
VM string `json:"vm,omitempty"`
DefaultRoute bool `json:"default_route"`
}
type State struct {
Subnet *Subnet `json:"subnet,omitempty"`
Hosts []Host `json:"hosts"`
}
type Lease struct {
MAC string `json:"mac"`
IP string `json:"ip"`
Netmask string `json:"netmask"`
Router string `json:"router,omitempty"`
DNS []string `json:"dns"`
Routes []string `json:"routes"`
LeaseSeconds uint32 `json:"lease_seconds"`
}
type Request struct {
Verb Verb `json:"verb"`
Subnet *Subnet `json:"subnet,omitempty"`
Host *Host `json:"host,omitempty"`
MAC string `json:"mac,omitempty"`
}
type Response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
State *State `json:"state,omitempty"`
Digest string `json:"digest,omitempty"`
Lease *Lease `json:"lease,omitempty"`
Served bool `json:"served"`
}

View file

@ -0,0 +1,29 @@
package dhcpapi
import (
"path/filepath"
)
const (
DefaultRunDir = "/run/two/dhcp"
SocketExt = ".sock"
StateExt = ".state"
UnitExt = ".service"
UnitName = "dhcp@"
)
func Instance(vpc, bridge string) string {
return vpc + "_" + bridge
}
func Unit(instance string) string {
return UnitName + instance + UnitExt
}
func SocketPath(runDir, instance string) string {
return filepath.Join(runDir, instance+SocketExt)
}
func StatePath(runDir, instance string) string {
return filepath.Join(runDir, instance+StateExt)
}

View file

@ -0,0 +1,69 @@
package dhcpapi
import (
"os"
"strings"
"testing"
)
func TestInstance_JoinsVPCAndBridge(t *testing.T) {
if got := Instance("vp-admin", "br-000001"); got != "vp-admin_br-000001" {
t.Errorf("Instance = %s, want vp-admin_br-000001", got)
}
}
func TestUnit_NamesTheTemplatedService(t *testing.T) {
if got := Unit(Instance("vp-admin", "br-000001")); got != "dhcp@vp-admin_br-000001.service" {
t.Errorf("Unit = %s", got)
}
}
func TestSocketPath_SitsUnderTheRunDir(t *testing.T) {
got := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000001"))
if got != "/run/two/dhcp/vp-admin_br-000001.sock" {
t.Errorf("SocketPath = %s", got)
}
}
func TestStatePath_SitsUnderTheRunDir(t *testing.T) {
got := StatePath(DefaultRunDir, Instance("vp-admin", "br-000001"))
if got != "/run/two/dhcp/vp-admin_br-000001.state" {
t.Errorf("StatePath = %s", got)
}
}
func TestPaths_NameTheVPCSoAListingIsReadable(t *testing.T) {
got := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000001"))
if !strings.Contains(got, "vp-admin") {
t.Errorf("path = %s, want the vpc visible when listing the run dir", got)
}
}
func TestPaths_DistinguishTwoSubnetsOfTheSameVPC(t *testing.T) {
a := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000001"))
b := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000002"))
if a == b {
t.Error("two subnets must not share a control socket")
}
}
func TestSocketPath_StaysUnderTheUnixPathLimit(t *testing.T) {
got := SocketPath(DefaultRunDir, Instance("vp-000000", "br-000000"))
if len(got) > 100 {
t.Errorf("socket path is %d bytes (%s): sun_path caps at 104 on darwin and 108 on linux", len(got), got)
}
}
func TestDefaultRunDir_MatchesTheWrapperScript(t *testing.T) {
const script = "../../../scripts/run-dhcp-in-netns.sh"
raw, err := os.ReadFile(script)
if err != nil {
t.Fatalf("read %s: %v", script, err)
}
want := `RUN_DIR="` + DefaultRunDir + `"`
if !strings.Contains(string(raw), want) {
t.Errorf("%s does not set %s: the agent would talk to a socket the server never creates", script, want)
}
}

190
internal/api/dhcp/server.go Normal file
View file

@ -0,0 +1,190 @@
package dhcpapi
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"os"
"path/filepath"
"git.g3e.fr/syonad/two/internal/dhcpd"
"git.g3e.fr/syonad/two/pkg/db/statefile"
"github.com/insomniacslk/dhcp/dhcpv4"
)
const SocketMode = 0o600
type Server struct {
store *dhcpd.Store
listener net.Listener
logger *slog.Logger
}
func Listen(store *dhcpd.Store, path string, logger *slog.Logger) (*Server, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, statefile.DirMode); err != nil {
return nil, fmt.Errorf("create %s: %w", dir, err)
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("remove stale socket %s: %w", path, err)
}
listener, err := net.Listen("unix", path)
if err != nil {
return nil, fmt.Errorf("listen on %s: %w", path, err)
}
if err := os.Chmod(path, SocketMode); err != nil {
listener.Close()
return nil, fmt.Errorf("chmod %s: %w", path, err)
}
return &Server{store: store, listener: listener, logger: logger}, nil
}
func (s *Server) Addr() string {
return s.listener.Addr().String()
}
func (s *Server) Close() error {
return s.listener.Close()
}
func (s *Server) Serve() error {
for {
conn, err := s.listener.Accept()
if err != nil {
return err
}
go s.handleConn(conn)
}
}
func (s *Server) handleConn(conn net.Conn) {
defer conn.Close()
defer func() {
if r := recover(); r != nil {
s.logger.Error("control connection panicked", "panic", r)
}
}()
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 0, 4096), MaxMessageBytes)
encoder := json.NewEncoder(conn)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var req Request
if err := json.Unmarshal(line, &req); err != nil {
if err := encoder.Encode(failure(fmt.Errorf("malformed request: %w", err))); err != nil {
return
}
continue
}
if err := encoder.Encode(s.dispatch(req)); err != nil {
return
}
}
if err := scanner.Err(); err != nil {
s.logger.Error("control connection read failed", "error", err)
}
}
func failure(err error) Response {
return Response{OK: false, Error: err.Error()}
}
func (s *Server) dispatch(req Request) Response {
switch req.Verb {
case VerbSetSubnet:
if req.Subnet == nil {
return failure(errors.New("set-subnet requires a subnet"))
}
config, err := req.Subnet.toConfig()
if err != nil {
return failure(err)
}
if err := s.store.SetSubnet(config); err != nil {
return failure(err)
}
return Response{OK: true}
case VerbSetHost:
if req.Host == nil {
return failure(errors.New("set-host requires a host"))
}
host, err := req.Host.toHost()
if err != nil {
return failure(err)
}
if err := s.store.SetHost(host); err != nil {
return failure(err)
}
return Response{OK: true}
case VerbDelHost:
mac, err := net.ParseMAC(req.MAC)
if err != nil {
return failure(fmt.Errorf("invalid mac %q: %w", req.MAC, err))
}
if err := s.store.DelHost(mac); err != nil {
return failure(err)
}
return Response{OK: true}
case VerbGetState:
state := stateFromStore(s.store)
digest, err := Digest(state)
if err != nil {
return failure(err)
}
return Response{OK: true, State: &state, Digest: digest}
case VerbProbe:
mac, err := net.ParseMAC(req.MAC)
if err != nil {
return failure(fmt.Errorf("invalid mac %q: %w", req.MAC, err))
}
reply, err := s.store.Probe(mac)
if err != nil {
return failure(err)
}
if reply == nil {
return Response{OK: true, Served: false}
}
return Response{OK: true, Served: true, Lease: leaseFromReply(mac, reply)}
default:
return failure(fmt.Errorf("unknown verb %q", req.Verb))
}
}
func leaseFromReply(mac net.HardwareAddr, reply *dhcpv4.DHCPv4) *Lease {
lease := &Lease{
MAC: mac.String(),
IP: reply.YourIPAddr.String(),
Netmask: net.IP(reply.SubnetMask()).String(),
DNS: make([]string, 0, 2),
Routes: make([]string, 0, 3),
LeaseSeconds: uint32(reply.IPAddressLeaseTime(0).Seconds()),
}
if routers := reply.Router(); len(routers) > 0 {
lease.Router = routers[0].String()
}
for _, dns := range reply.DNS() {
lease.DNS = append(lease.DNS, dns.String())
}
for _, route := range reply.ClasslessStaticRoute() {
lease.Routes = append(lease.Routes, route.Dest.String()+" via "+route.Router.String())
}
return lease
}

View file

@ -0,0 +1,104 @@
package dhcpclient
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"net"
"time"
dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp"
)
const DefaultTimeout = 5 * time.Second
var ErrNotServed = errors.New("mac is not served by this subnet")
type Client struct {
path string
timeout time.Duration
}
func New(path string) *Client {
return &Client{path: path, timeout: DefaultTimeout}
}
func (c *Client) WithTimeout(d time.Duration) *Client {
return &Client{path: c.path, timeout: d}
}
func (c *Client) call(req dhcpapi.Request) (dhcpapi.Response, error) {
conn, err := net.DialTimeout("unix", c.path, c.timeout)
if err != nil {
return dhcpapi.Response{}, fmt.Errorf("dial %s: %w", c.path, err)
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(c.timeout)); err != nil {
return dhcpapi.Response{}, fmt.Errorf("set deadline on %s: %w", c.path, err)
}
raw, err := json.Marshal(req)
if err != nil {
return dhcpapi.Response{}, fmt.Errorf("encode %s: %w", req.Verb, err)
}
if _, err := conn.Write(append(raw, '\n')); err != nil {
return dhcpapi.Response{}, fmt.Errorf("send %s: %w", req.Verb, err)
}
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 0, 4096), dhcpapi.MaxMessageBytes)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return dhcpapi.Response{}, fmt.Errorf("read reply to %s: %w", req.Verb, err)
}
return dhcpapi.Response{}, fmt.Errorf("no reply to %s", req.Verb)
}
var resp dhcpapi.Response
if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil {
return dhcpapi.Response{}, fmt.Errorf("parse reply to %s: %w", req.Verb, err)
}
if !resp.OK {
return resp, fmt.Errorf("%s refused: %s", req.Verb, resp.Error)
}
return resp, nil
}
func (c *Client) SetSubnet(subnet dhcpapi.Subnet) error {
_, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbSetSubnet, Subnet: &subnet})
return err
}
func (c *Client) SetHost(host dhcpapi.Host) error {
_, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbSetHost, Host: &host})
return err
}
func (c *Client) DelHost(mac string) error {
_, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbDelHost, MAC: mac})
return err
}
func (c *Client) GetState() (dhcpapi.State, string, error) {
resp, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbGetState})
if err != nil {
return dhcpapi.State{}, "", err
}
if resp.State == nil {
return dhcpapi.State{}, "", errors.New("get-state returned no state")
}
return *resp.State, resp.Digest, nil
}
func (c *Client) Probe(mac string) (dhcpapi.Lease, error) {
resp, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbProbe, MAC: mac})
if err != nil {
return dhcpapi.Lease{}, err
}
if !resp.Served || resp.Lease == nil {
return dhcpapi.Lease{}, ErrNotServed
}
return *resp.Lease, nil
}

View file

@ -0,0 +1,364 @@
package dhcpclient
import (
"errors"
"io"
"log/slog"
"net"
"os"
"path/filepath"
"strings"
"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 testSubnet() dhcpapi.Subnet {
return dhcpapi.Subnet{
Network: "10.0.5.0/24",
InterfaceIP: "10.0.5.1",
VPCRoute: "10.0.0.0/16",
DefaultGateway: "10.0.5.254",
}
}
func testHost() dhcpapi.Host {
return dhcpapi.Host{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", VM: "vm-test", DefaultRoute: true}
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func serve(t *testing.T) (*Client, *dhcpd.Store, string) {
t.Helper()
dir := shortTempDir(t)
store := dhcpd.NewStore(filepath.Join(dir, "s.state"))
if err := store.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
socketPath := filepath.Join(dir, "s.sock")
server, err := dhcpapi.Listen(store, socketPath, discardLogger())
if err != nil {
t.Fatalf("Listen: %v", err)
}
go server.Serve()
t.Cleanup(func() { server.Close() })
return New(socketPath), store, socketPath
}
func TestListen_SocketIsOwnerOnly(t *testing.T) {
_, _, socketPath := serve(t)
info, err := os.Stat(socketPath)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Errorf("mode = %o, want 600: whoever reaches it rewrites the subnet addressing", got)
}
}
func TestListen_ReplacesAStaleSocketFile(t *testing.T) {
dir := shortTempDir(t)
socketPath := filepath.Join(dir, "stale.sock")
if err := os.WriteFile(socketPath, []byte("leftover"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
store := dhcpd.NewStore(filepath.Join(dir, "s.state"))
if err := store.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
server, err := dhcpapi.Listen(store, socketPath, discardLogger())
if err != nil {
t.Fatalf("a socket left by an unclean stop must not block startup: %v", err)
}
server.Close()
}
func TestSetSubnet_ReachesTheStore(t *testing.T) {
client, store, _ := serve(t)
if err := client.SetSubnet(testSubnet()); err != nil {
t.Fatalf("SetSubnet: %v", err)
}
if _, configured := store.Subnet(); !configured {
t.Error("the subnet configuration did not reach the store")
}
}
func TestSetSubnet_InvalidNetworkIsRefused(t *testing.T) {
client, _, _ := serve(t)
subnet := testSubnet()
subnet.Network = "10.0.5.0"
err := client.SetSubnet(subnet)
if err == nil {
t.Fatal("an invalid network must be refused")
}
if !strings.Contains(err.Error(), "refused") {
t.Errorf("error = %v, want the server refusal to surface", err)
}
}
func TestSetHost_IsIdempotent(t *testing.T) {
client, store, _ := serve(t)
for range 3 {
if err := client.SetHost(testHost()); err != nil {
t.Fatalf("SetHost: %v", err)
}
}
if got := len(store.Hosts()); got != 1 {
t.Errorf("hosts = %d, want 1: set-host replaces the entry for that mac", got)
}
}
func TestSetHost_InvalidMACIsRefused(t *testing.T) {
client, _, _ := serve(t)
host := testHost()
host.MAC = "nope"
if err := client.SetHost(host); err == nil {
t.Fatal("an invalid mac must be refused")
}
}
func TestDelHost_RemovesTheEntry(t *testing.T) {
client, store, _ := serve(t)
if err := client.SetHost(testHost()); err != nil {
t.Fatalf("SetHost: %v", err)
}
if err := client.DelHost(testHost().MAC); err != nil {
t.Fatalf("DelHost: %v", err)
}
if got := len(store.Hosts()); got != 0 {
t.Errorf("hosts = %d, want 0", got)
}
}
func TestDelHost_UnknownMACIsNotAnError(t *testing.T) {
client, _, _ := serve(t)
if err := client.DelHost("00:22:33:ff:ff:ff"); err != nil {
t.Errorf("deleting an absent entry must be idempotent, got %v", err)
}
}
func TestDelHost_InvalidMACIsRefused(t *testing.T) {
client, _, _ := serve(t)
if err := client.DelHost("not-a-mac"); err == nil {
t.Fatal("an invalid mac must be refused")
}
}
func TestGetState_ReturnsStateAndDigest(t *testing.T) {
client, _, _ := serve(t)
if err := client.SetSubnet(testSubnet()); err != nil {
t.Fatalf("SetSubnet: %v", err)
}
if err := client.SetHost(testHost()); err != nil {
t.Fatalf("SetHost: %v", err)
}
state, digest, err := client.GetState()
if err != nil {
t.Fatalf("GetState: %v", err)
}
if state.Subnet == nil || len(state.Hosts) != 1 {
t.Fatalf("state = %+v, want one subnet and one host", state)
}
if digest == "" {
t.Fatal("the digest is what the watchdog compares")
}
local, err := dhcpapi.Digest(state)
if err != nil {
t.Fatalf("Digest: %v", err)
}
if local != digest {
t.Errorf("digest recomputed locally = %s, server said %s: the canonical form diverges", local, digest)
}
}
func TestGetState_DigestFollowsTheState(t *testing.T) {
client, _, _ := serve(t)
if err := client.SetSubnet(testSubnet()); err != nil {
t.Fatalf("SetSubnet: %v", err)
}
_, before, err := client.GetState()
if err != nil {
t.Fatalf("GetState: %v", err)
}
if err := client.SetHost(testHost()); err != nil {
t.Fatalf("SetHost: %v", err)
}
_, after, err := client.GetState()
if err != nil {
t.Fatalf("GetState: %v", err)
}
if before == after {
t.Error("adding a reservation must change the digest")
}
}
func TestProbe_DescribesWhatWouldBeSent(t *testing.T) {
client, _, _ := serve(t)
if err := client.SetSubnet(testSubnet()); err != nil {
t.Fatalf("SetSubnet: %v", err)
}
if err := client.SetHost(testHost()); err != nil {
t.Fatalf("SetHost: %v", err)
}
lease, err := client.Probe("00:22:33:00:00:0A")
if err != nil {
t.Fatalf("Probe: %v", err)
}
if lease.IP != "10.0.5.10" {
t.Errorf("ip = %s, want 10.0.5.10", lease.IP)
}
if lease.Netmask != "255.255.255.0" {
t.Errorf("netmask = %s, want 255.255.255.0", lease.Netmask)
}
if lease.Router != "10.0.5.254" {
t.Errorf("router = %s, want 10.0.5.254", lease.Router)
}
if len(lease.DNS) != 2 {
t.Errorf("dns = %v, want two servers", lease.DNS)
}
if len(lease.Routes) != 3 {
t.Errorf("routes = %v, want metadata, vpc and default", lease.Routes)
}
if lease.LeaseSeconds != 43200 {
t.Errorf("lease = %ds, want 43200", lease.LeaseSeconds)
}
if lease.MAC != "00:22:33:00:00:0a" {
t.Errorf("mac = %s, want the normalized form", lease.MAC)
}
}
func TestProbe_UnservedMACIsReported(t *testing.T) {
client, _, _ := serve(t)
if err := client.SetSubnet(testSubnet()); err != nil {
t.Fatalf("SetSubnet: %v", err)
}
if _, err := client.Probe("00:22:33:ff:ff:ff"); !errors.Is(err, ErrNotServed) {
t.Fatalf("error = %v, want ErrNotServed", err)
}
}
func TestProbe_WithoutSubnetConfigurationIsRefused(t *testing.T) {
client, _, _ := serve(t)
if _, err := client.Probe("00:22:33:00:00:0a"); err == nil {
t.Fatal("probing an unconfigured subnet must be refused")
}
}
func TestCall_UnknownVerbIsRefused(t *testing.T) {
_, _, socketPath := serve(t)
conn, err := net.Dial("unix", socketPath)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer conn.Close()
if _, err := conn.Write([]byte(`{"verb":"drop-everything"}` + "\n")); err != nil {
t.Fatalf("Write: %v", err)
}
buf := make([]byte, 512)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read: %v", err)
}
if !strings.Contains(string(buf[:n]), "unknown verb") {
t.Errorf("reply = %s, want an unknown verb refusal", buf[:n])
}
}
func TestCall_MalformedLineIsRefusedWithoutClosingTheConnection(t *testing.T) {
_, _, socketPath := serve(t)
conn, err := net.Dial("unix", socketPath)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer conn.Close()
if _, err := conn.Write([]byte("{not json\n" + `{"verb":"get-state"}` + "\n")); err != nil {
t.Fatalf("Write: %v", err)
}
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read: %v", err)
}
if !strings.Contains(string(buf[:n]), "malformed request") {
t.Errorf("first reply = %s, want a malformed request refusal", buf[:n])
}
}
func TestCall_OnAnAbsentSocketFails(t *testing.T) {
client := New(filepath.Join(shortTempDir(t), "nothing.sock"))
if err := client.DelHost("00:22:33:00:00:0a"); err == nil {
t.Fatal("an absent socket must be reported")
}
}
func TestCall_HonoursItsTimeout(t *testing.T) {
socketPath := filepath.Join(shortTempDir(t), "mute.sock")
listener, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatalf("Listen: %v", err)
}
defer listener.Close()
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
time.Sleep(3 * time.Second)
}()
client := New(socketPath).WithTimeout(150 * time.Millisecond)
start := time.Now()
if err := client.DelHost("00:22:33:00:00:0a"); err == nil {
t.Fatal("a mute server must not hang the caller")
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("returned after %s, want the 150ms deadline to apply", elapsed)
}
}

View file

@ -0,0 +1,17 @@
package configuration
import "fmt"
const (
BackendDnsmasq = "dnsmasq"
BackendTwo = "two"
)
func ValidBackend(backend string) error {
switch backend {
case BackendDnsmasq, BackendTwo:
return nil
default:
return fmt.Errorf("unknown dhcp backend %q: expected %q or %q", backend, BackendDnsmasq, BackendTwo)
}
}

View file

@ -0,0 +1,82 @@
package configuration
import (
"os"
"path/filepath"
"testing"
)
func writeConfig(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "agent.yml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
return path
}
func TestValidBackend_AcceptsTheTwoKnownBackends(t *testing.T) {
for _, backend := range []string{BackendDnsmasq, BackendTwo} {
if err := ValidBackend(backend); err != nil {
t.Errorf("ValidBackend(%q) = %v, want nil", backend, err)
}
}
}
func TestValidBackend_RejectsAnythingElse(t *testing.T) {
for _, backend := range []string{"", "dhcpd", "DNSMASQ", "two "} {
if err := ValidBackend(backend); err == nil {
t.Errorf("ValidBackend(%q) = nil, want an error", backend)
}
}
}
func TestLoadConfig_DefaultsToDnsmasq(t *testing.T) {
path := writeConfig(t, "database:\n path: /tmp/two\n")
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.DHCP.Backend != BackendDnsmasq {
t.Errorf("backend = %q, want %q: a 0.1.0 config must keep behaving as before", cfg.DHCP.Backend, BackendDnsmasq)
}
}
func TestLoadConfig_ReadsTheTwoBackend(t *testing.T) {
path := writeConfig(t, "dhcp:\n backend: two\n")
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.DHCP.Backend != BackendTwo {
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)
}
}

View file

@ -1,6 +1,10 @@
package configuration
import (
"errors"
"fmt"
"os"
"github.com/spf13/viper"
)
@ -31,6 +35,9 @@ type Config struct {
Metadata struct {
RunDir string `mapstructure:"run_dir"`
} `mapstructure:"metadata"`
DHCP struct {
Backend string `mapstructure:"backend"`
} `mapstructure:"dhcp"`
Admin struct {
Enabled bool `mapstructure:"enabled"`
Address string `mapstructure:"address"`
@ -67,6 +74,7 @@ func LoadConfig(path string) (*Config, error) {
v.SetDefault("dispatcher.timeout_seconds", 300)
v.SetDefault("dispatcher.poll_seconds", 2)
v.SetDefault("metadata.run_dir", "/run/two/metadata")
v.SetDefault("dhcp.backend", BackendDnsmasq)
v.SetDefault("qemu.ovmf_code_path", "/usr/share/OVMF/OVMF_CODE.fd")
v.SetDefault("qemu.ovmf_vars_template", "/usr/share/OVMF/OVMF_VARS.fd")
v.SetDefault("qemu.uefi_vars_dir", "/run/two/vms/uefi")
@ -82,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 {

View file

@ -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()

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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")
}

View file

@ -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")
}
}

147
internal/dhcpbackend/two.go Normal file
View file

@ -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()
}

View file

@ -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
}

29
internal/dhcpd/dhcpd.go Normal file
View file

@ -0,0 +1,29 @@
package dhcpd
import (
"net"
"time"
)
const LeaseTime = 12 * time.Hour
func DNSServers() []net.IP {
return []net.IP{
net.IPv4(1, 1, 1, 1),
net.IPv4(8, 8, 8, 8),
}
}
type SubnetConfig struct {
Network *net.IPNet
InterfaceIP net.IP
VPCRoute *net.IPNet
DefaultGateway net.IP
}
type Host struct {
MAC net.HardwareAddr
IP net.IP
VM string
DefaultRoute bool
}

60
internal/dhcpd/engine.go Normal file
View file

@ -0,0 +1,60 @@
package dhcpd
import (
"net"
"github.com/insomniacslk/dhcp/dhcpv4"
)
func answerable(req *dhcpv4.DHCPv4) bool {
switch req.MessageType() {
case dhcpv4.MessageTypeDiscover, dhcpv4.MessageTypeRequest:
return true
default:
return false
}
}
func (s *Store) Handle(req *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, error) {
if req == nil {
return nil, ErrNoRequest
}
if !answerable(req) {
return nil, nil
}
subnet, configured := s.Subnet()
if !configured {
return nil, nil
}
host, known := s.Lookup(req.ClientHWAddr)
if !known {
return nil, nil
}
return BuildReply(subnet, host, req)
}
func (s *Store) Probe(mac net.HardwareAddr) (*dhcpv4.DHCPv4, error) {
if len(mac) == 0 {
return nil, ErrNoMAC
}
subnet, configured := s.Subnet()
if !configured {
return nil, ErrNotConfigured
}
host, known := s.Lookup(mac)
if !known {
return nil, nil
}
req, err := dhcpv4.New(dhcpv4.WithMessageType(dhcpv4.MessageTypeRequest), dhcpv4.WithHwAddr(mac))
if err != nil {
return nil, err
}
return BuildReply(subnet, host, req)
}

View file

@ -0,0 +1,195 @@
package dhcpd
import (
"errors"
"net"
"testing"
"github.com/insomniacslk/dhcp/dhcpv4"
)
func configuredStore(t *testing.T) *Store {
t.Helper()
s, _ := loadedStore(t)
if err := s.SetSubnet(fullConfig(t)); err != nil {
t.Fatalf("SetSubnet: %v", err)
}
if err := s.SetHost(testHost(t)); err != nil {
t.Fatalf("SetHost: %v", err)
}
return s
}
func TestHandle_KnownMACGetsAnOfferOnDiscover(t *testing.T) {
s := configuredStore(t)
reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")))
if err != nil {
t.Fatalf("Handle: %v", err)
}
if reply == nil {
t.Fatal("a known mac must be answered")
}
if reply.MessageType() != dhcpv4.MessageTypeOffer {
t.Errorf("message type = %s, want OFFER", reply.MessageType())
}
if !reply.YourIPAddr.Equal(net.ParseIP("10.0.5.10")) {
t.Errorf("yiaddr = %s, want the reserved 10.0.5.10", reply.YourIPAddr)
}
}
func TestHandle_UnknownMACIsAnsweredWithSilence(t *testing.T) {
s := configuredStore(t)
reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:ff:ff:ff")))
if err != nil {
t.Fatalf("an unknown mac is not an error: %v", err)
}
if reply != nil {
t.Error("an unknown mac must get no reply, not a NAK")
}
}
func TestHandle_UnconfiguredSubnetIsAnsweredWithSilence(t *testing.T) {
s, _ := loadedStore(t)
if err := s.SetHost(testHost(t)); err != nil {
t.Fatalf("SetHost: %v", err)
}
reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")))
if err != nil {
t.Fatalf("Handle: %v", err)
}
if reply != nil {
t.Error("without a subnet configuration the server must stay silent")
}
}
func TestHandle_ReleaseIsANoOp(t *testing.T) {
s := configuredStore(t)
reply, err := s.Handle(request(t, dhcpv4.MessageTypeRelease, mac(t, "00:22:33:00:00:0a")))
if err != nil {
t.Fatalf("a RELEASE is not an error: %v", err)
}
if reply != nil {
t.Error("a RELEASE must get no reply: reservations are static")
}
}
func TestHandle_DeclineIsANoOp(t *testing.T) {
s := configuredStore(t)
reply, err := s.Handle(request(t, dhcpv4.MessageTypeDecline, mac(t, "00:22:33:00:00:0a")))
if err != nil {
t.Fatalf("a DECLINE is not an error: %v", err)
}
if reply != nil {
t.Error("a DECLINE must get no reply: there is nothing to release")
}
}
func TestHandle_RequestIsAnsweredWithAnAck(t *testing.T) {
s := configuredStore(t)
reply, err := s.Handle(request(t, dhcpv4.MessageTypeRequest, mac(t, "00:22:33:00:00:0a")))
if err != nil {
t.Fatalf("Handle: %v", err)
}
if reply == nil || reply.MessageType() != dhcpv4.MessageTypeAck {
t.Fatalf("reply = %v, want an ACK", reply)
}
}
func TestHandle_NilRequestIsRejected(t *testing.T) {
s := configuredStore(t)
if _, err := s.Handle(nil); !errors.Is(err, ErrNoRequest) {
t.Fatalf("error = %v, want ErrNoRequest", err)
}
}
func TestHandle_DeletedHostStopsBeingAnswered(t *testing.T) {
s := configuredStore(t)
if err := s.DelHost(mac(t, "00:22:33:00:00:0a")); err != nil {
t.Fatalf("DelHost: %v", err)
}
reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")))
if err != nil {
t.Fatalf("Handle: %v", err)
}
if reply != nil {
t.Error("a deleted host must no longer be served")
}
}
func TestProbe_ReturnsWhatWouldBeSentToTheMAC(t *testing.T) {
s := configuredStore(t)
reply, err := s.Probe(mac(t, "00:22:33:00:00:0A"))
if err != nil {
t.Fatalf("Probe: %v", err)
}
if reply == nil {
t.Fatal("a known mac must be described")
}
if !reply.YourIPAddr.Equal(net.ParseIP("10.0.5.10")) {
t.Errorf("yiaddr = %s, want 10.0.5.10", reply.YourIPAddr)
}
if got := reply.ClasslessStaticRoute(); len(got) != 3 {
t.Errorf("routes = %s, want metadata, vpc and default", got)
}
}
func TestProbe_UnknownMACReturnsNothing(t *testing.T) {
s := configuredStore(t)
reply, err := s.Probe(mac(t, "00:22:33:ff:ff:ff"))
if err != nil {
t.Fatalf("Probe: %v", err)
}
if reply != nil {
t.Error("an unknown mac must describe no reply")
}
}
func TestProbe_WithoutSubnetConfigurationIsRejected(t *testing.T) {
s, _ := loadedStore(t)
if _, err := s.Probe(mac(t, "00:22:33:00:00:0a")); !errors.Is(err, ErrNotConfigured) {
t.Fatalf("error = %v, want ErrNotConfigured", err)
}
}
func TestProbe_EmptyMACIsRejected(t *testing.T) {
s := configuredStore(t)
if _, err := s.Probe(nil); !errors.Is(err, ErrNoMAC) {
t.Fatalf("error = %v, want ErrNoMAC", err)
}
}
func TestHandle_SecondaryInterfaceGetsNoDefaultRoute(t *testing.T) {
s := configuredStore(t)
h := testHost(t)
h.MAC = mac(t, "00:22:33:00:00:0b")
h.IP = net.ParseIP("10.0.5.11")
h.DefaultRoute = false
if err := s.SetHost(h); err != nil {
t.Fatalf("SetHost: %v", err)
}
reply, err := s.Handle(request(t, dhcpv4.MessageTypeRequest, mac(t, "00:22:33:00:00:0b")))
if err != nil {
t.Fatalf("Handle: %v", err)
}
if got := reply.Router(); len(got) != 0 {
t.Errorf("router option = %v, want none on a secondary interface", got)
}
for _, r := range reply.ClasslessStaticRoute() {
if ones, _ := r.Dest.Mask.Size(); ones == 0 {
t.Errorf("unexpected default route for a secondary interface: %s", reply.ClasslessStaticRoute())
}
}
}

View file

@ -0,0 +1,59 @@
package dhcpd
import (
"net"
"testing"
"github.com/insomniacslk/dhcp/dhcpv4"
)
func FuzzBuildReply(f *testing.F) {
mac, err := net.ParseMAC("00:22:33:00:00:0a")
if err != nil {
f.Fatalf("ParseMAC: %v", err)
}
for _, kind := range []dhcpv4.MessageType{
dhcpv4.MessageTypeDiscover,
dhcpv4.MessageTypeRequest,
dhcpv4.MessageTypeRelease,
dhcpv4.MessageTypeDecline,
} {
req, err := dhcpv4.New(dhcpv4.WithMessageType(kind), dhcpv4.WithHwAddr(mac))
if err != nil {
f.Fatalf("New request: %v", err)
}
f.Add(req.ToBytes())
}
_, network, err := net.ParseCIDR("10.0.5.0/24")
if err != nil {
f.Fatalf("ParseCIDR: %v", err)
}
_, vpcRoute, err := net.ParseCIDR("10.0.0.0/16")
if err != nil {
f.Fatalf("ParseCIDR: %v", err)
}
c := SubnetConfig{
Network: network,
InterfaceIP: net.ParseIP("10.0.5.1"),
VPCRoute: vpcRoute,
DefaultGateway: net.ParseIP("10.0.5.254"),
}
h := Host{MAC: mac, IP: net.ParseIP("10.0.5.10"), VM: "vm-fuzz", DefaultRoute: true}
f.Fuzz(func(t *testing.T, raw []byte) {
req, err := dhcpv4.FromBytes(raw)
if err != nil {
return
}
reply, err := BuildReply(c, h, req)
if err != nil {
return
}
if reply == nil {
t.Fatal("nil reply without an error")
}
reply.ToBytes()
})
}

View file

@ -0,0 +1,68 @@
package dhcpd
import (
"log/slog"
"net"
"github.com/insomniacslk/dhcp/dhcpv4"
)
const MaxDatagramBytes = 1500
var clientBroadcast = net.IPv4bcast
func replyTo(peer net.Addr) net.Addr {
udp, ok := peer.(*net.UDPAddr)
if !ok {
return peer
}
if udp.IP == nil || udp.IP.IsUnspecified() {
return &net.UDPAddr{IP: clientBroadcast, Port: udp.Port}
}
return udp
}
func (s *Store) serveDatagram(conn net.PacketConn, raw []byte, peer net.Addr, logger *slog.Logger) {
defer func() {
if r := recover(); r != nil {
logger.Error("dhcp datagram handling panicked", "peer", peer, "panic", r)
}
}()
req, err := dhcpv4.FromBytes(raw)
if err != nil {
logger.Debug("malformed dhcp datagram", "peer", peer, "error", err)
return
}
reply, err := s.Handle(req)
if err != nil {
logger.Error("building dhcp reply failed",
"peer", peer, "mac", req.ClientHWAddr, "type", req.MessageType(), "error", err)
return
}
if reply == nil {
logger.Debug("no reply for datagram", "mac", req.ClientHWAddr, "type", req.MessageType())
return
}
target := replyTo(peer)
if _, err := conn.WriteTo(reply.ToBytes(), target); err != nil {
logger.Error("sending dhcp reply failed", "target", target, "mac", req.ClientHWAddr, "error", err)
return
}
logger.Info("dhcp reply sent",
"mac", req.ClientHWAddr, "type", reply.MessageType(), "ip", reply.YourIPAddr, "target", target)
}
func (s *Store) Serve(conn net.PacketConn, logger *slog.Logger) error {
buf := make([]byte, MaxDatagramBytes)
for {
n, peer, err := conn.ReadFrom(buf)
if err != nil {
return err
}
s.serveDatagram(conn, buf[:n], peer, logger)
}
}

View file

@ -0,0 +1,206 @@
package dhcpd
import (
"io"
"log/slog"
"net"
"sync"
"testing"
"time"
"github.com/insomniacslk/dhcp/dhcpv4"
)
func discard() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func loopbackPair(t *testing.T) (*net.UDPConn, *net.UDPConn) {
t.Helper()
server, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatalf("ListenUDP server: %v", err)
}
t.Cleanup(func() { server.Close() })
client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatalf("ListenUDP client: %v", err)
}
t.Cleanup(func() { client.Close() })
return server, client
}
func exchange(t *testing.T, s *Store, raw []byte) *dhcpv4.DHCPv4 {
t.Helper()
server, client := loopbackPair(t)
go s.Serve(server, discard())
if _, err := client.WriteToUDP(raw, server.LocalAddr().(*net.UDPAddr)); err != nil {
t.Fatalf("WriteToUDP: %v", err)
}
if err := client.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil {
t.Fatalf("SetReadDeadline: %v", err)
}
buf := make([]byte, MaxDatagramBytes)
n, _, err := client.ReadFromUDP(buf)
if err != nil {
return nil
}
reply, err := dhcpv4.FromBytes(buf[:n])
if err != nil {
t.Fatalf("the reply must be a valid dhcp packet: %v", err)
}
return reply
}
func TestServe_AnswersAKnownMAC(t *testing.T) {
s := configuredStore(t)
reply := exchange(t, s, request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")).ToBytes())
if reply == nil {
t.Fatal("a known mac must be answered on the wire")
}
if reply.MessageType() != dhcpv4.MessageTypeOffer {
t.Errorf("message type = %s, want OFFER", reply.MessageType())
}
if !reply.YourIPAddr.Equal(net.ParseIP("10.0.5.10")) {
t.Errorf("yiaddr = %s, want 10.0.5.10", reply.YourIPAddr)
}
}
func TestServe_StaysSilentForAnUnknownMAC(t *testing.T) {
s := configuredStore(t)
if reply := exchange(t, s, request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:ff:ff:ff")).ToBytes()); reply != nil {
t.Errorf("an unknown mac must get nothing on the wire, got %s", reply.MessageType())
}
}
func TestServe_StaysSilentOnARelease(t *testing.T) {
s := configuredStore(t)
if reply := exchange(t, s, request(t, dhcpv4.MessageTypeRelease, mac(t, "00:22:33:00:00:0a")).ToBytes()); reply != nil {
t.Errorf("a RELEASE must get nothing on the wire, got %s", reply.MessageType())
}
}
func TestServe_SurvivesAMalformedDatagram(t *testing.T) {
s := configuredStore(t)
server, client := loopbackPair(t)
go s.Serve(server, discard())
target := server.LocalAddr().(*net.UDPAddr)
for _, garbage := range [][]byte{{}, {0x01}, make([]byte, 1200)} {
if _, err := client.WriteToUDP(garbage, target); err != nil {
t.Fatalf("WriteToUDP: %v", err)
}
}
if _, err := client.WriteToUDP(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")).ToBytes(), target); err != nil {
t.Fatalf("WriteToUDP: %v", err)
}
if err := client.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatalf("SetReadDeadline: %v", err)
}
buf := make([]byte, MaxDatagramBytes)
if _, _, err := client.ReadFromUDP(buf); err != nil {
t.Fatalf("the loop must survive garbage and keep serving: %v", err)
}
}
func TestServe_ReturnsWhenTheConnectionCloses(t *testing.T) {
s := configuredStore(t)
server, _ := loopbackPair(t)
done := make(chan error, 1)
go func() { done <- s.Serve(server, discard()) }()
server.Close()
select {
case err := <-done:
if err == nil {
t.Error("Serve must report why it stopped")
}
case <-time.After(time.Second):
t.Fatal("Serve did not return after the connection closed")
}
}
type explodingConn struct {
net.PacketConn
mu sync.Mutex
writes int
}
func (c *explodingConn) WriteTo(b []byte, addr net.Addr) (int, error) {
c.mu.Lock()
first := c.writes == 0
c.writes++
c.mu.Unlock()
if first {
panic("write exploded")
}
return c.PacketConn.WriteTo(b, addr)
}
func TestServe_SurvivesAPanicWhileHandlingADatagram(t *testing.T) {
s := configuredStore(t)
server, client := loopbackPair(t)
go s.Serve(&explodingConn{PacketConn: server}, discard())
target := server.LocalAddr().(*net.UDPAddr)
raw := request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")).ToBytes()
for range 2 {
if _, err := client.WriteToUDP(raw, target); err != nil {
t.Fatalf("WriteToUDP: %v", err)
}
time.Sleep(50 * time.Millisecond)
}
if err := client.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatalf("SetReadDeadline: %v", err)
}
buf := make([]byte, MaxDatagramBytes)
if _, _, err := client.ReadFromUDP(buf); err != nil {
t.Fatalf("a panic on one datagram must not kill the serving loop: %v", err)
}
}
func TestReplyTo_BroadcastsWhenTheClientHasNoAddress(t *testing.T) {
got := replyTo(&net.UDPAddr{IP: net.IPv4zero, Port: 68})
udp, ok := got.(*net.UDPAddr)
if !ok {
t.Fatalf("target = %T, want *net.UDPAddr", got)
}
if !udp.IP.Equal(net.IPv4bcast) {
t.Errorf("target = %s, want 255.255.255.255: the client cannot be reached by unicast yet", udp.IP)
}
if udp.Port != 68 {
t.Errorf("port = %d, want the client port to be kept", udp.Port)
}
}
func TestReplyTo_KeepsTheUnicastPeerWhenItHasAnAddress(t *testing.T) {
got := replyTo(&net.UDPAddr{IP: net.ParseIP("10.0.5.10"), Port: 68})
udp := got.(*net.UDPAddr)
if !udp.IP.Equal(net.ParseIP("10.0.5.10")) {
t.Errorf("target = %s, want the renewing client itself", udp.IP)
}
}
func TestReplyTo_BroadcastsWhenThePeerIPIsNil(t *testing.T) {
udp := replyTo(&net.UDPAddr{Port: 68}).(*net.UDPAddr)
if !udp.IP.Equal(net.IPv4bcast) {
t.Errorf("target = %s, want 255.255.255.255", udp.IP)
}
}

97
internal/dhcpd/reply.go Normal file
View file

@ -0,0 +1,97 @@
package dhcpd
import (
"errors"
"fmt"
"net"
"git.g3e.fr/syonad/two/internal/metadata"
"github.com/insomniacslk/dhcp/dhcpv4"
)
var (
ErrNoInterfaceIP = errors.New("interface ip is required: guests would have no route to the metadata server")
ErrNoNetwork = errors.New("subnet network is required")
ErrNoHostIP = errors.New("host ip is required")
ErrNoRequest = errors.New("request is nil")
)
func metadataRoute() *net.IPNet {
return &net.IPNet{
IP: net.ParseIP(metadata.ServiceIP).To4(),
Mask: net.CIDRMask(32, 32),
}
}
func defaultRoute() *net.IPNet {
return &net.IPNet{
IP: net.IPv4zero.To4(),
Mask: net.CIDRMask(0, 32),
}
}
func Routes(c SubnetConfig, h Host) (dhcpv4.Routes, error) {
if c.InterfaceIP == nil {
return nil, ErrNoInterfaceIP
}
routes := dhcpv4.Routes{{Dest: metadataRoute(), Router: c.InterfaceIP}}
if c.VPCRoute != nil {
routes = append(routes, &dhcpv4.Route{Dest: c.VPCRoute, Router: c.InterfaceIP})
}
if h.DefaultRoute && c.DefaultGateway != nil {
routes = append(routes, &dhcpv4.Route{Dest: defaultRoute(), Router: c.DefaultGateway})
}
return routes, nil
}
func replyType(req *dhcpv4.DHCPv4) (dhcpv4.MessageType, error) {
switch req.MessageType() {
case dhcpv4.MessageTypeDiscover:
return dhcpv4.MessageTypeOffer, nil
case dhcpv4.MessageTypeRequest:
return dhcpv4.MessageTypeAck, nil
default:
return 0, fmt.Errorf("no reply built for message type %s", req.MessageType())
}
}
func BuildReply(c SubnetConfig, h Host, req *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, error) {
if req == nil {
return nil, ErrNoRequest
}
if c.Network == nil {
return nil, ErrNoNetwork
}
if h.IP == nil {
return nil, ErrNoHostIP
}
kind, err := replyType(req)
if err != nil {
return nil, err
}
routes, err := Routes(c, h)
if err != nil {
return nil, err
}
mods := []dhcpv4.Modifier{
dhcpv4.WithMessageType(kind),
dhcpv4.WithServerIP(c.InterfaceIP),
dhcpv4.WithYourIP(h.IP),
dhcpv4.WithNetmask(c.Network.Mask),
dhcpv4.WithLeaseTime(uint32(LeaseTime.Seconds())),
dhcpv4.WithOption(dhcpv4.OptServerIdentifier(c.InterfaceIP)),
dhcpv4.WithOption(dhcpv4.OptDNS(DNSServers()...)),
dhcpv4.WithOption(dhcpv4.OptClasslessStaticRoute(routes...)),
}
if h.DefaultRoute && c.DefaultGateway != nil {
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptRouter(c.DefaultGateway)))
}
return dhcpv4.NewReplyFromRequest(req, mods...)
}

View file

@ -0,0 +1,339 @@
package dhcpd
import (
"bytes"
"errors"
"net"
"testing"
"git.g3e.fr/syonad/two/internal/metadata"
"github.com/insomniacslk/dhcp/dhcpv4"
)
func cidr(t *testing.T, s string) *net.IPNet {
t.Helper()
_, n, err := net.ParseCIDR(s)
if err != nil {
t.Fatalf("ParseCIDR(%q): %v", s, err)
}
return n
}
func testConfig(t *testing.T) SubnetConfig {
t.Helper()
return SubnetConfig{
Network: cidr(t, "10.0.5.0/24"),
InterfaceIP: net.ParseIP("10.0.5.1"),
}
}
func fullConfig(t *testing.T) SubnetConfig {
t.Helper()
c := testConfig(t)
c.VPCRoute = cidr(t, "10.0.0.0/16")
c.DefaultGateway = net.ParseIP("10.0.5.254")
return c
}
func mac(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
}
func testHost(t *testing.T) Host {
t.Helper()
return Host{MAC: mac(t, "00:22:33:00:00:0a"), IP: net.ParseIP("10.0.5.10"), VM: "vm-test", DefaultRoute: true}
}
func request(t *testing.T, kind dhcpv4.MessageType, mac net.HardwareAddr) *dhcpv4.DHCPv4 {
t.Helper()
req, err := dhcpv4.New(dhcpv4.WithMessageType(kind), dhcpv4.WithHwAddr(mac))
if err != nil {
t.Fatalf("New request: %v", err)
}
return req
}
func encodedRoute(t *testing.T, routes dhcpv4.Routes, dest string) []byte {
t.Helper()
for _, r := range routes {
if r.Dest.String() == dest {
return dhcpv4.Routes{r}.ToBytes()
}
}
t.Fatalf("no route to %s in %s", dest, routes)
return nil
}
func TestRoutes_AlwaysCarriesTheMetadataRoute(t *testing.T) {
c := testConfig(t)
routes, err := Routes(c, Host{IP: net.ParseIP("10.0.5.10")})
if err != nil {
t.Fatalf("Routes: %v", err)
}
if len(routes) != 1 {
t.Fatalf("expected the metadata route alone, got %s", routes)
}
if got := routes[0].Dest.String(); got != metadata.ServiceIP+"/32" {
t.Errorf("destination = %s, want %s/32", got, metadata.ServiceIP)
}
if !routes[0].Router.Equal(c.InterfaceIP) {
t.Errorf("next-hop = %s, want the subnet interface ip %s", routes[0].Router, c.InterfaceIP)
}
}
func TestRoutes_WithoutInterfaceIPIsRejected(t *testing.T) {
_, err := Routes(SubnetConfig{Network: cidr(t, "10.0.5.0/24")}, testHost(t))
if !errors.Is(err, ErrNoInterfaceIP) {
t.Fatalf("error = %v, want ErrNoInterfaceIP", err)
}
}
func TestRoutes_VPCRouteUsesTheInterfaceIPAsNextHop(t *testing.T) {
c := testConfig(t)
c.VPCRoute = cidr(t, "10.0.0.0/16")
c.DefaultGateway = net.ParseIP("192.0.2.1")
routes, err := Routes(c, testHost(t))
if err != nil {
t.Fatalf("Routes: %v", err)
}
for _, r := range routes {
if r.Dest.String() != "10.0.0.0/16" {
continue
}
if !r.Router.Equal(c.InterfaceIP) {
t.Fatalf("vpc route next-hop = %s, want %s", r.Router, c.InterfaceIP)
}
return
}
t.Fatalf("no vpc route in %s", routes)
}
func TestRoutes_NoDefaultRouteWithoutDefaultGateway(t *testing.T) {
routes, err := Routes(testConfig(t), testHost(t))
if err != nil {
t.Fatalf("Routes: %v", err)
}
for _, r := range routes {
if ones, _ := r.Dest.Mask.Size(); ones == 0 {
t.Fatalf("unexpected default route in %s", routes)
}
}
}
func TestRoutes_NoDefaultRouteWhenTheInterfaceDoesNotReceiveIt(t *testing.T) {
c := testConfig(t)
c.DefaultGateway = net.ParseIP("10.0.5.254")
h := testHost(t)
h.DefaultRoute = false
routes, err := Routes(c, h)
if err != nil {
t.Fatalf("Routes: %v", err)
}
for _, r := range routes {
if ones, _ := r.Dest.Mask.Size(); ones == 0 {
t.Fatalf("a secondary interface must not receive the default route, got %s", routes)
}
}
}
func TestRoutes_DefaultRouteEncodesZeroDestinationOctets(t *testing.T) {
c := testConfig(t)
c.DefaultGateway = net.ParseIP("10.0.5.254")
routes, err := Routes(c, testHost(t))
if err != nil {
t.Fatalf("Routes: %v", err)
}
want := []byte{0x00, 10, 0, 5, 254}
if got := encodedRoute(t, routes, "0.0.0.0/0"); !bytes.Equal(got, want) {
t.Errorf("default route encoding = % x, want % x", got, want)
}
}
func TestRoutes_UnalignedPrefixEncodesOnlyItsSignificantOctets(t *testing.T) {
c := testConfig(t)
c.VPCRoute = cidr(t, "10.16.0.0/12")
routes, err := Routes(c, testHost(t))
if err != nil {
t.Fatalf("Routes: %v", err)
}
want := []byte{0x0c, 10, 16, 10, 0, 5, 1}
if got := encodedRoute(t, routes, "10.16.0.0/12"); !bytes.Equal(got, want) {
t.Errorf("/12 encoding = % x, want % x", got, want)
}
}
func TestRoutes_MetadataRouteEncodesOnFourDestinationOctets(t *testing.T) {
routes, err := Routes(testConfig(t), testHost(t))
if err != nil {
t.Fatalf("Routes: %v", err)
}
want := []byte{0x20, 169, 254, 169, 254, 10, 0, 5, 1}
if got := encodedRoute(t, routes, metadata.ServiceIP+"/32"); !bytes.Equal(got, want) {
t.Errorf("metadata route encoding = % x, want % x", got, want)
}
}
func TestBuildReply_DiscoverIsAnsweredWithAnOffer(t *testing.T) {
h := testHost(t)
reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeDiscover, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if reply.MessageType() != dhcpv4.MessageTypeOffer {
t.Errorf("message type = %s, want OFFER", reply.MessageType())
}
}
func TestBuildReply_RequestIsAnsweredWithAnAck(t *testing.T) {
h := testHost(t)
reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if reply.MessageType() != dhcpv4.MessageTypeAck {
t.Errorf("message type = %s, want ACK", reply.MessageType())
}
}
func TestBuildReply_ReleaseGetsNoReply(t *testing.T) {
h := testHost(t)
if _, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRelease, h.MAC)); err == nil {
t.Fatal("a RELEASE must not produce a reply")
}
}
func TestBuildReply_DeclineGetsNoReply(t *testing.T) {
h := testHost(t)
if _, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeDecline, h.MAC)); err == nil {
t.Fatal("a DECLINE must not produce a reply")
}
}
func TestBuildReply_CarriesAddressMaskLeaseAndServerIdentifier(t *testing.T) {
c := testConfig(t)
h := testHost(t)
reply, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if !reply.YourIPAddr.Equal(h.IP) {
t.Errorf("yiaddr = %s, want %s", reply.YourIPAddr, h.IP)
}
if got := net.IP(reply.SubnetMask()).String(); got != net.IP(c.Network.Mask).String() {
t.Errorf("netmask = %s, want %s", got, net.IP(c.Network.Mask))
}
if got := reply.IPAddressLeaseTime(0); got != LeaseTime {
t.Errorf("lease time = %s, want %s", got, LeaseTime)
}
if got := reply.ServerIdentifier(); !got.Equal(c.InterfaceIP) {
t.Errorf("server identifier = %s, want %s", got, c.InterfaceIP)
}
if got := reply.DNS(); len(got) != 2 || !got[0].Equal(net.IPv4(1, 1, 1, 1)) || !got[1].Equal(net.IPv4(8, 8, 8, 8)) {
t.Errorf("dns = %v, want 1.1.1.1 and 8.8.8.8", got)
}
}
func TestBuildReply_NoRouterOptionWithoutDefaultRoute(t *testing.T) {
h := testHost(t)
reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if got := reply.Router(); len(got) != 0 {
t.Errorf("router option = %v, want none: the guest would use the server as its gateway", got)
}
}
func TestBuildReply_RouterOptionCarriesTheDefaultGateway(t *testing.T) {
c := testConfig(t)
c.DefaultGateway = net.ParseIP("10.0.5.254")
h := testHost(t)
reply, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
got := reply.Router()
if len(got) != 1 || !got[0].Equal(c.DefaultGateway) {
t.Errorf("router option = %v, want [%s]", got, c.DefaultGateway)
}
}
func TestBuildReply_SecondaryInterfaceGetsNoRouterOption(t *testing.T) {
c := testConfig(t)
c.DefaultGateway = net.ParseIP("10.0.5.254")
h := testHost(t)
h.DefaultRoute = false
reply, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if got := reply.Router(); len(got) != 0 {
t.Errorf("router option = %v, want none on a secondary interface", got)
}
}
func TestBuildReply_ClasslessStaticRouteIsPresent(t *testing.T) {
h := testHost(t)
reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if got := reply.ClasslessStaticRoute(); len(got) == 0 {
t.Fatal("option 121 missing: cloud-init would have no route to the metadata server")
}
}
func TestBuildReply_WithoutInterfaceIPIsRejected(t *testing.T) {
c := testConfig(t)
c.InterfaceIP = nil
h := testHost(t)
if _, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)); !errors.Is(err, ErrNoInterfaceIP) {
t.Fatalf("error = %v, want ErrNoInterfaceIP", err)
}
}
func TestBuildReply_WithoutNetworkIsRejected(t *testing.T) {
c := testConfig(t)
c.Network = nil
h := testHost(t)
if _, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)); !errors.Is(err, ErrNoNetwork) {
t.Fatalf("error = %v, want ErrNoNetwork", err)
}
}
func TestBuildReply_WithoutHostIPIsRejected(t *testing.T) {
h := testHost(t)
h.IP = nil
if _, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, testHost(t).MAC)); !errors.Is(err, ErrNoHostIP) {
t.Fatalf("error = %v, want ErrNoHostIP", err)
}
}
func TestBuildReply_NilRequestIsRejected(t *testing.T) {
if _, err := BuildReply(testConfig(t), testHost(t), nil); !errors.Is(err, ErrNoRequest) {
t.Fatalf("error = %v, want ErrNoRequest", err)
}
}

255
internal/dhcpd/store.go Normal file
View file

@ -0,0 +1,255 @@
package dhcpd
import (
"errors"
"fmt"
"net"
"sort"
"sync"
"git.g3e.fr/syonad/two/pkg/db/statefile"
)
var (
ErrNoMAC = errors.New("host mac is required")
ErrNotConfigured = errors.New("subnet is not configured")
)
type diskSubnet struct {
Network string `json:"network"`
InterfaceIP string `json:"interface_ip"`
VPCRoute string `json:"vpc_route,omitempty"`
DefaultGateway string `json:"default_gateway,omitempty"`
}
type diskHost struct {
MAC string `json:"mac"`
IP string `json:"ip"`
VM string `json:"vm,omitempty"`
DefaultRoute bool `json:"default_route"`
}
type diskState struct {
Subnet *diskSubnet `json:"subnet,omitempty"`
Hosts []diskHost `json:"hosts"`
}
type Store struct {
mu sync.RWMutex
file *statefile.File[diskState]
subnet SubnetConfig
configured bool
hosts map[string]Host
}
func NewStore(path string) *Store {
return &Store{
file: statefile.New[diskState](path),
hosts: make(map[string]Host),
}
}
func (s *Store) Path() string {
return s.file.Path()
}
func (s *Store) Load() error {
state, err := s.file.Load()
if err != nil {
return err
}
subnet := SubnetConfig{}
configured := false
if state.Subnet != nil {
parsed, err := subnetFromDisk(*state.Subnet)
if err != nil {
return err
}
subnet = parsed
configured = true
}
hosts := make(map[string]Host, len(state.Hosts))
for _, h := range state.Hosts {
host, err := hostFromDisk(h)
if err != nil {
return err
}
hosts[host.MAC.String()] = host
}
s.mu.Lock()
defer s.mu.Unlock()
s.subnet = subnet
s.configured = configured
s.hosts = hosts
return nil
}
func subnetFromDisk(d diskSubnet) (SubnetConfig, error) {
_, network, err := net.ParseCIDR(d.Network)
if err != nil {
return SubnetConfig{}, fmt.Errorf("invalid network %q: %w", d.Network, err)
}
interfaceIP := net.ParseIP(d.InterfaceIP)
if interfaceIP == nil {
return SubnetConfig{}, ErrNoInterfaceIP
}
c := SubnetConfig{Network: network, InterfaceIP: interfaceIP}
if d.VPCRoute != "" {
if _, c.VPCRoute, err = net.ParseCIDR(d.VPCRoute); err != nil {
return SubnetConfig{}, fmt.Errorf("invalid vpc route %q: %w", d.VPCRoute, err)
}
}
if d.DefaultGateway != "" {
if c.DefaultGateway = net.ParseIP(d.DefaultGateway); c.DefaultGateway == nil {
return SubnetConfig{}, fmt.Errorf("invalid default gateway %q", d.DefaultGateway)
}
}
return c, nil
}
func hostFromDisk(d diskHost) (Host, error) {
mac, err := net.ParseMAC(d.MAC)
if err != nil {
return Host{}, fmt.Errorf("invalid mac %q: %w", d.MAC, err)
}
ip := net.ParseIP(d.IP)
if ip == nil {
return Host{}, fmt.Errorf("invalid host ip %q", d.IP)
}
return Host{MAC: mac, IP: ip, VM: d.VM, DefaultRoute: d.DefaultRoute}, nil
}
func (s *Store) SetSubnet(c SubnetConfig) error {
if c.Network == nil {
return ErrNoNetwork
}
if c.InterfaceIP == nil {
return ErrNoInterfaceIP
}
s.mu.Lock()
defer s.mu.Unlock()
previous, wasConfigured := s.subnet, s.configured
s.subnet, s.configured = c, true
if err := s.persist(); err != nil {
s.subnet, s.configured = previous, wasConfigured
return err
}
return nil
}
func (s *Store) SetHost(h Host) error {
if len(h.MAC) == 0 {
return ErrNoMAC
}
if h.IP == nil {
return ErrNoHostIP
}
key := h.MAC.String()
s.mu.Lock()
defer s.mu.Unlock()
previous, existed := s.hosts[key]
s.hosts[key] = h
if err := s.persist(); err != nil {
if existed {
s.hosts[key] = previous
} else {
delete(s.hosts, key)
}
return err
}
return nil
}
func (s *Store) DelHost(mac net.HardwareAddr) error {
if len(mac) == 0 {
return ErrNoMAC
}
key := mac.String()
s.mu.Lock()
defer s.mu.Unlock()
previous, existed := s.hosts[key]
delete(s.hosts, key)
if err := s.persist(); err != nil {
if existed {
s.hosts[key] = previous
}
return err
}
return nil
}
func (s *Store) Lookup(mac net.HardwareAddr) (Host, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
h, ok := s.hosts[mac.String()]
return h, ok
}
func (s *Store) Subnet() (SubnetConfig, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.subnet, s.configured
}
func (s *Store) Hosts() []Host {
s.mu.RLock()
defer s.mu.RUnlock()
return s.sortedHosts()
}
func (s *Store) sortedHosts() []Host {
hosts := make([]Host, 0, len(s.hosts))
for _, h := range s.hosts {
hosts = append(hosts, h)
}
sort.Slice(hosts, func(i, j int) bool { return hosts[i].MAC.String() < hosts[j].MAC.String() })
return hosts
}
func (s *Store) persist() error {
state := diskState{Hosts: make([]diskHost, 0, len(s.hosts))}
if s.configured {
sub := diskSubnet{
Network: s.subnet.Network.String(),
InterfaceIP: s.subnet.InterfaceIP.String(),
}
if s.subnet.VPCRoute != nil {
sub.VPCRoute = s.subnet.VPCRoute.String()
}
if s.subnet.DefaultGateway != nil {
sub.DefaultGateway = s.subnet.DefaultGateway.String()
}
state.Subnet = &sub
}
for _, h := range s.sortedHosts() {
state.Hosts = append(state.Hosts, diskHost{
MAC: h.MAC.String(),
IP: h.IP.String(),
VM: h.VM,
DefaultRoute: h.DefaultRoute,
})
}
return s.file.Save(state)
}

View file

@ -0,0 +1,327 @@
package dhcpd
import (
"encoding/json"
"errors"
"net"
"os"
"path/filepath"
"testing"
)
func statePath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "vp-admin_br-000001.state")
}
func loadedStore(t *testing.T) (*Store, string) {
t.Helper()
path := statePath(t)
s := NewStore(path)
if err := s.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
return s, path
}
func TestStore_LoadCreatesTheStateFileWhenAbsent(t *testing.T) {
_, path := loadedStore(t)
info, err := os.Stat(path)
if err != nil {
t.Fatalf("the state file must be created on load: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Errorf("mode = %o, want 600: the file exposes tenant mac and ip", got)
}
}
func TestStore_LoadOnEmptyFileYieldsNoSubnet(t *testing.T) {
path := statePath(t)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
s := NewStore(path)
if err := s.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if _, configured := s.Subnet(); configured {
t.Error("an empty state file must not report a configured subnet")
}
}
func TestStore_LoadRejectsCorruptedState(t *testing.T) {
path := statePath(t)
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := NewStore(path).Load(); err == nil {
t.Fatal("a corrupted state file must be reported, not silently ignored")
}
}
func TestStore_LoadRejectsAnInvalidStoredMAC(t *testing.T) {
path := statePath(t)
raw := []byte(`{"hosts":[{"mac":"nope","ip":"10.0.5.10","default_route":true}]}`)
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := NewStore(path).Load(); err == nil {
t.Fatal("an unparseable stored mac must be reported")
}
}
func TestStore_SetSubnetIsPersisted(t *testing.T) {
s, path := loadedStore(t)
if err := s.SetSubnet(fullConfig(t)); err != nil {
t.Fatalf("SetSubnet: %v", err)
}
reloaded := NewStore(path)
if err := reloaded.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
c, configured := reloaded.Subnet()
if !configured {
t.Fatal("subnet lost across a restart")
}
if got := c.Network.String(); got != "10.0.5.0/24" {
t.Errorf("network = %s, want 10.0.5.0/24", got)
}
if !c.InterfaceIP.Equal(net.ParseIP("10.0.5.1")) {
t.Errorf("interface ip = %s, want 10.0.5.1", c.InterfaceIP)
}
if got := c.VPCRoute.String(); got != "10.0.0.0/16" {
t.Errorf("vpc route = %s, want 10.0.0.0/16", got)
}
if !c.DefaultGateway.Equal(net.ParseIP("10.0.5.254")) {
t.Errorf("default gateway = %s, want 10.0.5.254", c.DefaultGateway)
}
}
func TestStore_SetSubnetRejectsAMissingInterfaceIP(t *testing.T) {
s, _ := loadedStore(t)
c := fullConfig(t)
c.InterfaceIP = nil
if err := s.SetSubnet(c); !errors.Is(err, ErrNoInterfaceIP) {
t.Fatalf("error = %v, want ErrNoInterfaceIP", err)
}
}
func TestStore_SetSubnetRejectsAMissingNetwork(t *testing.T) {
s, _ := loadedStore(t)
c := fullConfig(t)
c.Network = nil
if err := s.SetSubnet(c); !errors.Is(err, ErrNoNetwork) {
t.Fatalf("error = %v, want ErrNoNetwork", err)
}
}
func TestStore_SetHostIsPersistedAndFound(t *testing.T) {
s, path := loadedStore(t)
if err := s.SetHost(testHost(t)); err != nil {
t.Fatalf("SetHost: %v", err)
}
reloaded := NewStore(path)
if err := reloaded.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
h, known := reloaded.Lookup(mac(t, "00:22:33:00:00:0a"))
if !known {
t.Fatal("host lost across a restart")
}
if !h.IP.Equal(net.ParseIP("10.0.5.10")) {
t.Errorf("ip = %s, want 10.0.5.10", h.IP)
}
if h.VM != "vm-test" {
t.Errorf("vm = %q, want vm-test", h.VM)
}
if !h.DefaultRoute {
t.Error("default route flag lost across a restart")
}
}
func TestStore_SetHostIsIdempotentOnTheSameMAC(t *testing.T) {
s, _ := loadedStore(t)
h := testHost(t)
if err := s.SetHost(h); err != nil {
t.Fatalf("SetHost: %v", err)
}
h.IP = net.ParseIP("10.0.5.11")
h.DefaultRoute = false
if err := s.SetHost(h); err != nil {
t.Fatalf("SetHost: %v", err)
}
hosts := s.Hosts()
if len(hosts) != 1 {
t.Fatalf("hosts = %d, want 1: the mac is the key", len(hosts))
}
if !hosts[0].IP.Equal(net.ParseIP("10.0.5.11")) || hosts[0].DefaultRoute {
t.Errorf("entry = %+v, want the second order to have replaced the first", hosts[0])
}
}
func TestStore_LookupNormalizesTheMACCase(t *testing.T) {
s, _ := loadedStore(t)
h := testHost(t)
h.MAC = mac(t, "00:22:33:AA:BB:CC")
if err := s.SetHost(h); err != nil {
t.Fatalf("SetHost: %v", err)
}
if _, known := s.Lookup(mac(t, "00:22:33:aa:bb:cc")); !known {
t.Error("an uppercase mac must be found in lowercase: the key would diverge")
}
}
func TestStore_LoadNormalizesTheMACCase(t *testing.T) {
path := statePath(t)
raw := []byte(`{"hosts":[{"mac":"00:22:33:AA:BB:CC","ip":"10.0.5.12","default_route":true}]}`)
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
s := NewStore(path)
if err := s.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if _, known := s.Lookup(mac(t, "00:22:33:aa:bb:cc")); !known {
t.Error("a reloaded uppercase mac must be keyed in lowercase: the host would silently stop being served")
}
}
func TestStore_SetHostRejectsAMissingMAC(t *testing.T) {
s, _ := loadedStore(t)
h := testHost(t)
h.MAC = nil
if err := s.SetHost(h); !errors.Is(err, ErrNoMAC) {
t.Fatalf("error = %v, want ErrNoMAC", err)
}
}
func TestStore_SetHostRejectsAMissingIP(t *testing.T) {
s, _ := loadedStore(t)
h := testHost(t)
h.IP = nil
if err := s.SetHost(h); !errors.Is(err, ErrNoHostIP) {
t.Fatalf("error = %v, want ErrNoHostIP", err)
}
}
func TestStore_DelHostRemovesTheEntry(t *testing.T) {
s, path := loadedStore(t)
if err := s.SetHost(testHost(t)); err != nil {
t.Fatalf("SetHost: %v", err)
}
if err := s.DelHost(mac(t, "00:22:33:00:00:0A")); err != nil {
t.Fatalf("DelHost: %v", err)
}
reloaded := NewStore(path)
if err := reloaded.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if got := len(reloaded.Hosts()); got != 0 {
t.Errorf("hosts = %d, want 0 after deletion", got)
}
}
func TestStore_DelHostOnAnUnknownMACIsNotAnError(t *testing.T) {
s, _ := loadedStore(t)
if err := s.DelHost(mac(t, "00:22:33:ff:ff:ff")); err != nil {
t.Errorf("deleting an absent entry must be idempotent, got %v", err)
}
}
func TestStore_DelHostRejectsAnEmptyMAC(t *testing.T) {
s, _ := loadedStore(t)
if err := s.DelHost(nil); !errors.Is(err, ErrNoMAC) {
t.Fatalf("error = %v, want ErrNoMAC", err)
}
}
func TestStore_HostsAreSortedByMAC(t *testing.T) {
s, _ := loadedStore(t)
for _, m := range []string{"00:22:33:00:00:0c", "00:22:33:00:00:0a", "00:22:33:00:00:0b"} {
h := testHost(t)
h.MAC = mac(t, m)
if err := s.SetHost(h); err != nil {
t.Fatalf("SetHost: %v", err)
}
}
hosts := s.Hosts()
for i := 1; i < len(hosts); i++ {
if hosts[i-1].MAC.String() >= hosts[i].MAC.String() {
t.Fatalf("hosts are not sorted: %v", hosts)
}
}
}
func TestStore_PersistedStateIsSortedOnDisk(t *testing.T) {
s, path := loadedStore(t)
for _, m := range []string{"00:22:33:00:00:0c", "00:22:33:00:00:0a"} {
h := testHost(t)
h.MAC = mac(t, m)
if err := s.SetHost(h); err != nil {
t.Fatalf("SetHost: %v", err)
}
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
var state struct {
Hosts []struct {
MAC string `json:"mac"`
} `json:"hosts"`
}
if err := json.Unmarshal(raw, &state); err != nil {
t.Fatalf("the state file must stay parseable: %v", err)
}
if len(state.Hosts) != 2 || state.Hosts[0].MAC != "00:22:33:00:00:0a" {
t.Errorf("hosts on disk = %+v, want sorted by mac", state.Hosts)
}
}
func TestStore_PersistRestoresTheModeAfterAnExternalChmod(t *testing.T) {
s, path := loadedStore(t)
if err := os.Chmod(path, 0o644); err != nil {
t.Fatalf("Chmod: %v", err)
}
if err := s.SetHost(testHost(t)); err != nil {
t.Fatalf("SetHost: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Errorf("mode = %o, want 600: each write must replace the file, not edit it in place", got)
}
}
func TestStore_PathReportsTheStateFile(t *testing.T) {
s, path := loadedStore(t)
if got := s.Path(); got != path {
t.Errorf("Path = %s, want %s", got, path)
}
}

View file

@ -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())

View file

@ -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,
})
}

View file

@ -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 {

View file

@ -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

View file

@ -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 {

10
internal/vm/dhcp.go Normal file
View file

@ -0,0 +1,10 @@
package vm
import (
"git.g3e.fr/syonad/two/internal/dhcpbackend"
)
type subnetReservations struct {
subnet dhcpbackend.Subnet
reservations []dhcpbackend.Reservation
}

122
internal/vm/dhcp_test.go Normal file
View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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) {

View file

@ -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)
}

View file

@ -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
}

View file

@ -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 {

View file

@ -0,0 +1,96 @@
package statefile
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
const (
FileMode = 0o600
DirMode = 0o700
)
type File[T any] struct {
path string
}
func New[T any](path string) *File[T] {
return &File[T]{path: path}
}
func (f *File[T]) Path() string {
return f.path
}
func (f *File[T]) Load() (T, error) {
var value T
raw, err := os.ReadFile(f.path)
if errors.Is(err, os.ErrNotExist) {
return value, f.Save(value)
}
if err != nil {
return value, fmt.Errorf("read %s: %w", f.path, err)
}
if len(raw) == 0 {
return value, nil
}
if err := json.Unmarshal(raw, &value); err != nil {
var zero T
return zero, fmt.Errorf("parse %s: %w", f.path, err)
}
return value, nil
}
func (f *File[T]) Save(value T) error {
raw, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("encode state for %s: %w", f.path, err)
}
dir := filepath.Dir(f.path)
if err := os.MkdirAll(dir, DirMode); err != nil {
return fmt.Errorf("create %s: %w", dir, err)
}
tmp, err := os.CreateTemp(dir, filepath.Base(f.path)+".tmp")
if err != nil {
return fmt.Errorf("create temp state in %s: %w", dir, err)
}
defer os.Remove(tmp.Name())
if err := tmp.Chmod(FileMode); err != nil {
tmp.Close()
return fmt.Errorf("chmod %s: %w", tmp.Name(), err)
}
if _, err := tmp.Write(raw); err != nil {
tmp.Close()
return fmt.Errorf("write %s: %w", tmp.Name(), err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("sync %s: %w", tmp.Name(), err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close %s: %w", tmp.Name(), err)
}
if err := os.Rename(tmp.Name(), f.path); err != nil {
return fmt.Errorf("rename %s to %s: %w", tmp.Name(), f.path, err)
}
return nil
}
func (f *File[T]) Remove() error {
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
}

View file

@ -0,0 +1,189 @@
package statefile
import (
"os"
"path/filepath"
"testing"
)
type payload struct {
Name string `json:"name"`
Items []string `json:"items"`
}
func path(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "component.state")
}
func TestLoad_CreatesTheFileWhenAbsent(t *testing.T) {
p := path(t)
f := New[payload](p)
value, err := f.Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if value.Name != "" || len(value.Items) != 0 {
t.Errorf("value = %+v, want the zero value", value)
}
info, err := os.Stat(p)
if err != nil {
t.Fatalf("the file must be created on load: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Errorf("mode = %o, want 600", got)
}
}
func TestLoad_CreatesTheDirectoryWhenAbsent(t *testing.T) {
p := filepath.Join(t.TempDir(), "nested", "deeper", "component.state")
if _, err := New[payload](p).Load(); err != nil {
t.Fatalf("Load: %v", err)
}
info, err := os.Stat(filepath.Dir(p))
if err != nil {
t.Fatalf("the directory must be created: %v", err)
}
if got := info.Mode().Perm(); got != 0o700 {
t.Errorf("directory mode = %o, want 700", got)
}
}
func TestLoad_EmptyFileYieldsTheZeroValue(t *testing.T) {
p := path(t)
if err := os.WriteFile(p, nil, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
value, err := New[payload](p).Load()
if err != nil {
t.Fatalf("an empty file is a valid starting point: %v", err)
}
if value.Name != "" {
t.Errorf("value = %+v, want the zero value", value)
}
}
func TestLoad_CorruptedFileIsReported(t *testing.T) {
p := path(t)
if err := os.WriteFile(p, []byte("{not json"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if _, err := New[payload](p).Load(); err == nil {
t.Fatal("a corrupted file must be reported, not silently ignored")
}
}
func TestSave_RoundTrips(t *testing.T) {
p := path(t)
f := New[payload](p)
want := payload{Name: "vp-admin_br-000001", Items: []string{"a", "b"}}
if err := f.Save(want); err != nil {
t.Fatalf("Save: %v", err)
}
got, err := New[payload](p).Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.Name != want.Name || len(got.Items) != len(want.Items) {
t.Errorf("value = %+v, want %+v", got, want)
}
}
func TestSave_RestoresTheModeAfterAnExternalChmod(t *testing.T) {
p := path(t)
f := New[payload](p)
if _, err := f.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if err := os.Chmod(p, 0o644); err != nil {
t.Fatalf("Chmod: %v", err)
}
if err := f.Save(payload{Name: "x"}); err != nil {
t.Fatalf("Save: %v", err)
}
info, err := os.Stat(p)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Errorf("mode = %o, want 600: each save must replace the file, not edit it in place", got)
}
}
func TestSave_LeavesNoTemporaryFileBehind(t *testing.T) {
p := path(t)
f := New[payload](p)
for range 3 {
if err := f.Save(payload{Name: "x"}); err != nil {
t.Fatalf("Save: %v", err)
}
}
entries, err := os.ReadDir(filepath.Dir(p))
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
if len(entries) != 1 {
t.Errorf("directory holds %d entries, want only the state file: %v", len(entries), entries)
}
}
func TestSave_DoesNotTruncateOnEncodingFailure(t *testing.T) {
p := path(t)
good := New[payload](p)
if err := good.Save(payload{Name: "kept"}); err != nil {
t.Fatalf("Save: %v", err)
}
broken := New[chan int](p)
if err := broken.Save(make(chan int)); err == nil {
t.Fatal("an unencodable value must be reported")
}
got, err := good.Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.Name != "kept" {
t.Errorf("value = %+v, want the previous state untouched", got)
}
}
func TestRemove_DeletesTheFile(t *testing.T) {
p := path(t)
f := New[payload](p)
if _, err := f.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if err := f.Remove(); err != nil {
t.Fatalf("Remove: %v", err)
}
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("the file must be gone, got %v", err)
}
}
func TestRemove_OnAnAbsentFileIsNotAnError(t *testing.T) {
if err := New[payload](path(t)).Remove(); err != nil {
t.Errorf("removing an absent file must be idempotent, got %v", err)
}
}
func TestPath_ReportsTheFileItOwns(t *testing.T) {
p := path(t)
if got := New[payload](p).Path(); got != p {
t.Errorf("Path = %s, want %s", got, p)
}
}

87
release_notes/0.2.0.md Normal file
View file

@ -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@<netns>_<bridge>`. 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/<netns>_<bridge>.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/<netns>_<bridge>.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é.

View file

@ -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 | | |

View file

@ -116,7 +116,7 @@ start_services () {
profile_units () {
case "${1}" in
kvm) echo "agent.service dnsmasq@.service metadata@.service" ;;
kvm) echo "agent.service dnsmasq@.service dhcp@.service metadata@.service" ;;
intel) echo "" ;;
*) return 1 ;;
esac
@ -124,7 +124,7 @@ profile_units () {
profile_binaries () {
case "${1}" in
kvm) echo "agent metadata run-dnsmasq-in-netns.sh" ;;
kvm) echo "agent metadata dhcp run-dnsmasq-in-netns.sh run-dhcp-in-netns.sh" ;;
intel) echo "" ;;
*) return 1 ;;
esac

View file

@ -0,0 +1,25 @@
#!/bin/bash
set -e
# Expects one argument: netns_bridge (e.g. vpc-00003_br-00002 or vpc1_br0)
# The netns is only needed here, to enter it. The server is handed its bridge
# and its two file paths, and knows nothing of the namespace it runs in.
arg="$1"
NETNS="${arg%%_*}"
BRIDGE="${arg#*_}"
RUN_DIR="/run/two/dhcp"
if [[ "${NETNS}" == "${arg}" || -z "${NETNS}" || -z "${BRIDGE}" ]]
then
echo "instance ${arg} is not <netns>_<bridge>" >&2
exit 1
fi
echo "start dhcp ${arg}"
exec ip netns exec "${NETNS}" \
/opt/two/bin/dhcp \
-conf /etc/two/agent.yml \
-interface "${BRIDGE}" \
-state "${RUN_DIR}/${arg}.state" \
-socket "${RUN_DIR}/${arg}.sock"

10
systemd/dhcp@.service Normal file
View file

@ -0,0 +1,10 @@
[Unit]
Description=two dhcp server in netns %i
After=network.target
[Service]
Type=simple
ExecStart=/opt/two/bin/run-dhcp-in-netns.sh %i
[Install]
WantedBy=multi-user.target