diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index d80e151..9cd29e6 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-go@v5 with: - go-version: "1.25.14" + go-version: "1.21" - name: Build du projet run: | echo "Building for ${BINARI}/${GOOS}/${GOARCH} (release: ${RELEASE_CIBLE})" diff --git a/.forgejo/workflows/docs.yml b/.forgejo/workflows/docs.yml deleted file mode 100644 index e8b7b07..0000000 --- a/.forgejo/workflows/docs.yml +++ /dev/null @@ -1,168 +0,0 @@ -name: Documentation - -# Un tag de release ajoute une version au site : la publication est donc -# déclenchée par les deux, sans filtre de chemin sur les tags — c'est le tag -# lui-même qui est la nouveauté, pas un fichier modifié. -on: - push: - branches: - - main - tags: - - '[0-9]*.[0-9]*.[0-9]*' - workflow_dispatch: - -# Deux publications simultanées se pousseraient l'une sur l'autre. -concurrency: - group: pages - cancel-in-progress: false - -jobs: - publish: - runs-on: docker - env: - TOKEN: ${{ secrets.RELEASE }} - SITE_DIR: /tmp/site - # Préfixe de chemin sous lequel le site est servi. Vide = racine du - # domaine, ce qui couvre le cas courant et un serveur de test local. - # À renseigner (par exemple /two) seulement si les pages sont publiées - # sous un sous-chemin. Aucun nom d'hôte ici : les liens du menu de - # version sont relatifs à l'origine, donc le site fonctionne à - # l'identique en local et en production. - DOCS_BASE_PATH: '' - steps: - # fetch-depth: 0 — les tags et leur contenu sont nécessaires : chaque - # version est construite depuis son propre ref. - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Installer Sphinx - run: | - apt-get update - apt-get install -y python3 python3-venv git - python3 -m venv /tmp/venv - /tmp/venv/bin/pip install --quiet --upgrade pip - /tmp/venv/bin/pip install --quiet -r docs/requirements.txt - - # Les versions publiables : main, plus les tags finaux qui contiennent - # déjà un répertoire docs/. 0.1.0 est antérieure à la documentation et - # n'est donc pas constructible — le filtre l'écarte de lui-même, sans - # liste à maintenir. - - name: Choisir les versions à publier - run: | - versions="" - for tag in $(git tag --sort=-v:refname) - do - case "${tag}" in *rc*) continue ;; esac - if git ls-tree --name-only "${tag}" | grep -qx docs - then - versions="${versions} ${tag}" - else - echo "ignoré : ${tag} n'a pas de docs/" - fi - done - echo "VERSIONS=${versions# }" >> "${GITHUB_ENV}" - echo "versions retenues : main${versions}" - - # main est construite en premier et son échec est fatal : la doc courante - # doit toujours partir. L'échec d'une version figée est signalé mais ne - # bloque pas la publication — une vieille version qui ne se reconstruit - # plus ne doit pas empêcher de publier la doc du jour. - - name: Construire chaque version - run: | - build () { - local ref="$1" src="$2" - DOCS_VERSION="${ref}" \ - /tmp/venv/bin/sphinx-build -b html -W --keep-going \ - -d "/tmp/doctrees-${ref}" "${src}/docs" "${SITE_DIR}/${ref}" - } - - build main . - - for version in ${VERSIONS} - do - rm -rf "/tmp/src-${version}" - git worktree add --quiet --detach "/tmp/src-${version}" "${version}" - if build "${version}" "/tmp/src-${version}" - then - echo "construit : ${version}" - else - echo "::warning::la version ${version} ne se construit plus, elle est absente du site" - rm -rf "${SITE_DIR}/${version}" - fi - git worktree remove --force "/tmp/src-${version}" - done - - # Les liens du menu de version sont relatifs à l'origine : le thème les - # concatène au chemin de la page courante avant de les poser en href, si - # bien qu'un chemin relatif y serait résolu depuis la page et casserait - # selon sa profondeur. Une barre initiale les ancre à la racine du site, - # sans jamais nommer d'hôte. - - name: Assembler la racine du site - run: | - preferred="$(echo ${VERSIONS} | tr ' ' '\n' | head -1)" - [ -n "${preferred}" ] || preferred="main" - - { - echo '[' - echo ' {"name": "dev (main)", "version": "main", "url": "'"${DOCS_BASE_PATH}"'/main/"},' - first=1 - for version in ${VERSIONS} - do - [ -d "${SITE_DIR}/${version}" ] || continue - [ ${first} -eq 1 ] && suffix=', "preferred": true' || suffix='' - first=0 - echo ' {"name": "'"${version}"'", "version": "'"${version}"'", "url": "'"${DOCS_BASE_PATH}"'/'"${version}"'/"'"${suffix}"'},' - done - } | sed '$ s/,$//' > "${SITE_DIR}/switcher.json" - echo ']' >> "${SITE_DIR}/switcher.json" - - python3 -c "import json,sys; json.load(open('${SITE_DIR}/switcher.json'))" - cat "${SITE_DIR}/switcher.json" - - # La racine ne sert qu'à rediriger : le contenu vit dans les - # sous-répertoires de version. - cat > "${SITE_DIR}/index.html" < - - - - two — documentation - - -

Documentation de two

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