diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 9cd29e6..d80e151 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.21" + go-version: "1.25.14" - name: Build du projet run: | echo "Building for ${BINARI}/${GOOS}/${GOARCH} (release: ${RELEASE_CIBLE})" diff --git a/.forgejo/workflows/release-pipeline.yml b/.forgejo/workflows/release-pipeline.yml index 53e2ad3..b4e018a 100644 --- a/.forgejo/workflows/release-pipeline.yml +++ b/.forgejo/workflows/release-pipeline.yml @@ -51,6 +51,7 @@ jobs: binaries: - metadata - agent + - dhcp uses: ./.forgejo/workflows/build.yml with: tag: ${{ needs.set-release-target.outputs.release_cible }} @@ -69,10 +70,14 @@ jobs: include: - path: scripts/run-dnsmasq-in-netns.sh name: run-dnsmasq-in-netns.sh + - path: scripts/run-dhcp-in-netns.sh + name: run-dhcp-in-netns.sh - path: systemd/agent.service name: agent.service - path: systemd/dnsmasq@.service name: dnsmasq@.service + - path: systemd/dhcp@.service + name: dhcp@.service - path: systemd/metadata@.service name: metadata@.service steps: diff --git a/cmd/dhcp/main.go b/cmd/dhcp/main.go new file mode 100644 index 0000000..ab5b7fb --- /dev/null +++ b/cmd/dhcp/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "flag" + "fmt" + "net" + "os" + + dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp" + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/internal/dhcpd" + "git.g3e.fr/syonad/two/pkg/logger" + + "github.com/insomniacslk/dhcp/dhcpv4" + "github.com/insomniacslk/dhcp/dhcpv4/server4" +) + +var ( + confFile = flag.String("conf", "/etc/two/agent.yml", "configuration file") + iface = flag.String("interface", "", "bridge to serve, already present in the current network namespace") + statePath = flag.String("state", "", "state file owned by this process") + socketPath = flag.String("socket", "", "control socket the agent talks to") +) + +func main() { + flag.Parse() + + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "dhcp: %v\n", err) + os.Exit(1) + } +} + +func run() error { + for name, value := range map[string]string{ + "-interface": *iface, + "-state": *statePath, + "-socket": *socketPath, + } { + if value == "" { + return fmt.Errorf("%s is required", name) + } + } + + cfg, err := configuration.LoadConfig(*confFile) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + log := logger.New(cfg.Logger.Level, cfg.Logger.Debug).With("bridge", *iface) + + store := dhcpd.NewStore(*statePath) + if err := store.Load(); err != nil { + return fmt.Errorf("load state: %w", err) + } + + control, err := dhcpapi.Listen(store, *socketPath, log) + if err != nil { + return fmt.Errorf("listen on the control socket: %w", err) + } + defer control.Close() + + go func() { + if err := control.Serve(); err != nil { + log.Error("control socket stopped", "error", err) + } + }() + + conn, err := server4.NewIPv4UDPConn(*iface, &net.UDPAddr{Port: dhcpv4.ServerPort}) + if err != nil { + return fmt.Errorf("bind udp/%d on %s: %w", dhcpv4.ServerPort, *iface, err) + } + defer conn.Close() + + log.Info("dhcp server started", "state", store.Path(), "socket", control.Addr()) + + return store.Serve(conn, log) +} diff --git a/conf/agent/config.exemple.yml b/conf/agent/config.exemple.yml index 72e88f8..de0f6ae 100644 --- a/conf/agent/config.exemple.yml +++ b/conf/agent/config.exemple.yml @@ -39,6 +39,16 @@ interfaces: metadata: run_dir: "/run/two/metadata" +# DHCP backend used for the subnets created by this agent. +# dnsmasq : dnsmasq@ instances driven by generated config files +# two : the built-in dhcp binary, driven over a unix socket +# Switching backends is a manual operation: drain the hypervisor, change this +# value, restart the agent. There is no hot migration. +# The per-subnet control socket and state file live in /run/two/dhcp, which is +# not configurable: the wrapper script hardcodes it too. +dhcp: + backend: dnsmasq + # QEMU runtime paths qemu: # UEFI firmware (requires apt install ovmf on Debian/Ubuntu) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..da61cbc --- /dev/null +++ b/docs/README.md @@ -0,0 +1,37 @@ +# Documentation `two` + +Construction locale, avec rechargement automatique : + +```bash +TMPDIR=$(mktemp -d) +python3 -m venv "${TMPDIR}/venv" +source "${TMPDIR}/venv/bin/activate" +pip install --upgrade pip +pip install -r requirements.txt +sphinx-autobuild . "${TMPDIR}/build" +``` + +Construction simple : + +```bash +sphinx-build -b html . _build/html +``` + +La référence de l'API est générée depuis `../api/agent.yaml` : c'est la source unique du +contrat, la documentation ne la recopie pas. + +## Schémas + +Les schémas simples sont en mermaid, directement dans les pages. Les schémas travaillés sont des +SVG dans `schemas/`, en deux variantes : + +``` +schemas/.svg thème clair → .. figure:: /schemas/.svg :class: only-light +schemas/-dark.svg thème sombre → .. figure:: /schemas/-dark.svg :class: only-dark +``` + +`schemas/` est dans `exclude_patterns` : Sphinx n'y cherche pas de pages, mais copie les fichiers +référencés par une directive `figure` ou `image`. + +Un export draw.io se dépose tel quel sous ce nom. Exporter en **SVG éditable** (« Include a copy +of my diagram ») pour pouvoir rouvrir le fichier dans draw.io ensuite. diff --git a/docs/architecture/contraintes.rst b/docs/architecture/contraintes.rst new file mode 100644 index 0000000..698cf4a --- /dev/null +++ b/docs/architecture/contraintes.rst @@ -0,0 +1,86 @@ +Invariants et pièges +==================== + +Contraintes découvertes en production ou en corrigeant des bugs. Les enfreindre casse quelque +chose qui fonctionne, souvent en silence. + +.. note:: + + Cette page reprend la section « Invariants et pièges » de ``CLAUDE.md``, qui reste la + référence de développement et fait foi en cas d'écart. + +Réseau +------ + +* **Ne pas retirer la route ``/32`` vers ``169.254.169.254``** de l'option 121, même quand elle + paraît redondante avec la route par défaut. La DNAT vers le serveur de metadata est posée dans + le netns du VPC en ``PREROUTING`` : le paquet n'y est traité en L3 que si son next-hop est + ``interface_ip``. Avec un autre next-hop, la trame est commutée en L2 sans traverser + ``PREROUTING``, et le provisionnement cloud-init échoue. +* **RFC 3442** : un client qui lit l'option 121 **ignore l'option 3**. Toute route par défaut + doit donc figurer dans la 121 ; l'option 3 ne sert que les clients qui n'implémentent pas la + 121. +* **La route vers le CIDR du VPC garde ``interface_ip`` comme next-hop** dans tous les modes sauf + ``bridge`` : sur un subnet à IP publique, le trafic interne ne doit pas sortir par la gateway + publique. +* ``169.254.169.254`` est centralisé dans ``metadata.ServiceIP`` — ne pas le réécrire en dur. + +QEMU et VM +---------- + +* **Un seul disque ``vdX`` par VM** ; les disques additionnels passent par le SCSI (``sdX``). La + carte PCI en dépend : NIC en ``0x03``, contrôleur SCSI en ``0x1e``, virtio-blk en ``0x1f``. +* ``bus=pci.0`` est explicite sur les trois ``-device`` : un passage de la machine en **q35** + casserait le démarrage (``Bus 'pci.0' not found``). +* QEMU est lancé par ``systemd-run --scope``, **jamais** en unit transitoire : le scope est + exécuté par le processus appelant et hérite du netns posé par ``netns.Call``. Une unit + transitoire, forkée par PID 1, démarrerait dans le netns racine et ne verrait pas le tap. +* L'arrêt d'une VM ne touche **jamais** aux fichiers disque. En revanche, un ``quit`` brutal est + envoyé à l'expiration de ``dispatcher.timeout_seconds``. + +Arrêt de l'agent +---------------- + +* Ordre imposé : serveurs HTTP → drainage des workers → fermeture de la base. L'inverser crée une + course. +* Budget d'arrêt dépassé ⇒ **la base n'est pas fermée** : fermer Badger sous un écrivain + concurrent est pire qu'un rejeu du journal au démarrage suivant. +* Jamais de ``log.Fatal`` dans une goroutine : ``os.Exit`` n'exécute aucun ``defer``. + +cloud-init +---------- + +* ``network-config.tmpl`` cible ``eth0`` alors que les guests sont en ``ens3`` : il ne s'applique + donc à rien, et le réseau vient du DHCP. **Ne pas le « corriger » ni le supprimer** — le rendre + opérant ferait remplacer par cloud-init la configuration réseau de l'image sur toutes les VM. +* Un document fourni par l'appelant est servi **verbatim** ; un document absent retombe sur le + template ; un document explicitement vide est servi vide. Les trois cas sont distincts. +* ``metadata.password`` est un **hash**, pas un mot de passe en clair. +* ``instance-id`` vaut le nom de la VM : recréer une VM du même nom sur le même disque fait que + cloud-init la reconnaît et **n'applique pas** le user-data. + +Configuration +------------- + +* Le chargement se fait par **viper** : tags ``mapstructure``, jamais ``yaml``. +* Un chemin configurable se propage par les signatures de fonction, jamais par une variable ou un + setter de paquet. + +Sécurité connue et acceptée +--------------------------- + +Ces points sont documentés parce qu'ils sont **assumés en l'état**, pas parce qu'ils sont sans +conséquence. Ils doivent être réévalués avant toute exposition élargie de l'API. + +* **L'API n'a aucune authentification** et l'exemple de configuration l'expose sur + ``0.0.0.0:8080``. Quiconque atteint ce port pilote le host KVM. +* ``vm//password`` est stocké tel quel (c'est un hash) et restitué par ``/db?prefix=vm/`` + du serveur d'administration — contenu par ``admin.enabled: false`` et l'écoute en boucle + locale. +* ``/run/two/metadata//vendor-data`` est en ``0644`` et contient ce hash : tout compte local + du host peut le lire. +* ``pkg/systemd.New()`` n'a **pas de timeout** : si le socket D-Bus accepte sans répondre, + l'appelant se fige. Concerne le watchdog, la création et la suppression de subnets, et le + serveur de metadata. +* Il n'y a **pas de rollback** : un échec partiel de création laisse des objets réseau orphelins + jusqu'à un ``DELETE`` explicite. diff --git a/docs/architecture/index.rst b/docs/architecture/index.rst new file mode 100644 index 0000000..8a1308e --- /dev/null +++ b/docs/architecture/index.rst @@ -0,0 +1,13 @@ +Architecture +============ + +L'organisation interne de l'agent : découpage en paquets, stockage, et contraintes à ne pas +enfreindre. Ces pages s'adressent à qui modifie le code ; ``CLAUDE.md``, à la racine du dépôt, +reste la référence de développement et fait foi en cas d'écart. + +.. toctree:: + :maxdepth: 1 + + vue-densemble + stockage + contraintes diff --git a/docs/architecture/stockage.rst b/docs/architecture/stockage.rst new file mode 100644 index 0000000..fd404a2 --- /dev/null +++ b/docs/architecture/stockage.rst @@ -0,0 +1,51 @@ +Schéma des clés +=============== + +Toutes les valeurs stockées dans Badger sont des **chaînes plates** : une clé, une valeur, pas +de sérialisation structurée. + +.. code-block:: text + + vpc//state → creating | running | error | deleting | deleted + vpc//cidr → + + subnet//state → creating | running | error | deleting | deleted + subnet//vpc → + subnet//mode → vxlan | bridge | public_ip + subnet//vxlan_id → (mode vxlan uniquement) + subnet//cidr → + subnet//interface_ip → (gateway, portée par br-) + subnet//local_iface → + subnet//default_route → "true" | "false" + subnet//gateway → (optionnel) + subnet//dhcp/ → + + vm//state → creating | running | error | deleting | deleted + vm//subnet → + vm//tap_id → + vm//ip → + vm//metadata_port → + vm//disk/ → (une clé par disque : sda, vda, …) + vm//memory → (Mo) + vm//cpus → + vm//uefi → "true" (absent si SeaBIOS) + vm//password → (optionnel — un hash, pas un mot de passe) + vm//sshkey → (optionnel) + vm//metadata/ → (optionnel : user-data, vendor-data, …) + +Règles +------ + +**Pas de duplication.** Une ressource ne stocke que ce qui lui est propre. Une VM garde le lien +``vm//subnet`` ; le VPC, le bridge et l'``interface_ip`` sont lus depuis le subnet, leur +source canonique. + +**Les états passent par ``state``.** Toujours ``state.Set`` / ``state.Get`` : ``Set`` refuse une +valeur hors énumération, ``Get`` refuse de retourner une valeur non reconnue. ``error`` n'est +écrit que par ``Dispatcher.Dispatch``, via ``cmd.Key()``. + +**Tout entier lu depuis la base peut être corrompu.** Les erreurs de conversion sont retournées, +jamais ignorées : une valeur absente ou illisible est un état d'erreur réel. + +**Un seul ouvreur.** L'agent est le seul processus à ouvrir la base. Le serveur de metadata lit +des fichiers écrits par l'agent sous ``metadata.run_dir``, jamais Badger. diff --git a/docs/architecture/vue-densemble.rst b/docs/architecture/vue-densemble.rst new file mode 100644 index 0000000..b8fb8e8 --- /dev/null +++ b/docs/architecture/vue-densemble.rst @@ -0,0 +1,90 @@ +Vue d'ensemble +============== + +Cycle d'une requête +------------------- + +.. code-block:: text + + HTTP → internal/api/agent → Dispatcher.Prepare() → Dispatcher.Dispatch() → worker.Queue → Command.Execute() + +**Prepare** (synchrone, dans le handler HTTP) + valide l'état, écrit l'état initial (``creating`` / ``deleting``) en base, et retourne 202 ou + une erreur. + +**Dispatch** (asynchrone) + place la commande sur un canal bufferisé ; une goroutine worker appelle ``Execute``. C'est + ``Dispatch``, et lui seul, qui marque la ressource en ``error`` si ``Execute`` échoue. + +**Execute** + effectue le travail réseau (netns, netif, VXLAN, veth, bridge, DHCP), puis met l'état à + ``running`` / ``deleted``. + +Paquets +------- + +.. list-table:: + :header-rows: 1 + :widths: 32 68 + + * - Chemin + - Rôle + * - ``internal/api/agent`` + - handlers HTTP de ``/vpcs``, ``/subnets`` et ``/vms`` + * - ``internal/dispatcher/agent`` + - interface ``Command`` (``Prepare``/``Execute``/``Key``) et commandes concrètes + * - ``internal/state`` + - énumération des états, ``CanDelete``/``IsTransient``, seul point d'écriture des états + * - ``internal/migration`` + - migrations idempotentes jouées au démarrage de l'agent + * - ``internal/vpc``, ``internal/subnet`` + - création et suppression bas niveau (netns + netif) + * - ``internal/netns`` + - network namespaces : create/enter/delete/call + * - ``internal/netif`` + - netlink : bridge, veth, vxlan, tap, routes, adresses + * - ``internal/ebtables``, ``internal/iptables`` + - wrappers dédiés ; ne pas appeler ces binaires ailleurs + * - ``internal/qemu``, ``internal/qmp`` + - lancement de QEMU et client QMP sur socket Unix + * - ``internal/vm`` + - cycle de vie d'une VM : tap, iptables, metadata, qemu + * - ``internal/dhcp`` + - génération des configurations dnsmasq et entrées ip → mac + * - ``internal/metadata`` + - serveur de metadata cloud-init et ses templates + * - ``internal/watchdog`` + - vérification périodique en lecture seule + * - ``internal/config/agent`` + - chargement par viper — tags ``mapstructure``, jamais ``yaml`` + * - ``internal/prometheus/agent`` + - collector des métriques ``syonad_*`` + * - ``pkg/db/kv`` + - wrapper Badger ; toutes les valeurs sont des chaînes plates + * - ``pkg/worker`` + - pool de goroutines sur canal + * - ``pkg/systemd`` + - client D-Bus systemd + * - ``pkg/logger``, ``pkg/prometheus`` + - journalisation ``slog`` et serveur de métriques + +Ajouter un type de ressource +---------------------------- + +#. ajouter les helpers KV dans ``pkg/db/kv`` si nécessaire ; +#. définir ``Create`` / ``Delete`` dans un nouveau paquet ``internal//`` ; +#. ajouter ``CreateCommand`` / ``DeleteCommand`` dans ``internal/dispatcher/agent/``, dont + ``Key()`` qui retourne ``/`` et le contrôle ``state.CanDelete`` dans + ``DeleteCommand.Prepare`` ; +#. ajouter les handlers HTTP dans ``internal/api/agent/`` et les routes dans ``server.go``. + +Stubs de plateforme +------------------- + +Les fichiers ``_linux.go`` portent l'implémentation netlink/netns réelle ; les ``_other.go`` +correspondants retournent une erreur « not supported on this platform ». Tous les paquets +**compilent** sur macOS, ce qui permet d'y tester la logique qui ne touche ni netlink ni netns. + +Deux exceptions à connaître : les stubs de ``netns`` exécutent ``fn`` **sans changer de +namespace** — ``netns.Call`` réussit donc hors Linux — et ``netif`` compile partout parce que +netlink fournit une implémentation « unspecified ». diff --git a/docs/concepts/cycle-de-vie.rst b/docs/concepts/cycle-de-vie.rst new file mode 100644 index 0000000..43e6172 --- /dev/null +++ b/docs/concepts/cycle-de-vie.rst @@ -0,0 +1,63 @@ +Cycle de vie des ressources +=========================== + +VPC, subnets et VM partagent le même jeu d'états. + +.. mermaid:: + + stateDiagram-v2 + [*] --> creating + creating --> running + creating --> error + running --> deleting + running --> error + deleting --> deleted + deleting --> error + error --> deleting + deleted --> [*] + +.. list-table:: + :header-rows: 1 + :widths: 15 85 + + * - État + - Signification + * - ``creating`` + - la demande est acceptée et enregistrée ; ``Execute`` n'a pas encore abouti + * - ``running`` + - la ressource existe sur le système + * - ``error`` + - ``Execute`` a échoué ; état **terminal**, il n'y a pas de reprise automatique + * - ``deleting`` + - suppression en cours + * - ``deleted`` + - suppression terminée + +Suppression +----------- + +Elle n'est autorisée que depuis ``running`` ou ``error`` — sinon **409**. Depuis ``error``, elle +est **best-effort** : les ressources système peuvent n'avoir été créées que partiellement. + +Un VPC ne peut être supprimé qu'une fois tous ses subnets supprimés. + +Pas de rollback +--------------- + +En cas d'échec partiel pendant une création, les ressources réseau déjà créées **ne sont pas +nettoyées**. C'est un choix délibéré : le nettoyage est déclenché explicitement par une +suppression, qui est justement autorisée depuis ``error``. + +Conséquence pour l'appelant : après un passage en ``error``, émettre un ``DELETE`` avant toute +tentative de recréation, faute de quoi la recréation butera sur des objets système résiduels. + +États transitoires au redémarrage de l'agent +-------------------------------------------- + +La file d'attente des workers est **en mémoire**. Une ressource restée en ``creating`` ou +``deleting`` au moment d'un arrêt de l'agent est donc nécessairement orpheline : plus personne +ne la traite. + +Au démarrage, une migration idempotente bascule ces ressources en ``error``, et traduit +l'ancien vocabulaire d'états. Une ressource retrouvée en ``error`` après un redémarrage n'a donc +pas forcément échoué techniquement — elle peut simplement avoir été interrompue. diff --git a/docs/concepts/index.rst b/docs/concepts/index.rst new file mode 100644 index 0000000..059c6fb --- /dev/null +++ b/docs/concepts/index.rst @@ -0,0 +1,14 @@ +Concepts +======== + +Le modèle de données, les modes réseau et le cycle de vie des ressources : comment les éléments +fonctionnent entre eux. Le contrat HTTP correspondant est dans +:doc:`/exploitation/api-agent/index`. + +.. toctree:: + :maxdepth: 1 + + vpc-subnet-vm + modes-reseau + cycle-de-vie + metadata-cloud-init diff --git a/docs/concepts/metadata-cloud-init.rst b/docs/concepts/metadata-cloud-init.rst new file mode 100644 index 0000000..483090b --- /dev/null +++ b/docs/concepts/metadata-cloud-init.rst @@ -0,0 +1,71 @@ +Metadata et cloud-init +====================== + +Chaque VM dispose d'un serveur de metadata NoCloud, servi sur ``169.254.169.254`` dans le netns +de son VPC, sous la forme d'une instance systemd ``metadata@``. + +Chaîne de production +-------------------- + +.. mermaid:: + + graph LR + A["agent
WriteNoCloudFiles"] -->|"/run/two/metadata/<vm>/"| M["binaire metadata"] + M -->|HTTP 169.254.169.254| G["guest
cloud-init"] + +L'agent écrit les fichiers cloud-init sur disque **avant** de démarrer le service ; le binaire +``metadata`` les lit et les sert. Le processus ``metadata`` n'ouvre **jamais** la base Badger : +deux processus ne doivent pas partager une même instance. + +Documents servis +---------------- + +Chaque document suit la même règle : + +* fourni par l'appelant → servi **verbatim**, l'agent n'interprète rien ; +* absent → le template par défaut est rendu ; +* fourni **vide** → servi vide. + +Les deux derniers cas sont distincts, et c'est délibéré : fournir une chaîne vide est une façon +explicite de neutraliser un document. + +Champs de ``metadata`` +---------------------- + +``sshkey`` + Clé publique ajoutée au compte ``syonad``. Transmise telle quelle, non encodée. + +``password`` + Un **hash**, tel qu'attendu par la clé ``passwd`` de cloud-config (``$6$…``) — jamais un mot + de passe en clair. Omis, le compte est créé verrouillé ; sans ``password`` ni ``sshkey``, + aucun compte n'est créé. + +``user_data`` + Le user-data cloud-init, **encodé en base64**. L'encodage évite l'échappement JSON des + documents multi-lignes et autorise les charges ``gzip+base64``. Un base64 invalide est rejeté + en 400 plutôt que servi vide. + +``instance-id`` +------------------ + +``instance-id`` vaut le **nom de la VM**. Recréer une VM du même nom sur le même disque fait que +cloud-init la reconnaît comme déjà provisionnée et **n'applique pas** le user-data. Pour rejouer +un provisionnement : changer de nom, repartir d'un disque neuf, ou exécuter +``cloud-init clean --logs`` dans le guest avant l'extinction. + +Configuration réseau +-------------------- + +.. warning:: + + ``network-config.tmpl`` cible ``eth0`` alors que les guests utilisent ``ens3`` : il ne + s'applique donc à rien, et le réseau des VM vient du DHCP. **Ne pas le « corriger » ni le + supprimer.** Le rendre opérant ferait remplacer par cloud-init la configuration réseau de + l'image, sur toutes les VM. + +Sécurité +-------- + +``/run/two/metadata//vendor-data`` est en ``0644`` et contient le hash de mot de passe. Tout +compte local du host peut le lire. C'est une exposition connue et acceptée en l'état ; elle +disqualifie l'usage de hashs faibles ou réutilisés. diff --git a/docs/concepts/modes-reseau.rst b/docs/concepts/modes-reseau.rst new file mode 100644 index 0000000..270cb17 --- /dev/null +++ b/docs/concepts/modes-reseau.rst @@ -0,0 +1,99 @@ +Modes réseau +============ + +Le champ ``mode`` d'un subnet détermine la façon dont il est raccordé à l'host, et les routes +annoncées aux VM. + +.. list-table:: + :header-rows: 1 + :widths: 15 45 40 + + * - Mode + - Raccordement + - État + * - ``vxlan`` + - tunnel VXLAN (``vxlan_id``) + bridge dans le netns du VPC + - défaut + * - ``bridge`` + - rattachement direct à un bridge existant de l'host, résolu depuis ``iface_type`` + - disponible + * - ``public_ip`` + - routé comme ``vxlan`` côté DHCP + - **mise en place host non implémentée** — la création échoue à l'exécution + * - ``vlan`` + - — + - réservé, non implémenté + +.. warning:: + + ``public_ip`` est accepté par l'API et traité comme ``vxlan`` pour le DHCP, mais sa + configuration réseau côté host n'existe pas encore : la création part en ``error`` dans + ``Execute``. Ne pas s'appuyer dessus en production. + +vxlan +----- + +.. mermaid:: + + graph LR + VM --- TAP[tap] --- BR["br-<subnet>
interface_ip"] + BR --- VX["vxlan<vni>"] --- HBR["bridge host
(iface_type)"] --- UP[uplink] + +Le subnet vit dans le netns du VPC. La VM n'est donc **pas joignable depuis l'host** sans route +explicite — point à connaître avant de câbler un outil externe dessus. + +bridge +------ + +Le subnet est rattaché directement à un bridge existant de l'host. Pas de tunnel, pas de route +VPC : le trafic sort par le bridge, et la VM est joignable depuis l'host. + +Routes annoncées aux VM +----------------------- + +Les routes sont poussées par DHCP, dans l'**option 121** (routes statiques sans classe, +RFC 3442). Trois entrées y figurent : + +#. la route ``/32`` vers ``169.254.169.254``, le serveur de metadata ; +#. la route vers le CIDR du VPC ; +#. la route par défaut ``0.0.0.0/0``. + +.. important:: + + **Un client qui lit l'option 121 ignore l'option 3.** Toute route par défaut doit donc figurer + dans l'option 121 ; l'option 3 ne sert que les clients qui n'implémentent pas la 121. + +Route par défaut : ``default_route`` et ``gateway`` +---------------------------------------------------------- + +Une route par défaut est **toujours** annoncée. Le champ ``default_route`` ne choisit que son +next-hop : + +``default_route: false`` (défaut) + next-hop = ``interface_ip`` du subnet. + +``default_route: true`` + next-hop = le champ ``gateway`` s'il est fourni, sinon la gateway lue dans la table de routage + de l'host. + +``gateway`` n'est **pas validé** par l'agent : sa joignabilité et sa cohérence avec le CIDR du +subnet relèvent de l'appelant. Fourni avec ``default_route: false``, il est ignoré. + +Dans tous les modes sauf ``bridge``, la route vers le CIDR du VPC garde ``interface_ip`` comme +next-hop : sur un subnet à IP publique, le trafic interne ne doit pas sortir par la gateway +publique. + +Pourquoi la route ``/32`` vers le serveur de metadata est indispensable +---------------------------------------------------------------------------- + +Elle paraît redondante avec la route par défaut. Elle ne l'est pas. + +La DNAT vers le serveur de metadata est posée dans le netns du VPC, en ``PREROUTING``. Le paquet +n'y est traité en L3 que si son next-hop est ``interface_ip``, portée par le bridge du netns. +Avec un autre next-hop, la trame est commutée en **L2** sans traverser ``PREROUTING`` : le +serveur de metadata devient injoignable et tout le provisionnement cloud-init échoue, +silencieusement. + +.. danger:: + + Ne jamais retirer cette route de l'option 121, quelle que soit l'apparence de redondance. diff --git a/docs/concepts/vpc-subnet-vm.rst b/docs/concepts/vpc-subnet-vm.rst new file mode 100644 index 0000000..ac1e673 --- /dev/null +++ b/docs/concepts/vpc-subnet-vm.rst @@ -0,0 +1,63 @@ +VPC, subnet et VM +================= + +Trois types de ressources, une hiérarchie stricte. + +.. mermaid:: + + graph TD + VPC["VPC
network namespace
cidr"] --> SN1["Subnet
bridge + VXLAN
interface_ip, cidr"] + VPC --> SN2["Subnet"] + SN1 --> VM1["VM
QEMU/KVM"] + SN1 --> VM2["VM"] + SN2 --> VM2 + +VPC +--- + +Un VPC est un **network namespace** portant un espace d'adressage (``cidr``). C'est l'unité +d'isolation : deux VPC ne se voient pas, et peuvent réutiliser les mêmes plages d'adresses. + +Un VPC ne peut être supprimé que si tous ses subnets le sont déjà — sinon 409. + +Subnet +------ + +Un subnet appartient à un VPC et pose, dans son netns, un bridge qui porte ``interface_ip`` — la +gateway vue par les VM. Il fournit aussi le DHCP (dnsmasq) et les routes annoncées aux guests. + +``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 +``default_interface``. Ce niveau d'indirection permet au même appel d'API de fonctionner sur des +hosts dont le nommage réseau diffère. + +Le comportement réseau dépend du :doc:`mode `. + +VM +-- + +Une VM est un processus QEMU/KVM raccordé à un ou plusieurs subnets par des taps. + +**Interfaces.** L'ordre du tableau ``interfaces`` détermine le slot PCI (``0x03 + index``), donc +le nom de l'interface dans le guest. Exactement une interface doit être ``primary`` : elle porte +la route par défaut et le serveur de metadata. Tous les subnets d'une VM doivent appartenir au +**même VPC**. + +**Stockage.** Un seul disque ``vdX`` (virtio-blk) par VM ; les disques supplémentaires passent +par le contrôleur SCSI (``sdX``). Cette contrainte vient de la carte PCI figée — voir +:doc:`/architecture/contraintes`. + +Ce qui est stocké, et où +------------------------ + +Une ressource ne porte en base que ce qui lui est propre. Une VM stocke le **lien** vers son +subnet (``vm//subnet``), pas le VPC ni le bridge ni la gateway : ces valeurs sont lues +depuis le subnet, leur source canonique. Le schéma complet des clés est dans +:doc:`/architecture/stockage`. + +Nommage +------- + +L'API est machine-to-machine : elle **ne valide pas** les conventions de nommage, à l'exception +du motif documenté pour les VPC (``vp-…``). Les exemples de cette documentation suivent la +convention ``vp-`` / ``sn-`` / ``i-``, mais c'est à l'appelant de la faire respecter. diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..19d4d9e --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,48 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'two' +copyright = '2026, Nicolas Boufidjeline' +author = 'Nicolas Boufidjeline' +version = '0.1' +release = '0.1.0' + + +# -- General configuration --------------------------------------------------- + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'README.md', 'requirements.txt', 'schemas'] + +language = 'fr' + +extensions = [ + 'myst_parser', + 'sphinxcontrib.mermaid', + 'sphinxcontrib.openapi', +] + +myst_enable_extensions = [ + 'colon_fence', + 'deflist', +] + +source_suffix = { + '.rst': 'restructuredtext', + '.md': 'markdown', +} + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'sphinx_book_theme' +html_static_path = [] +html_show_sphinx = False + +html_theme_options = { + 'home_page_in_toc': True, +} diff --git a/docs/demarrage/index.rst b/docs/demarrage/index.rst new file mode 100644 index 0000000..d1d3929 --- /dev/null +++ b/docs/demarrage/index.rst @@ -0,0 +1,14 @@ +Démarrage +========= + +Le parcours court : un hyperviseur, un VPC, un subnet, une VM qui démarre. Tout reste sur le +même nœud — c'est suffisant pour valider une installation et pour découvrir le modèle, pas pour +faire fonctionner un parc. + +Pour un cluster, poursuivre avec :doc:`/deploiement/index`. + +.. toctree:: + :maxdepth: 1 + + installation + premier-vpc diff --git a/docs/demarrage/installation.rst b/docs/demarrage/installation.rst new file mode 100644 index 0000000..56c4cda --- /dev/null +++ b/docs/demarrage/installation.rst @@ -0,0 +1,147 @@ +Installation d'un hyperviseur +============================= + +Cette page installe l'agent sur **un** hyperviseur. Le réseau du cluster — routage entre nœuds, +plan de contrôle — est traité à part : voir :doc:`/deploiement/index`. + +Prérequis +--------- + +Un host Linux avec KVM, sur lequel vous avez ``root``. Les opérations réseau (network +namespaces, netlink, VXLAN, ebtables, iptables) et QEMU ne fonctionnent que sous Linux. + +Déploiement +----------- + +.. code-block:: bash + + curl -O https://git.g3e.fr/syonad/two/raw/branch/main/scripts/deploy.sh + bash ./deploy.sh -t 0.1.0 -i + +``deploy.sh`` se met à jour lui-même depuis la branche avant toute action — s'il diffère, il se +réécrit et demande d'être relancé. Il télécharge ensuite binaires, units systemd et scripts +depuis la release, et les vérifie contre le manifeste ``SHA256SUMS``. + +.. list-table:: + :header-rows: 1 + :widths: 26 54 20 + + * - Option + - Effet + - Défaut + * - ``-t `` + - déployer une release donnée + - dernière + * - ``-b `` + - branche utilisée pour l'auto-mise à jour du script + - ``main`` + * - ``-p `` + - profil d'host ; seul ``kvm`` installe les units de l'agent + - ``kvm`` + * - ``-i`` + - préparer l'host : paquets, noyau, réseau + - désactivé + * - ``-u `` + - interface physique d'uplink + - ``eno1`` + * - ``-B `` + - bridge principal, auquel l'uplink est rattaché + - ``br-000000`` + * - ``-P `` + - bridge supplémentaire, créé vide et réservé + - ``br-public`` + * - ``-R `` + - délai avant le redémarrage de secours pendant la migration réseau + - ``120`` + * - ``-d`` + - dry-run : affiche les commandes sans les exécuter + - désactivé + +Les options booléennes actives par défaut se **désactivent** par leur forme longue négative : +``--nopackages``, ``--nonetwork``, ``--noverify``, ``--noup_script``. + +.. warning:: + + ``--noverify`` désactive la seule vérification d'intégrité des artefacts téléchargés. Ne + l'utiliser que pour diagnostiquer un manifeste cassé, jamais en déploiement courant. + +Ce que fait ``-i`` +------------------ + +**Paquets** — ``qemu-system-x86``, ``ovmf``, ``dnsmasq``, ``ebtables``, ``iptables``, +``nfs-common``, ``jq``, ``curl``. Le service ``dnsmasq`` du système est ensuite désactivé et +**masqué** : il prendrait le port 53 en concurrence des instances ``dnsmasq@`` que l'agent lance +dans les netns. + +**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 +ne se provisionne pas. Contrepartie assumée : tout le trafic inter-VM traverse les tables NAT. + +**Réseau** — création du bridge réservé, puis rattachement de l'uplink au bridge principal, +l'adresse et la route par défaut étant déplacées de l'interface physique vers le bridge. + +.. danger:: + + La migration réseau **coupe le réseau de l'host si elle échoue à mi-parcours**, sans console + de secours. Deux garde-fous sont en place : un redémarrage de secours armé avant l'opération + (``-R``, 120 s par défaut) qui ramène la configuration d'origine puisque rien n'est écrit sur + disque, et l'exécution de la séquence sous systemd plutôt que dans la session SSH, pour + qu'une coupure de SSH ne l'interrompe pas. + + Le désarmement n'a lieu **qu'après** un ping réussi vers la passerelle. Prévoir un accès + physique ou console avant de lancer un ``-i`` à distance sur un host de production. + +Host sans état +-------------- + +L'hyperviseur est **stateless** : sa racine est en tmpfs, rien de ce que pose ``-i`` ne survit à +un redémarrage. ``deploy.sh --bootstrap`` est donc rejoué à chaque démarrage — c'est le +mécanisme normal, pas une réparation. + +Binaires installés +------------------ + +.. list-table:: + :header-rows: 1 + :widths: 20 60 20 + + * - Binaire + - Rôle + - Drapeau de config + * - ``agent`` + - processus principal : API, dispatcher, exécution, watchdog + - ``-config`` + * - ``metadata`` + - serveur de metadata cloud-init, une instance par VM dans le netns du VPC + - ``-conf`` + * - ``db`` + - inspection de la base clé-valeur en ligne de commande + - ``-conf`` + +Les trois partagent le même fichier, ``/etc/two/agent.yml`` — voir +:doc:`/exploitation/configuration`. + +Mise à jour +----------- + +``deploy.sh`` relève les instances ``dnsmasq@`` et ``metadata@`` actives **avant** d'arrêter les +services, et les redémarre ensuite : c'est la seule façon de savoir lesquelles relancer. Arrêter +les services à la main avant de lancer le script fait perdre cette liste. + +Vérifier l'installation +----------------------- + +.. code-block:: bash + + systemctl status agent + curl -s http://127.0.0.1:8080/vpcs + +Une liste JSON — vide au premier démarrage — signifie que l'API répond. Passez à +:doc:`/demarrage/premier-vpc`. + +.. important:: + + L'API de l'agent **n'a aucune authentification**. Avant d'ouvrir le port au-delà de la boucle + locale, lisez l'avertissement de :doc:`/exploitation/configuration` : quiconque atteint ce + port pilote la totalité de l'hyperviseur. diff --git a/docs/demarrage/premier-vpc.rst b/docs/demarrage/premier-vpc.rst new file mode 100644 index 0000000..e5e67dc --- /dev/null +++ b/docs/demarrage/premier-vpc.rst @@ -0,0 +1,155 @@ +Premier VPC, premier subnet, première VM +======================================== + +Ce tutoriel crée de bout en bout une VM joignable, sur un hyperviseur où l'agent est installé et +répond. Il suppose l'API sur ``127.0.0.1:8080`` et une image disque déjà présente sur l'host. + +Tout se passe sur un **seul nœud** : un subnet ne s'étend à d'autres hyperviseurs qu'une fois le +plan de contrôle du cluster en place, cf. :doc:`/deploiement/architecture-cluster`. + +Ce que l'on construit +--------------------- + +.. mermaid:: + + graph LR + subgraph netns vp-admin + BR["br-sn000001
10.1.1.1"] + MD["metadata@i-web
169.254.169.254"] + end + VM["VM i-web
10.1.1.2"] --- BR + BR --- MD + BR --- VXLAN["VXLAN vni 1
br-000000"] + +Le VPC est un network namespace ; le subnet y pose un bridge porteur de la gateway ; la VM s'y +raccroche par un tap, reçoit son adresse en DHCP et son cloud-init depuis le serveur de metadata +du netns. + +1. Le VPC +--------- + +.. code-block:: bash + + curl -X POST http://127.0.0.1:8080/vpcs \ + -H 'Content-Type: application/json' \ + -d '{"name": "vp-admin", "cidr": "192.168.0.0/16"}' + +Le ``cidr`` est l'espace d'adressage global du VPC : c'est lui qui sera annoncé aux VM comme +route interne, quel que soit le mode du subnet. + +La réponse est un **202** : la création est acceptée, pas terminée. + +.. code-block:: bash + + curl -s http://127.0.0.1:8080/vpcs/vp-admin + +Attendez ``"state": "running"`` avant l'étape suivante — un subnet dont le VPC parent n'est pas +prêt est refusé en **422**. Le modèle d'attente est décrit dans :doc:`/exploitation/api-agent/asynchronisme`. + +2. Le subnet +------------ + +.. code-block:: bash + + curl -X POST http://127.0.0.1:8080/subnets \ + -H 'Content-Type: application/json' \ + -d '{"name": "sn-000001", + "vpc": "vp-admin", + "mode": "vxlan", + "vxlan_id": 1, + "iface_type": "vms", + "interface_ip": "10.1.1.1", + "cidr": "10.1.0.0/23"}' + +``iface_type`` est une clé **logique** résolue dans la configuration de l'agent (section +``interfaces``) vers un bridge physique de l'host ; une clé inconnue retombe sur +``default_interface``. ``interface_ip`` est la gateway du subnet, portée par le bridge créé dans +le netns. + +Les modes disponibles et leurs conséquences sur le routage sont détaillés dans +:doc:`/concepts/modes-reseau`. + +Là encore, attendez ``running`` : + +.. code-block:: bash + + curl -s http://127.0.0.1:8080/subnets/sn-000001 + +3. La VM +-------- + +.. code-block:: bash + + curl -X POST http://127.0.0.1:8080/vms \ + -H 'Content-Type: application/json' \ + -d '{"name": "i-web", + "memory": 2048, + "cpus": 2, + "uefi": true, + "metadata": {"sshkey": "ssh-ed25519 AAAA…", + "user_data": "'"$(base64 < user-data.yml | tr -d '\n')"'"}, + "interfaces": [{"subnet": "sn-000001", "ip": "10.1.1.2", "primary": true}], + "storage": [{"path": "/var/lib/two/volumes/i-web.qcow2", "dev": "vda"}]}' + +Quatre points qui coûtent du temps quand on les découvre en production : + +``user_data`` est **encodé en base64** + Un base64 invalide est rejeté en 400 plutôt que servi vide. L'agent n'interprète jamais ce + contenu. + +``password`` est un **hash**, pas un mot de passe + Le champ attend la valeur de la clé ``passwd`` de cloud-config (``$6$…``). Sans ``password`` + ni ``sshkey``, aucun compte n'est créé. + +Exactement une interface est ``primary`` + Elle porte la route par défaut et le serveur de metadata. L'ordre du tableau détermine le + slot PCI (``0x03 + index``), donc le nom de l'interface dans le guest. Tous les subnets d'une + VM doivent appartenir au même VPC. + +Un seul disque ``vdX`` + Les disques supplémentaires passent par ``sdX``. La carte PCI en dépend — voir + :doc:`/architecture/contraintes`. + +4. Vérifier +----------- + +.. code-block:: bash + + curl -s http://127.0.0.1:8080/vms/i-web + +En ``running``, la VM est démarrée et le serveur de metadata est en place. Le provisionnement +cloud-init, lui, se déroule dans le guest ; on l'observe par la console série : + +.. code-block:: bash + + socat -,raw,echo=0 UNIX-CONNECT:/run/two/vms/serial/i-web.sock + +Puis, depuis l'host : + +.. code-block:: bash + + ssh syonad@10.1.1.2 + +.. note:: + + En mode ``vxlan``, la VM vit dans le netns du VPC : elle n'est pas joignable depuis l'host + sans route explicite. En mode ``bridge``, elle l'est directement. + +5. Supprimer +------------ + +Dans l'ordre inverse — un VPC dont il reste des subnets est refusé en **409** : + +.. code-block:: bash + + curl -X DELETE http://127.0.0.1:8080/vms/i-web + curl -X DELETE http://127.0.0.1:8080/subnets/sn-000001 + curl -X DELETE http://127.0.0.1:8080/vpcs/vp-admin + +La suppression d'une VM ne touche **jamais** aux fichiers disque. + +.. warning:: + + ``instance-id`` vaut le nom de la VM. Recréer une VM du même nom sur le même disque fait que + cloud-init la reconnaît et **n'applique pas** le user-data. Pour rejouer un provisionnement, + changez de nom ou repartez d'un disque neuf. diff --git a/docs/deploiement/architecture-cluster.rst b/docs/deploiement/architecture-cluster.rst new file mode 100644 index 0000000..d1eb9bf --- /dev/null +++ b/docs/deploiement/architecture-cluster.rst @@ -0,0 +1,98 @@ +Architecture du cluster +======================= + +Topologie +--------- + +.. figure:: /schemas/topologie-cluster.svg + :alt: Topologie du cluster : routeurs, route reflector et hyperviseurs + :align: center + :width: 100% + :class: only-light + + Topologie cible. Trait plein : plan de données. Trait pointillé : plan de contrôle. + +.. figure:: /schemas/topologie-cluster-dark.svg + :alt: Topologie du cluster : routeurs, route reflector et hyperviseurs + :align: center + :width: 100% + :class: only-dark + + Topologie cible. Trait plein : plan de données. Trait pointillé : plan de contrôle. + +Deux plans distincts, à ne pas confondre au moment du diagnostic : + +Plan de données + les tunnels VXLAN entre hyperviseurs, encapsulés sur le réseau qui les relie. + +Plan de contrôle + ce qui dit à chaque hyperviseur où se trouvent les adresses MAC des autres. C'est le rôle de + FRR et du route reflector. + +Ce que l'agent suppose déjà en place +------------------------------------ + +L'agent ne configure **que** son propre hyperviseur, et seulement à partir du bridge d'uplink. +Tout ce qui est en amont — adressage des hyperviseurs, routage entre eux, plan de contrôle — lui +préexiste et n'est jamais créé ni vérifié par lui. + +Concrètement, il attend : + +* le bridge d'uplink de la configuration (``br-000000`` par défaut), avec l'interface physique + esclave et l'adresse de l'hyperviseur portée par le bridge — c'est ce que fait + ``deploy.sh --bootstrap``, voir :doc:`/demarrage/installation` ; +* une connectivité IP entre hyperviseurs sur cette adresse, port UDP **4789** ouvert dans les + deux sens ; +* un plan de contrôle qui peuple la table de transfert VXLAN — voir ci-dessous. + +Pourquoi un plan de contrôle est nécessaire +------------------------------------------- + +L'agent crée les interfaces VXLAN sur le port 4789 **sans groupe multicast et avec +l'apprentissage désactivé** (``Learning: false``). Il n'y a donc ni inondation multicast, ni +apprentissage des adresses MAC depuis le trafic, ni voisin statique configuré. + +.. important:: + + Conséquence directe : sur un même VNI, **rien ne traverse d'un hyperviseur à l'autre** tant + qu'un composant externe n'a pas peuplé la table de transfert (FDB) du VXLAN. Sur un nœud + isolé le trafic reste sur le bridge local et cette absence ne se voit pas ; elle apparaît dès + le deuxième nœud. + +C'est exactement le rôle que remplissent FRR sur chaque hyperviseur et le route reflector qui +les fait converger. + +MTU +--- + +.. warning:: + + L'agent crée bridges, veth et interfaces VXLAN avec un **MTU figé à 1500**. VXLAN ajoute 50 + octets d'encapsulation : le réseau qui relie les hyperviseurs doit donc accepter au moins + **1550 octets** de MTU, sinon les paquets pleine taille des VM sont perdus. + + Le symptôme est trompeur : le ping passe, les petites requêtes passent, les transferts + volumineux et les poignées de main TLS échouent. + +Hyperviseurs sans état +---------------------- + +L'hyperviseur est **stateless** — sa racine est en tmpfs, rien de ce que pose +``deploy.sh --bootstrap`` ne survit à un redémarrage, et le script est rejoué à chaque démarrage. + +Toute configuration ajoutée à un hyperviseur — FRR compris — doit donc être posée par un +mécanisme rejouable au démarrage, jamais par une modification manuelle d'un fichier sous +``/etc``. + +Adressage +--------- + +.. note:: + + **À rédiger** — cette page ne décrit pas encore le plan d'adressage du cluster. À documenter : + + * la plage utilisée pour les adresses d'hyperviseurs, et son rapport avec ``br-000000`` ; + * l'allocation des VNI VXLAN : qui la tient, et comment on évite les collisions, puisque + l'agent ne valide pas ``vxlan_id`` ; + * l'usage prévu de ``br-public``, créé vide et réservé par le bootstrap ; + * le plan d'adressage des VPC, et ce qui garantit qu'ils ne se recouvrent pas entre clients. diff --git a/docs/deploiement/image-qcow2.rst b/docs/deploiement/image-qcow2.rst new file mode 100644 index 0000000..63ddc28 --- /dev/null +++ b/docs/deploiement/image-qcow2.rst @@ -0,0 +1,337 @@ +Construction de l'image qcow2 +============================= + +Toutes les VM du cluster — ``intel``, PostgreSQL, route reflector et les suivantes — partent +d'une même image qcow2 « golden », construite une fois puis réutilisée. Cette page décrit la +procédure en service. + +.. important:: + + Cette image est un **artefact redistribuable** : tout ce qui s'y trouve se retrouve dans + chaque VM qui en dérive. Les étapes de nettoyage de la fin ne sont pas une commodité, ce sont + des exigences. + +Principe +-------- + +La construction se fait dans une **VM jetable**, et non par montage de l'image sur l'host : le +chroot a besoin d'un noyau et d'un espace utilisateur cohérents avec la distribution cible, ce +que l'host ne fournit pas nécessairement. + +Cette VM de construction démarre sur un overlay de l'image du fournisseur et voit deux disques +supplémentaires : le futur disque « golden », et un espace de travail. + +.. mermaid:: + + graph LR + ISO["seed.iso
cloud-init NoCloud"] --> BVM + OVL["<os>-tmp.qcow2
overlay, jetable"] --> BVM["VM de construction"] + BVM --> ROOT["<os>-root.qcow2
image golden"] + BVM --> WORK["tmp.qcow2
espace de travail"] + BASE["image du fournisseur
(qcow2)"] -.backing file.-> OVL + +.. list-table:: + :header-rows: 1 + :widths: 26 20 54 + + * - Disque + - Vu dans la VM + - Rôle + * - ``-tmp.qcow2`` + - ``vda`` (virtio-blk) + - système de la VM de construction ; overlay de l'image du fournisseur, jeté à la fin + * - ``-root.qcow2`` + - ``sda`` (SCSI) + - **le résultat** : l'image golden, écrite en brut depuis la VM + * - ``tmp.qcow2`` + - ``sdb`` (SCSI) + - espace de travail : téléchargement et conversion + +Variables +--------- + +.. code-block:: bash + + export os= + export os_link= + export os_file= + export os_dir= + export disk_dir= + +Étape 1 — Le seed cloud-init de la VM de construction +------------------------------------------------------ + +Ce seed ne concerne **que la VM de construction**. Il n'a aucun rapport avec la configuration +cloud-init de l'image produite, qui est posée plus loin en chroot. Son seul rôle est de donner +un accès à la VM le temps du build. + +.. code-block:: bash + + mkdir -p "${os_dir}" && cd "${os_dir}" + mkdir -p /opt/seed/${os} + + cat << 'ENDFILE' > /opt/seed/${os}/meta-data + instance-id: iid-local01 + local-hostname: my-vm-01 + ENDFILE + + cat << 'ENDFILE' > /opt/seed/${os}/network-config + version: 2 + renderer: networkd + ethernets: + eth0: + dhcp4: true + ENDFILE + + cat << 'ENDFILE' > /opt/seed/${os}/user-data + #cloud-config + users: + - name: + lock_passwd: false + passwd: "" + sudo: ALL=(ALL) NOPASSWD:ALL + ssh_authorized_keys: + - + ENDFILE + + mkisofs -o /opt/seed/${os}_seed.iso -V cidata -J -r /opt/seed/${os}/ + +Le label de volume ``cidata`` n'est pas décoratif : c'est ce qui fait reconnaître l'ISO comme une +source NoCloud par cloud-init. + +.. warning:: + + ``passwd`` attend un **hash**, et ``ssh_authorized_keys`` une clé publique personnelle : ces + deux valeurs sont des données à ne pas recopier hors de l'host de construction. Elles ne + figurent volontairement pas dans cette documentation. + + ``openssl passwd -5`` pour generer un hash + +Étape 2 — Les disques +--------------------- + +.. code-block:: bash + + curl "${os_link}" -O + + qemu-img create -f qcow2 "${disk_dir}/${os}-root.qcow2" 10G + qemu-img create -f qcow2 "${disk_dir}/tmp.qcow2" 50G + qemu-img create -f qcow2 -b "${os_dir}/${os_file}" -F qcow2 "${disk_dir}/${os}-tmp.qcow2" 10G + +.. important:: + + ``-F qcow2`` est **obligatoire** sur qemu récent : sans lui, le format du backing file n'est + pas figé dans l'en-tête de l'overlay. + +La taille de ``-root.qcow2`` (10 Gio ici) borne l'image produite : elle doit être au moins +égale à la taille **virtuelle** de l'image du fournisseur, pas à la taille de son fichier. + +Étape 3 — Lancer la VM de construction +-------------------------------------- + +.. code-block:: bash + + qemu-system-x86_64 \ + -enable-kvm \ + -cpu host \ + -m 2048 \ + -smp 2 \ + -nographic \ + -serial mon:stdio \ + -monitor unix:/tmp/vm-build.mon-sock,server,nowait \ + -drive file=/opt/seed/${os}_seed.iso,media=cdrom,if=ide \ + \ + -drive file=${disk_dir}/${os}-tmp.qcow2,format=qcow2,if=none,id=vda \ + -device virtio-blk-pci,drive=vda,bootindex=0 \ + \ + -device virtio-scsi-pci,id=scsi0 \ + \ + -drive file=${disk_dir}/${os}-root.qcow2,if=none,id=hd0 \ + -device scsi-hd,drive=hd0,bus=scsi0.0 \ + \ + -drive file=${disk_dir}/tmp.qcow2,if=none,id=hd1 \ + -device scsi-hd,drive=hd1,bus=scsi0.0 \ + \ + -netdev tap,id=net0,ifname=tap0,script=no,downscript=no \ + -device virtio-net-pci,netdev=net0,mac=00:22:33:00:00:01 + +La répartition virtio-blk pour le système / SCSI pour les disques supplémentaires est la même que +celle qu'impose l'agent — voir :doc:`/architecture/contraintes`. Le tap ``tap0`` doit exister et +être raccordé à un réseau qui donne un accès sortant : la suite télécharge l'image du +fournisseur depuis la VM. + +Étape 4 — Écrire l'image du fournisseur sur le disque cible +------------------------------------------------------------ + +Les commandes suivantes s'exécutent **dans la VM de construction**. Identifier d'abord les +disques : le disque de travail et le disque cible ne doivent pas être confondus. + +.. danger:: + + ``qemu-img convert`` écrase intégralement le disque cible. Vérifier les noms avant, avec + ``lsblk``, plutôt que de supposer l'ordre d'énumération. + +.. code-block:: bash + + work_disk=/dev/sdb + os_disk=/dev/sda + + mkdir /work + mkfs.xfs ${work_disk} + mount ${work_disk} /work + cd /work + + curl "${os_link}" -O + 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. + +.. code-block:: bash + + partprobe + echo 1 > /sys/block/sda/device/rescan + sleep 2 + + # La partition racine est la plus grande du disque + 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 + mount -o bind /dev /mnt/dev + mount -o bind /proc /mnt/proc + mount -o bind /sys /mnt/sys + + cp /etc/resolv.conf /mnt/etc/resolv.conf + +``-o nouuid`` est nécessaire parce que le système de fichiers qui vient d'être écrit porte le +même UUID que celui déjà monté par la VM de construction. Le ``resolv.conf`` est copié pour que +les commandes en chroot aient la résolution DNS ; il est supprimé au nettoyage. + +Étape 5 — Personnaliser l'image +------------------------------- + +**Accès SSH** + +.. code-block:: bash + + yum install -y augeas + + echo "The default user for Syonad VMs is 'syonad'." > /mnt/etc/banner + + augtool -r /mnt -s <<'EOF' + set /files/etc/ssh/sshd_config/X11Forwarding no + set /files/etc/ssh/sshd_config/PermitTunnel no + set /files/etc/ssh/sshd_config/PermitRootLogin no + set /files/etc/ssh/sshd_config/RSAAuthentication yes + set /files/etc/ssh/sshd_config/PubkeyAuthentication yes + set /files/etc/ssh/sshd_config/PasswordAuthentication no + set /files/etc/ssh/sshd_config/UseDNS no + set /files/etc/ssh/sshd_config/ChallengeResponseAuthentication no + set /files/etc/ssh/sshd_config/GSSAPIAuthentication no + set /files/etc/ssh/sshd_config/Match[1]/Condition/User "root,centos,ubuntu,debian,ec2-user" + set /files/etc/ssh/sshd_config/Match[1]/Settings/Banner "/etc/banner" + EOF + +``PasswordAuthentication no`` vaut pour toutes les VM dérivées : l'accès se fait par clé, et le +champ ``password`` de l'API de l'agent ne sert donc **pas** à ouvrir une session SSH. + +**Utilisateur par défaut et source de metadata** + +.. code-block:: bash + + cat << 'ENDFILE' > /mnt/etc/cloud/cloud.cfg.d/20_user.cfg + system_info: + default_user: + name: syonad + ENDFILE + + cat << 'ENDFILE' > /mnt/etc/cloud/cloud.cfg.d/99_metadata.cfg + datasource_list: [ NoCloud ] + datasource: + NoCloud: + seedfrom: 'http://169.254.169.254:80' + timeout: 5 + max_wait: 10 + ENDFILE + +C'est ce second fichier qui raccorde l'image au serveur de metadata de l'agent : ``NoCloud`` est +la seule source retenue, et elle pointe sur ``169.254.169.254``. La route ``/32`` vers cette +adresse est indispensable côté agent — voir :doc:`/concepts/modes-reseau`. + +**Services et durcissement** + +.. code-block:: bash + + chroot /mnt/ systemctl enable fstrim.timer + + chroot /mnt/ systemctl disable rpcbind.service + chroot /mnt/ systemctl disable rpcbind.socket + + augtool -r /mnt -s set /files/etc/selinux/config/SELINUX disabled + + chroot /mnt/ dnf remove -y 'cockpit*' + chroot /mnt/ rm -rf /run/cockpit + +Étape 6 — Nettoyer, puis éteindre +--------------------------------- + +.. code-block:: bash + + rm -f /mnt/etc/resolv.conf + rm -rf /mnt/var/cache/yum + rm -rf /mnt/root/.ssh + rm -rf /mnt/root/.bash_history + rm -rf /mnt/tmp/* + rm -rf /mnt/var/lib/dhcp/* + rm -rf /mnt/var/tmp/* + find /mnt/var/log ! -type d -exec rm '{}' \; + rm -rf /mnt/var/lib/cloud/* + + poweroff + +``/mnt/var/lib/cloud/*`` est le nettoyage le plus important : c'est lui qui fait que cloud-init +considère chaque VM dérivée comme une instance neuve. Sans lui, l'image embarque l'identité de +l'instance de construction et le user-data n'est pas appliqué — même mécanisme que la +recréation d'une VM sous un nom déjà utilisé, cf. :doc:`/concepts/metadata-cloud-init`. + +Une fois la VM éteinte, ``${disk_dir}/${os}-root.qcow2`` est l'image golden. +``${os}-tmp.qcow2`` et ``tmp.qcow2`` sont jetables. + +Points de vigilance +------------------- + +.. warning:: + + **SELinux est désactivé** dans l'image. C'est une couche de protection en moins sur toutes les + VM qui en dérivent, y compris celles qui portent des fonctions sensibles comme le route + reflector ou la base de données. Décision à assumer explicitement, et à réévaluer : le mode + ``permissive`` permettrait au minimum de savoir ce qui serait bloqué. + +.. note:: + + ``fstrim.timer`` est activé dans l'image, mais l'agent lance QEMU **sans** ``discard=unmap`` + ni ``detect-zeroes=unmap`` sur les disques. Le ``fstrim`` du guest ne rend donc aujourd'hui + aucun espace à l'host : les qcow2 ne se rétractent pas. L'activation reste utile pour le jour + où l'option sera ajoutée côté agent, mais ne pas compter dessus pour la place disque. + +.. note:: + + **À vérifier** — ``seedfrom`` sans barre oblique finale. cloud-init construit l'URL des + documents en concaténant ``seedfrom`` avec ``meta-data`` et ``user-data``. Confirmer sur une + VM réelle que les deux documents sont bien récupérés, et corriger en + ``http://169.254.169.254:80/`` si ce n'est pas le cas. + +.. note:: + + **Provenance de l'image du fournisseur** — tout ce qui est construit en hérite. Vérifier la + somme de contrôle, et la signature quand elle existe, avant de construire dessus. La + procédure actuelle télécharge l'image deux fois, une fois sur l'host et une fois dans la VM, + sans vérification. + +.. note:: + + **À rédiger** — la procédure ne dit pas encore comment l'image produite est nommée, versionnée + et distribuée aux hyperviseurs, ni quelles variantes existent par rôle (``intel``, PostgreSQL, + route reflector) : image unique personnalisée au démarrage par cloud-init, ou images + dérivées ? diff --git a/docs/deploiement/index.rst b/docs/deploiement/index.rst new file mode 100644 index 0000000..9586f3a --- /dev/null +++ b/docs/deploiement/index.rst @@ -0,0 +1,32 @@ +Déploiement d'un cluster +======================== + +Le :doc:`/demarrage/index` couvre un hyperviseur isolé : un VPC, un subnet, une VM, tout sur le +même nœud. Cette section couvre la mise en place d'un **cluster** complet, dans l'ordre où les +étapes se font. + +Cet ordre n'est pas indifférent : chaque étape a besoin de la précédente. L'image doit exister +avant qu'on puisse démarrer quoi que ce soit ; le réseau doit être en place avant le premier +hyperviseur ; le route reflector est lui-même une VM, il lui faut donc un hyperviseur qui +fonctionne. + +Étapes +------ + +#. :doc:`architecture-cluster` — la topologie cible, à lire avant tout le reste +#. :doc:`image-qcow2` — l'image golden dont dérivent toutes les VM du cluster +#. :doc:`routeurs` — le matériel : routeurs de cluster, de datacentre et de bordure +#. :doc:`premier-hyperviseur` — le premier nœud, agent et plan de contrôle +#. :doc:`route-reflector` — les VM route reflector + +D'autres étapes viendront à mesure que les composants d'orchestration seront livrés. + +.. toctree:: + :hidden: + :maxdepth: 1 + + architecture-cluster + image-qcow2 + routeurs + premier-hyperviseur + route-reflector diff --git a/docs/deploiement/premier-hyperviseur.rst b/docs/deploiement/premier-hyperviseur.rst new file mode 100644 index 0000000..ae70bc1 --- /dev/null +++ b/docs/deploiement/premier-hyperviseur.rst @@ -0,0 +1,74 @@ +Premier hyperviseur +=================== + +Le premier nœud du cluster se déploie comme les suivants, mais il est le seul à devoir +fonctionner **avant** que le plan de contrôle existe : c'est lui qui hébergera la première VM +route reflector. + +Installation +------------ + +L'installation de l'agent est identique à celle d'un nœud isolé et n'est pas reprise ici : +voir :doc:`/demarrage/installation` pour ``deploy.sh``, ses options, la préparation de l'host et +la migration réseau vers le bridge d'uplink. + +Deux points à relire avant de lancer un ``-i`` sur un nœud de production : la migration réseau +coupe le réseau de l'host si elle échoue à mi-parcours, et l'hyperviseur est **sans état** — +tout ce qui est posé doit l'être par un mécanisme rejoué à chaque démarrage. + +Plan de contrôle — FRR +---------------------- + +FRR tourne sur chaque hyperviseur et peuple la table de transfert (FDB) des interfaces VXLAN +créées par l'agent. C'est ce qui rend un subnet utilisable au-delà d'un seul nœud, puisque +l'agent désactive l'apprentissage et ne configure aucun voisin — cf. +:doc:`architecture-cluster`. + +.. note:: + + **À rédiger.** À documenter : + + * la version de FRR de référence et son mode d'installation, sachant que l'hyperviseur est + sans état : le paquet et la configuration doivent être posés à chaque démarrage, par le + bootstrap ou par un mécanisme équivalent ; + * les démons activés dans ``/etc/frr/daemons`` ; + * la configuration de référence : numéro d'AS, session vers le route reflector, famille + d'adresses utilisée pour annoncer les MAC et les VNI ; + * l'articulation avec les interfaces créées par l'agent : comment FRR découvre une interface + VXLAN qui apparaît à la création d'un subnet, et si une action est nécessaire ensuite ; + * ce qui se passe au démarrage à froid, quand FRR démarre avant ou après l'agent ; + * le cas particulier du **premier** hyperviseur, dont la session ne peut pas s'établir tant + que le route reflector n'existe pas. + +Vérifier le plan de données +--------------------------- + +Ces deux vérifications restent valables quelle que soit la configuration retenue, et méritent +d'être dans toute procédure de diagnostic : + +.. code-block:: bash + + # La FDB du VXLAN doit contenir des entrées vers les autres hyperviseurs. + # Vide, c'est le plan de contrôle qui ne fonctionne pas, pas l'agent. + ip netns exec bridge fdb show dev + + # L'interface VXLAN telle que l'agent l'a créée : port 4789, learning off, + # aucun groupe multicast, aucun remote. + ip netns exec ip -d link show + +.. important:: + + Une FDB vide alors que le subnet est en ``running`` n'est **pas** un défaut de l'agent : il + crée délibérément l'interface sans apprentissage ni voisin, et laisse le peuplement au plan + de contrôle. + +Valider le nœud +--------------- + +Avant de passer à la suite, le nœud doit savoir créer une VM de bout en bout à partir de l'image +golden — c'est exactement le parcours de :doc:`/demarrage/premier-vpc`, avec +``storage[0].path`` pointant sur une copie de l'image produite par :doc:`image-qcow2`. + +Une VM qui démarre, obtient son adresse en DHCP et applique son user-data valide d'un coup +l'agent, le DHCP, la route vers le serveur de metadata et l'image. C'est le prérequis de +:doc:`route-reflector`. diff --git a/docs/deploiement/route-reflector.rst b/docs/deploiement/route-reflector.rst new file mode 100644 index 0000000..e0095d2 --- /dev/null +++ b/docs/deploiement/route-reflector.rst @@ -0,0 +1,42 @@ +VM route reflector +================== + +Le route reflector est le point de rendez-vous du plan de contrôle : plutôt que de maintenir une +session entre chaque paire d'hyperviseurs, chaque hyperviseur ouvre une session vers le route +reflector, qui redistribue. + +Il tourne lui-même en machine virtuelle, ce qui crée une dépendance circulaire à traiter +explicitement : la VM qui porte le plan de contrôle du cluster est hébergée par le cluster. + +.. note:: + + **À rédiger.** À documenter : + + * la création de la VM : ressources, subnet et mode utilisés, et s'il s'agit d'une VM créée + par l'agent comme les autres ou d'un cas particulier — l'image, elle, est l'image golden de + :doc:`image-qcow2` ; + * son adressage, et comment les hyperviseurs le connaissent ; + * la configuration du démon de routage qu'elle héberge ; + * la redondance : une seule VM route reflector, ou deux, et sur quels hyperviseurs ; + * la procédure de reconstruction, et l'état du cluster pendant que le route reflector est + absent — les tunnels déjà établis continuent-ils de fonctionner, et pendant combien de + temps ; + * la procédure d'amorçage : ce qui fonctionne, et dans quel ordre, quand on démarre un cluster + entier depuis zéro — le premier hyperviseur n'a pas de session FRR établie tant que cette VM + n'existe pas, cf. :doc:`premier-hyperviseur`. + +Points de vigilance +------------------- + +.. warning:: + + L'agent **ne réattache pas** les VM existantes à son démarrage, et les processus QEMU ne + survivent pas à un redémarrage de l'hyperviseur. Le redémarrage de l'hyperviseur qui héberge + le route reflector est donc un événement à part entière : la procédure de remise en service + doit être écrite, et testée. + +.. warning:: + + ``instance-id`` valant le nom de la VM, recréer la VM route reflector sous le même nom sur le + même disque fait que cloud-init **ne rejoue pas** le user-data. Cf. + :doc:`/concepts/metadata-cloud-init`. diff --git a/docs/deploiement/routeurs.rst b/docs/deploiement/routeurs.rst new file mode 100644 index 0000000..76ec2e7 --- /dev/null +++ b/docs/deploiement/routeurs.rst @@ -0,0 +1,51 @@ +Routeurs +======== + +Trois niveaux de routage entourent le cluster, du plus proche des hyperviseurs au plus proche de +l'extérieur. Tous préexistent à l'agent : celui-ci ne les configure pas et n'en a aucune +connaissance. + +Routeurs de cluster + raccordent les hyperviseurs entre eux. C'est le niveau dont dépend directement le plan de + données VXLAN. + +Routeurs de datacentre + agrègent les clusters d'un même site. + +Routeurs de bordure + terminent le routage vers l'extérieur. + +.. note:: + + **À rédiger.** Cette page attend les éléments de terrain. Pour chacun des trois niveaux : + + * le matériel ou le logiciel employé, et la version de référence ; + * la configuration de référence : interfaces, adressage, protocole de routage et numéros + d'AS ; + * la redondance : combien d'équipements, quel mécanisme de bascule, quel comportement attendu + pendant une bascule ; + * ce qui est annoncé et ce qui est filtré à chaque niveau ; + * l'ordre de mise en service, et ce qui doit être opérationnel avant de préparer le premier + hyperviseur. + +Contraintes imposées par le reste du cluster +--------------------------------------------- + +Indépendamment des choix d'équipement, deux contraintes viennent de ce que fait l'agent. + +.. warning:: + + **MTU** — l'agent crée bridges, veth et interfaces VXLAN avec un MTU figé à 1500, et VXLAN + ajoute 50 octets d'encapsulation. Les liens entre hyperviseurs doivent donc accepter au moins + **1550 octets**. Cf. :doc:`architecture-cluster`. + +.. warning:: + + **UDP 4789** doit passer entre hyperviseurs, dans les deux sens : c'est le port des tunnels + VXLAN. + +.. warning:: + + **L'API de l'agent n'a aucune authentification.** Le filtrage réalisé ici est aujourd'hui + l'une des rares barrières entre cette API et le reste du réseau : son port ne doit être + joignable que depuis le réseau d'administration. Cf. :doc:`/exploitation/configuration`. diff --git a/docs/exploitation/api-agent/asynchronisme.rst b/docs/exploitation/api-agent/asynchronisme.rst new file mode 100644 index 0000000..96b5172 --- /dev/null +++ b/docs/exploitation/api-agent/asynchronisme.rst @@ -0,0 +1,91 @@ +Modèle asynchrone et codes de retour +==================================== + +Toute création et toute suppression sont **asynchrones**. C'est le point qui surprend le plus +souvent à l'intégration : un ``202`` ne dit pas que la ressource existe, il dit que la demande +a été acceptée et enregistrée. + +Les deux temps d'une requête +---------------------------- + +.. mermaid:: + + sequenceDiagram + participant C as Appelant + participant A as API + participant W as Worker + C->>A: POST /subnets + A->>A: Prepare — valide, écrit "creating" + A-->>C: 202 + ressource en creating + A->>W: Dispatch (file d'attente) + W->>W: Execute — netns, netif, dnsmasq + W->>W: état → running (ou error) + C->>A: GET /subnets/ + A-->>C: 200 + state + +**Prepare** est synchrone, dans le handler HTTP : il valide l'état, écrit l'état initial en base +et répond. **Execute** est asynchrone : il fait le travail réseau réel, puis positionne l'état +final. + +Conséquence directe : un échec d'``Execute`` ne peut pas être remonté dans la réponse HTTP. Il +se lit dans l'état de la ressource, qui passe à ``error``. + +Attendre correctement +--------------------- + +Il n'y a pas de webhook ni de long-polling : l'appelant interroge ``GET //`` jusqu'à +un état stable. + +.. code-block:: bash + + until [ "$(curl -sf http://127.0.0.1:8080/subnets/sn-000001 | jq -r .state)" = running ]; do + sleep 2 + done + +Trois règles pour un appelant robuste : + +* **Toujours borner l'attente.** Côté agent, ``dispatcher.timeout_seconds`` (300 s par défaut) + borne les opérations qui attendent une transition ; l'appelant doit avoir sa propre borne. +* **Traiter ``error`` comme terminal, pas comme un échec transitoire.** Un ``Execute`` en échec + ne se rejoue pas tout seul. +* **Ne pas enchaîner sans vérifier.** Créer un subnet dont le VPC est encore en ``creating`` + échoue en 422 ; démarrer une VM sur un subnet en ``creating`` ou ``running`` est en revanche + accepté. + +Codes de retour +--------------- + +.. list-table:: + :header-rows: 1 + :widths: 12 88 + + * - Code + - Signification + * - ``202`` + - demande acceptée et enregistrée ; l'état passera à ``running`` ou ``error`` + * - ``200`` + - lecture réussie (``GET``) + * - ``400`` + - champ obligatoire manquant, corps invalide, ``iface_type`` inconnu, base64 invalide + * - ``404`` + - ressource inexistante + * - ``409`` + - conflit d'existence ou d'état : ressource déjà créée, ou suppression depuis un état qui + ne l'autorise pas ; pour un VPC, subnets encore présents + * - ``422`` + - dépendance absente ou pas prête : VPC parent d'un subnet, subnet d'une VM + * - ``500`` + - erreur interne + +Le corps d'erreur est uniforme : ``{"error": "…"}``. + +Idempotence +----------- + +Les créations ne sont **pas** idempotentes : recréer une ressource existante donne 409, pas 202. +Un appelant qui rejoue une requête après un timeout réseau doit donc traiter 409 comme +« déjà fait », après avoir vérifié l'état par un ``GET``. + +Les suppressions depuis l'état ``error`` sont acceptées mais **best-effort** : les ressources +système peuvent n'avoir été créées que partiellement, et il n'y a pas de rollback — voir +:doc:`/concepts/cycle-de-vie`. diff --git a/docs/exploitation/api-agent/index.rst b/docs/exploitation/api-agent/index.rst new file mode 100644 index 0000000..6360016 --- /dev/null +++ b/docs/exploitation/api-agent/index.rst @@ -0,0 +1,21 @@ +API de l'agent +============== + +L'agent expose une API HTTP par hyperviseur. C'est aujourd'hui la seule API de ``two`` ; les +API de niveau supérieur viendront avec les composants d'orchestration, et seront documentées +à part. + +Elle est **machine-to-machine** : elle est consommée par un autre logiciel, pas par un humain. +La validation de cohérence des entrées (format des CIDR, plage des VXLAN ID, convention de +nommage) est à la charge de l'appelant — l'agent ne la refait pas. + +.. warning:: + + Cette API **n'a aucune authentification**. Voir :doc:`/exploitation/configuration` avant de + l'exposer au-delà de la boucle locale. + +.. toctree:: + :maxdepth: 1 + + asynchronisme + reference diff --git a/docs/exploitation/api-agent/reference.rst b/docs/exploitation/api-agent/reference.rst new file mode 100644 index 0000000..448b4b7 --- /dev/null +++ b/docs/exploitation/api-agent/reference.rst @@ -0,0 +1,8 @@ +Référence +========= + +Cette page est générée depuis ``api/agent.yaml``, à la racine du dépôt. C'est la source unique +du contrat : en cas d'écart avec le reste de la documentation, c'est elle qui fait foi. + +.. openapi:: ../../../api/agent.yaml + :examples: diff --git a/docs/exploitation/configuration.rst b/docs/exploitation/configuration.rst new file mode 100644 index 0000000..63b29a9 --- /dev/null +++ b/docs/exploitation/configuration.rst @@ -0,0 +1,138 @@ +Configuration +============= + +Un seul fichier, ``/etc/two/agent.yml``, partagé par les trois binaires : ``agent -config``, +``metadata -conf`` et ``db -conf``. Le fichier de référence commenté est +``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. + +.. danger:: + + **L'API de l'agent n'a aucune authentification.** L'exemple livré écoute sur + ``0.0.0.0:8080`` : quiconque atteint ce port peut créer et détruire des VM et des réseaux sur + l'host KVM, c'est-à-dire en prendre le contrôle. + + Sur tout déploiement réel : restreindre ``api.address`` à une adresse d'administration, ou + filtrer le port en amont (pare-feu, réseau dédié). Traiter l'ouverture de ce port comme une + décision d'architecture, pas comme un réglage. + +Base de données +--------------- + +.. code-block:: yaml + + database: + path: "/var/lib/two/data/" + +Répertoire de la base clé-valeur Badger. **Un seul processus l'ouvre** : l'agent. Ni le serveur +de metadata ni aucun autre outil ne doit être configuré pour ouvrir le même répertoire pendant +que l'agent tourne. + +Serveurs +-------- + +.. code-block:: yaml + + api: + address: "0.0.0.0" + port: 8080 + + prometheus: + address: "0.0.0.0" + port: 9090 + + admin: + enabled: false + address: "127.0.0.1" + port: 9091 + +``admin`` expose une inspection en lecture seule de la base (``/db?prefix=…``). Elle est +désactivée par défaut et prévue pour la boucle locale uniquement. + +.. warning:: + + Le contenu de la base inclut ``vm//password``, qui est un hash de mot de passe. + L'activation de l'API d'administration rend ces valeurs lisibles par tout ce qui atteint le + port. Ne pas l'exposer hors de la boucle locale. + +Exécution des commandes +----------------------- + +.. code-block:: yaml + + worker: + count: 4 + buffer_size: 100 + + dispatcher: + timeout_seconds: 300 + poll_seconds: 2 + +``worker.count`` est le nombre de goroutines qui exécutent les commandes ; ``buffer_size`` le +nombre de commandes en attente au-delà duquel ``Dispatch`` bloque. + +``dispatcher.timeout_seconds`` borne les opérations qui attendent une transition d'état, dont +l'extinction d'une VM. + +.. warning:: + + À l'expiration de ce délai, une VM qui ne s'est pas éteinte reçoit un ``quit`` QMP — un arrêt + **brutal**. Pour des charges dont l'extinction est lente (bases de données, construction + d'images), une valeur confortable évite un système de fichiers invité incohérent. + +Correspondance des interfaces +----------------------------- + +.. code-block:: yaml + + default_interface: br-000000 + + interfaces: + vms: br-000000 + internet: br-000000 + admin: br-000000 + +Traduit les clés logiques ``iface_type`` de l'API vers les bridges physiques de l'host. Une clé +inconnue ou omise retombe silencieusement sur ``default_interface`` — ce n'est pas une erreur, +mais c'est une source de subnets branchés au mauvais endroit sans le dire. + +Metadata et QEMU +---------------- + +.. code-block:: yaml + + metadata: + run_dir: "/run/two/metadata" + + qemu: + ovmf_code_path: "/usr/share/OVMF/OVMF_CODE.fd" + ovmf_vars_template: "/usr/share/OVMF/OVMF_VARS.fd" + uefi_vars_dir: "/run/two/vms/efi" + serial_dir: "/run/two/vms/serial" + monitor_dir: "/run/two/vms/monitor" + qmp_dir: "/run/two/vms/qmp" + +Les chemins OVMF sont nécessaires aux VM démarrées avec ``uefi: true`` (paquet ``ovmf`` sur +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. + +Watchdog +-------- + +.. code-block:: yaml + + watchdog: + enabled: true + interval_seconds: 60 + +Vérification périodique **en lecture seule** — voir :doc:`/exploitation/observabilite`. + +Journalisation +-------------- + +.. code-block:: yaml + + logger: + level: info # debug, info, warn, error + debug: false # force le niveau debug quel que soit level diff --git a/docs/exploitation/diagnostic.rst b/docs/exploitation/diagnostic.rst new file mode 100644 index 0000000..89f7b81 --- /dev/null +++ b/docs/exploitation/diagnostic.rst @@ -0,0 +1,122 @@ +Diagnostic +========== + +Symptôme, cause probable, vérification. Les causes listées sont celles réellement rencontrées. + +La ressource part en ``error`` juste après le 202 +------------------------------------------------- + +``Execute`` a échoué : la cause est dans le journal de l'agent, pas dans la réponse HTTP. + +.. code-block:: bash + + journalctl -u agent -n 100 + +Cas fréquents : + +* subnet en mode ``public_ip`` — la mise en place host n'est pas implémentée, l'échec est attendu ; +* ``vxlan_id`` déjà utilisé sur l'host ; +* bridge cible absent : ``iface_type`` inconnu retombé sur ``default_interface``, lui-même + inexistant. + +Avant toute recréation, émettre un ``DELETE`` : il n'y a pas de rollback, les objets système +partiellement créés subsistent. + +La VM démarre mais n'a pas d'adresse +------------------------------------ + +Le DHCP est servi par l'instance ``dnsmasq@`` du subnet. + +.. code-block:: bash + + systemctl status 'dnsmasq@_' + tail -50 /var/log/dnsmasq-_.log + cat /run/dnsmasq-_.leases + cat /etc/dnsmasq.d/_.conf + +Si dnsmasq ne voit passer aucune requête, le problème est en amont : tap absent, bridge non +raccordé, VM dans le mauvais netns. + +La VM a une adresse mais cloud-init n'applique rien +--------------------------------------------------- + +Deux causes distinctes, à écarter dans cet ordre. + +**1. La VM porte déjà cet ``instance-id``.** ``instance-id`` vaut le nom de la VM : sur un disque +déjà provisionné sous le même nom, cloud-init considère l'instance connue et ne rejoue pas le +user-data. Vérification dans le guest : + +.. code-block:: bash + + cloud-init query instance-id + ls /var/lib/cloud/instances/ + +**2. Le serveur de metadata est injoignable.** Depuis le guest : + +.. code-block:: bash + + ip route + curl -s http://169.254.169.254/latest/meta-data/ + +La route ``169.254.169.254/32`` doit être présente, avec l'``interface_ip`` du subnet comme +next-hop. Si elle est absente ou pointe ailleurs, la DNAT posée en ``PREROUTING`` dans le netns +n'est jamais traversée : la trame est commutée en L2 et le serveur reste injoignable. Voir +:doc:`/concepts/modes-reseau`. + +Depuis l'host, l'instance correspondante : + +.. code-block:: bash + + systemctl status 'metadata@' + ls -l /run/two/metadata// + +Le user-data est servi vide +--------------------------- + +Un document fourni explicitement vide est servi vide — ce n'est pas la même chose qu'un document +absent, qui retombe sur le template. Vérifier le contenu réellement écrit : + +.. code-block:: bash + + cat /run/two/metadata//user-data + +Un base64 invalide, lui, aurait été rejeté en 400 à la création. + +La VM ne démarre pas (UEFI) +--------------------------- + +``uefi: true`` exige les fichiers OVMF déclarés dans la configuration : + +.. code-block:: bash + + ls -l /usr/share/OVMF/OVMF_CODE.fd /usr/share/OVMF/OVMF_VARS.fd + ls -l /run/two/vms/efi/ + +Sur Debian et Ubuntu, le paquet est ``ovmf``. + +Le ``DELETE`` renvoie 409 +------------------------- + +La suppression n'est autorisée que depuis ``running`` ou ``error``. Depuis ``creating`` ou +``deleting``, attendre l'état stable. Pour un VPC, tous les subnets doivent être supprimés +d'abord. + +La base et le système ont divergé +--------------------------------- + +Le watchdog signale une ressource ``running`` absente du système. Il ne répare rien : la +correction est un ``DELETE`` explicite suivi d'une recréation. Après un redémarrage de l'agent, +les ressources restées transitoires sont basculées en ``error`` par la migration de démarrage — +elles n'ont pas forcément échoué, elles ont été interrompues. + +L'agent ne redémarre pas après un arrêt brutal +---------------------------------------------- + +Badger rejoue son journal au démarrage : c'est normal et attendu, notamment si le budget d'arrêt +précédent a été dépassé et que la base n'a pas été fermée. Si le démarrage échoue vraiment, le +message se trouve dans ``journalctl -u agent``. + +.. note:: + + Les VM ne sont pas réattachées au redémarrage de l'agent : un processus QEMU survivant à + l'agent n'est plus piloté par lui. diff --git a/docs/exploitation/index.rst b/docs/exploitation/index.rst new file mode 100644 index 0000000..58915bc --- /dev/null +++ b/docs/exploitation/index.rst @@ -0,0 +1,14 @@ +Exploitation +============ + +Faire tourner un hyperviseur en service : configuration, services systemd, API de l'agent, +observabilité et diagnostic. + +.. toctree:: + :maxdepth: 1 + + configuration + services + api-agent/index + observabilite + diagnostic diff --git a/docs/exploitation/observabilite.rst b/docs/exploitation/observabilite.rst new file mode 100644 index 0000000..05392d1 --- /dev/null +++ b/docs/exploitation/observabilite.rst @@ -0,0 +1,83 @@ +Observabilité +============= + +Métriques Prometheus +-------------------- + +Exposées sur le port ``prometheus.port`` (9090 par défaut), alimentées par l'état lu en base : + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Métrique + - Description + * - ``syonad_vpcs_total`` + - nombre de VPC, par état + * - ``syonad_subnets_total`` + - nombre de subnets, par état + * - ``syonad_vms_total`` + - nombre de VM, par état + +Les états sont ceux du :doc:`cycle de vie `. Une valeur non nulle et +durable sur ``error`` est l'alerte la plus utile à poser ; une valeur durable sur ``creating`` +ou ``deleting`` signale une opération qui n'aboutit pas. + +Watchdog +-------- + +Une goroutine périodique vérifie que les ressources marquées ``running`` en base existent +toujours sur le système, et **notifie les écarts sans jamais réparer**. + +.. code-block:: yaml + + watchdog: + enabled: true + interval_seconds: 60 + +Il contrôle notamment l'existence des network namespaces, des liens réseau des subnets, des taps +de VM et la réponse des units systemd associées. + +.. note:: + + Un écart persistant est signalé **à chaque tick**, sans déduplication. Le volume de + notifications est donc proportionnel à la durée de l'anomalie : c'est voulu, mais cela veut + dire qu'une alerte doit agréger, pas compter. + +Le watchdog étant strictement en lecture seule, une divergence entre la base et le système +subsiste jusqu'à une action explicite (``DELETE`` puis recréation). + +Journaux +-------- + +``slog`` structuré, niveau réglé par ``logger.level`` (``debug``, ``info``, ``warn``, ``error``), +``logger.debug: true`` forçant ``debug``. + +.. code-block:: bash + + journalctl -u agent -f + journalctl -u 'metadata@i-web' -n 50 + tail -f /var/log/dnsmasq-vp-admin_br-sn000001.log + +Inspection de la base +--------------------- + +En ligne de commande, sur l'host : + +.. code-block:: bash + + /opt/two/bin/db -conf /etc/two/agent.yml + +.. warning:: + + ``db`` ouvre directement la base Badger. **Ne pas l'utiliser pendant que l'agent tourne** : + deux processus ne doivent pas ouvrir la même instance. + +L'API d'administration donne la même lecture sans ce risque, quand elle est activée : + +.. code-block:: bash + + curl -s 'http://127.0.0.1:9091/db?prefix=vm/' + +Elle expose l'intégralité des valeurs, **y compris les hashs de mot de passe** — cf. +:doc:`/exploitation/configuration`. diff --git a/docs/exploitation/services.rst b/docs/exploitation/services.rst new file mode 100644 index 0000000..4e096cc --- /dev/null +++ b/docs/exploitation/services.rst @@ -0,0 +1,91 @@ +Services systemd +================ + +Trois units, installées sous ``/opt/two/bin`` par ``deploy.sh``. + +.. list-table:: + :header-rows: 1 + :widths: 28 32 40 + + * - Unit + - Instance ``%i`` + - Rôle + * - ``agent.service`` + - — + - processus principal : API, dispatcher, exécution, watchdog + * - ``dnsmasq@.service`` + - ``_`` + - dnsmasq lancé dans le netns du VPC, un par subnet + * - ``metadata@.service`` + - ```` + - serveur de metadata cloud-init, un par VM + +Les instances sont créées et pilotées par l'agent au fil des créations de subnets et de VM : il +n'y a pas à les démarrer à la main en fonctionnement normal. + +.. code-block:: bash + + systemctl status agent + systemctl status 'dnsmasq@vp-admin_br-sn000001' + systemctl status 'metadata@i-web' + +dnsmasq +------- + +Le script ``run-dnsmasq-in-netns.sh`` entre dans le netns puis exécute dnsmasq avec un fichier +de configuration par subnet, généré par l'agent : + +.. list-table:: + :widths: 40 60 + + * - Configuration + - ``/etc/dnsmasq.d/_.conf`` + * - Baux + - ``/run/dnsmasq-_.leases`` + * - Journal + - ``/var/log/dnsmasq-_.log`` + * - PID + - ``/run/dnsmasq-_.pid`` + +Le fichier de baux et le journal sont les deux premiers endroits à regarder quand une VM n'obtient +pas d'adresse. + +QEMU n'est pas une unit +----------------------- + +Les processus QEMU sont lancés par ``systemd-run --scope``, **jamais** en unit transitoire. Un +scope est exécuté par le processus appelant et hérite donc du network namespace posé par +l'agent ; une unit transitoire, forkée par PID 1, démarrerait dans le netns racine et ne verrait +pas le tap de la VM. + +Conséquence pratique : les VM n'apparaissent pas dans ``systemctl list-units`` mais dans +``systemd-cgls``, et elles ne survivent pas à un ``systemctl stop agent`` suivi d'un +redémarrage — l'agent ne réattache pas les VM existantes. + +Sockets par VM +-------------- + +.. code-block:: text + + /run/two/vms/serial/.sock console série + /run/two/vms/monitor/.sock monitor QEMU + /run/two/vms/qmp/.sock QMP (utilisé par l'agent) + +.. code-block:: bash + + socat -,raw,echo=0 UNIX-CONNECT:/run/two/vms/serial/i-web.sock + +Arrêt de l'agent +---------------- + +L'ordre d'arrêt est imposé : serveurs HTTP, puis drainage des workers, puis fermeture de la base. +Si le budget d'arrêt est dépassé, la base **n'est pas fermée** — fermer Badger sous un écrivain +concurrent est pire qu'un rejeu du journal au démarrage suivant. Un message à ce sujet dans le +journal au moment d'un ``stop`` n'est donc pas une anomalie. + +Mise à jour +----------- + +``deploy.sh`` relève les instances ``dnsmasq@`` et ``metadata@`` actives **avant** d'arrêter les +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/index.rst b/docs/index.rst new file mode 100644 index 0000000..b72be04 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,66 @@ +two +=== + +**two** est un orchestrateur de virtualisation et de réseau : il pilote un parc d'hyperviseurs, +le réseau qui les relie, et les machines virtuelles qui y tournent. + +Il se compose de plusieurs éléments, déployés et versionnés séparément. + +.. list-table:: + :header-rows: 1 + :widths: 22 58 20 + + * - Composant + - Rôle + - État + * - **agent** + - un par hyperviseur : expose une API HTTP qui crée des VPC — isolés par network + namespace —, des subnets — VXLAN ou bridge — et des VM QEMU/KVM raccordées à ces + subnets, avec DHCP, routage et metadata cloud-init + - livré (0.1.0) + * - *à venir* + - les composants de niveau supérieur — ordonnancement sur le parc, API d'orchestration, + interface d'administration — sont à documenter au fur et à mesure de leur livraison + - à venir + +À ce stade, la totalité de cette documentation porte donc sur l'**agent** et sur le réseau du +cluster qui l'entoure. + +Par où commencer +---------------- + +:doc:`/demarrage/index` + Installer l'agent sur un hyperviseur et créer un premier VPC, un subnet et une VM. C'est le + parcours court, sur un nœud isolé. + +:doc:`/deploiement/index` + L'architecture complète : réseau du cluster, routage, et ce qu'il faut mettre en place avant + qu'un parc d'hyperviseurs fonctionne ensemble. + +:doc:`/exploitation/index` + Configuration, services, API de l'agent, métriques et diagnostic sur un nœud en service. + +:doc:`/concepts/index` + Comment les éléments fonctionnent entre eux : modèle de données, modes réseau, cycle de vie, + metadata. À lire avant de diagnostiquer un comportement inattendu. + +.. toctree:: + :hidden: + :caption: Mise en œuvre + + demarrage/index + deploiement/index + +.. toctree:: + :hidden: + :caption: Exploitation + + exploitation/index + +.. toctree:: + :hidden: + :caption: Interne + + concepts/index + architecture/index + versions/index diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..88e210b --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,6 @@ +sphinx==9.1.0 +sphinx-autobuild==2025.8.25 +sphinx-book-theme==1.2.0 +myst-parser==5.1.0 +sphinxcontrib.mermaid==2.0.2 +sphinxcontrib-openapi==0.9.0 diff --git a/docs/schemas/topologie-cluster-dark.svg b/docs/schemas/topologie-cluster-dark.svg new file mode 100644 index 0000000..48a3916 --- /dev/null +++ b/docs/schemas/topologie-cluster-dark.svg @@ -0,0 +1,52 @@ + + + + + ROUTEURS DE CLUSTER + + routeur A + + routeur B + + + VM route reflector + plan de contrôle + + + HYPERVISEUR 1 + + br-000000 + + FRR + + agent + + + + + HYPERVISEUR 2 + + br-000000 + + FRR + + agent + + + + + + uplink + uplink + + + + diff --git a/docs/schemas/topologie-cluster.svg b/docs/schemas/topologie-cluster.svg new file mode 100644 index 0000000..858d18a --- /dev/null +++ b/docs/schemas/topologie-cluster.svg @@ -0,0 +1,52 @@ + + + + + ROUTEURS DE CLUSTER + + routeur A + + routeur B + + + VM route reflector + plan de contrôle + + + HYPERVISEUR 1 + + br-000000 + + FRR + + agent + + + + + HYPERVISEUR 2 + + br-000000 + + FRR + + agent + + + + + + uplink + uplink + + + + diff --git a/docs/versions/0.1.0.md b/docs/versions/0.1.0.md new file mode 100644 index 0000000..f1783b0 --- /dev/null +++ b/docs/versions/0.1.0.md @@ -0,0 +1,2 @@ +```{include} ../../release_notes/0.1.0.md +``` diff --git a/docs/versions/index.rst b/docs/versions/index.rst new file mode 100644 index 0000000..4333ae4 --- /dev/null +++ b/docs/versions/index.rst @@ -0,0 +1,12 @@ +Versions +======== + +Chaque version porte un nom de code dérivé du rang de sa publication : anges et démons alternés. + +.. toctree:: + :maxdepth: 1 + + 0.1.0 + +.. include:: ../../release_notes/codenames.md + :parser: myst_parser.sphinx_ diff --git a/go.mod b/go.mod index a4b0b38..0e59eb9 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,24 @@ module git.g3e.fr/syonad/two -go 1.24.0 +go 1.25.0 -toolchain go1.24.11 +toolchain go1.25.14 + +require ( + github.com/coreos/go-systemd/v22 v22.6.0 + github.com/dgraph-io/badger/v4 v4.8.0 + github.com/insomniacslk/dhcp v0.0.0-20260831074340-8416b400a2b2 + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 + github.com/spf13/viper v1.21.0 + github.com/vishvananda/netlink v1.3.1 + github.com/vishvananda/netns v0.0.5 + golang.org/x/sys v0.45.0 +) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/coreos/go-systemd/v22 v22.6.0 // indirect - github.com/dgraph-io/badger/v4 v4.8.0 // indirect github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -17,11 +27,11 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect + github.com/josharian/native v1.1.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect + github.com/pierrec/lz4/v4 v4.1.14 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect @@ -29,18 +39,15 @@ require ( github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/spf13/viper v1.21.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/vishvananda/netlink v1.3.1 // indirect - github.com/vishvananda/netns v0.0.5 // indirect + github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/go.sum b/go.sum index 402452d..271450a 100644 --- a/go.sum +++ b/go.sum @@ -4,12 +4,18 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs= github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w= github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -23,12 +29,29 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/insomniacslk/dhcp v0.0.0-20260831074340-8416b400a2b2 h1:rhbGNh5bIA6JYahHdphjFiIbavqVspsvXFvTpBR7dQE= +github.com/insomniacslk/dhcp v0.0.0-20260831074340-8416b400a2b2/go.mod h1:tGfUTcnFYGYvVNCaZZhwlJySU/fQQxh9TmpsFzWXnnY= +github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= +github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE= +github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -37,6 +60,8 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -49,8 +74,12 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA= +github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= @@ -63,24 +92,25 @@ go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/Wgbsd go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/dhcp/convert.go b/internal/api/dhcp/convert.go new file mode 100644 index 0000000..77dd7c4 --- /dev/null +++ b/internal/api/dhcp/convert.go @@ -0,0 +1,83 @@ +package dhcpapi + +import ( + "fmt" + "net" + + "git.g3e.fr/syonad/two/internal/dhcpd" +) + +func (s Subnet) toConfig() (dhcpd.SubnetConfig, error) { + _, network, err := net.ParseCIDR(s.Network) + if err != nil { + return dhcpd.SubnetConfig{}, fmt.Errorf("invalid network %q: %w", s.Network, err) + } + interfaceIP := net.ParseIP(s.InterfaceIP) + if interfaceIP == nil { + return dhcpd.SubnetConfig{}, fmt.Errorf("invalid interface ip %q", s.InterfaceIP) + } + + c := dhcpd.SubnetConfig{Network: network, InterfaceIP: interfaceIP} + + if s.VPCRoute != "" { + if _, c.VPCRoute, err = net.ParseCIDR(s.VPCRoute); err != nil { + return dhcpd.SubnetConfig{}, fmt.Errorf("invalid vpc route %q: %w", s.VPCRoute, err) + } + } + if s.DefaultGateway != "" { + if c.DefaultGateway = net.ParseIP(s.DefaultGateway); c.DefaultGateway == nil { + return dhcpd.SubnetConfig{}, fmt.Errorf("invalid default gateway %q", s.DefaultGateway) + } + } + return c, nil +} + +func (h Host) toHost() (dhcpd.Host, error) { + mac, err := net.ParseMAC(h.MAC) + if err != nil { + return dhcpd.Host{}, fmt.Errorf("invalid mac %q: %w", h.MAC, err) + } + ip := net.ParseIP(h.IP) + if ip == nil { + return dhcpd.Host{}, fmt.Errorf("invalid host ip %q", h.IP) + } + return dhcpd.Host{MAC: mac, IP: ip, VM: h.VM, DefaultRoute: h.DefaultRoute}, nil +} + +func subnetFromConfig(c dhcpd.SubnetConfig) Subnet { + s := Subnet{ + Network: c.Network.String(), + InterfaceIP: c.InterfaceIP.String(), + } + if c.VPCRoute != nil { + s.VPCRoute = c.VPCRoute.String() + } + if c.DefaultGateway != nil { + s.DefaultGateway = c.DefaultGateway.String() + } + return s +} + +func hostFromHost(h dhcpd.Host) Host { + return Host{ + MAC: h.MAC.String(), + IP: h.IP.String(), + VM: h.VM, + DefaultRoute: h.DefaultRoute, + } +} + +func stateFromStore(store *dhcpd.Store) State { + state := State{Hosts: make([]Host, 0)} + + if config, configured := store.Subnet(); configured { + subnet := subnetFromConfig(config) + state.Subnet = &subnet + } + for _, h := range store.Hosts() { + state.Hosts = append(state.Hosts, hostFromHost(h)) + } + SortHosts(state.Hosts) + + return state +} diff --git a/internal/api/dhcp/digest.go b/internal/api/dhcp/digest.go new file mode 100644 index 0000000..f311b22 --- /dev/null +++ b/internal/api/dhcp/digest.go @@ -0,0 +1,116 @@ +package dhcpapi + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "sort" +) + +func canonicalMAC(s string) (string, error) { + mac, err := net.ParseMAC(s) + if err != nil { + return "", fmt.Errorf("invalid mac %q: %w", s, err) + } + return mac.String(), nil +} + +func canonicalIP(s string) (string, error) { + ip := net.ParseIP(s) + if ip == nil { + return "", fmt.Errorf("invalid ip %q", s) + } + return ip.String(), nil +} + +func canonicalCIDR(s string) (string, error) { + _, network, err := net.ParseCIDR(s) + if err != nil { + return "", fmt.Errorf("invalid cidr %q: %w", s, err) + } + return network.String(), nil +} + +func SortHosts(hosts []Host) { + sort.Slice(hosts, func(i, j int) bool { return hosts[i].MAC < hosts[j].MAC }) +} + +func CanonicalSubnet(s Subnet) (Subnet, error) { + network, err := canonicalCIDR(s.Network) + if err != nil { + return Subnet{}, err + } + interfaceIP, err := canonicalIP(s.InterfaceIP) + if err != nil { + return Subnet{}, err + } + + out := Subnet{Network: network, InterfaceIP: interfaceIP} + + if s.VPCRoute != "" { + if out.VPCRoute, err = canonicalCIDR(s.VPCRoute); err != nil { + return Subnet{}, err + } + } + if s.DefaultGateway != "" { + if out.DefaultGateway, err = canonicalIP(s.DefaultGateway); err != nil { + return Subnet{}, err + } + } + return out, nil +} + +func CanonicalHost(h Host) (Host, error) { + mac, err := canonicalMAC(h.MAC) + if err != nil { + return Host{}, err + } + ip, err := canonicalIP(h.IP) + if err != nil { + return Host{}, err + } + return Host{MAC: mac, IP: ip, VM: h.VM, DefaultRoute: h.DefaultRoute}, nil +} + +func Canonical(s State) (State, error) { + out := State{Hosts: make([]Host, 0, len(s.Hosts))} + + if s.Subnet != nil { + subnet, err := CanonicalSubnet(*s.Subnet) + if err != nil { + return State{}, err + } + out.Subnet = &subnet + } + + seen := make(map[string]struct{}, len(s.Hosts)) + for _, h := range s.Hosts { + host, err := CanonicalHost(h) + if err != nil { + return State{}, err + } + if _, dup := seen[host.MAC]; dup { + return State{}, fmt.Errorf("duplicate mac %s", host.MAC) + } + seen[host.MAC] = struct{}{} + out.Hosts = append(out.Hosts, host) + } + SortHosts(out.Hosts) + + return out, nil +} + +func Digest(s State) (string, error) { + canonical, err := Canonical(s) + if err != nil { + return "", err + } + raw, err := json.Marshal(canonical) + if err != nil { + return "", fmt.Errorf("encode state: %w", err) + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} diff --git a/internal/api/dhcp/digest_test.go b/internal/api/dhcp/digest_test.go new file mode 100644 index 0000000..fe5dd29 --- /dev/null +++ b/internal/api/dhcp/digest_test.go @@ -0,0 +1,173 @@ +package dhcpapi + +import ( + "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) + } +} diff --git a/internal/api/dhcp/models.go b/internal/api/dhcp/models.go new file mode 100644 index 0000000..b0268cf --- /dev/null +++ b/internal/api/dhcp/models.go @@ -0,0 +1,58 @@ +package dhcpapi + +type Verb string + +const ( + VerbSetSubnet Verb = "set-subnet" + VerbSetHost Verb = "set-host" + VerbDelHost Verb = "del-host" + VerbGetState Verb = "get-state" + VerbProbe Verb = "probe" +) + +const MaxMessageBytes = 64 * 1024 + +type Subnet struct { + Network string `json:"network"` + InterfaceIP string `json:"interface_ip"` + VPCRoute string `json:"vpc_route,omitempty"` + DefaultGateway string `json:"default_gateway,omitempty"` +} + +type Host struct { + MAC string `json:"mac"` + IP string `json:"ip"` + VM string `json:"vm,omitempty"` + DefaultRoute bool `json:"default_route"` +} + +type State struct { + Subnet *Subnet `json:"subnet,omitempty"` + Hosts []Host `json:"hosts"` +} + +type Lease struct { + MAC string `json:"mac"` + IP string `json:"ip"` + Netmask string `json:"netmask"` + Router string `json:"router,omitempty"` + DNS []string `json:"dns"` + Routes []string `json:"routes"` + LeaseSeconds uint32 `json:"lease_seconds"` +} + +type Request struct { + Verb Verb `json:"verb"` + Subnet *Subnet `json:"subnet,omitempty"` + Host *Host `json:"host,omitempty"` + MAC string `json:"mac,omitempty"` +} + +type Response struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + State *State `json:"state,omitempty"` + Digest string `json:"digest,omitempty"` + Lease *Lease `json:"lease,omitempty"` + Served bool `json:"served,omitempty"` +} diff --git a/internal/api/dhcp/paths.go b/internal/api/dhcp/paths.go new file mode 100644 index 0000000..fcd5b09 --- /dev/null +++ b/internal/api/dhcp/paths.go @@ -0,0 +1,29 @@ +package dhcpapi + +import ( + "path/filepath" +) + +const ( + DefaultRunDir = "/run/two/dhcp" + SocketExt = ".sock" + StateExt = ".state" + UnitExt = ".service" + UnitName = "dhcp@" +) + +func Instance(vpc, bridge string) string { + return vpc + "_" + bridge +} + +func Unit(instance string) string { + return UnitName + instance + UnitExt +} + +func SocketPath(runDir, instance string) string { + return filepath.Join(runDir, instance+SocketExt) +} + +func StatePath(runDir, instance string) string { + return filepath.Join(runDir, instance+StateExt) +} diff --git a/internal/api/dhcp/paths_test.go b/internal/api/dhcp/paths_test.go new file mode 100644 index 0000000..8ad3658 --- /dev/null +++ b/internal/api/dhcp/paths_test.go @@ -0,0 +1,69 @@ +package dhcpapi + +import ( + "os" + "strings" + "testing" +) + +func TestInstance_JoinsVPCAndBridge(t *testing.T) { + if got := Instance("vp-admin", "br-000001"); got != "vp-admin_br-000001" { + t.Errorf("Instance = %s, want vp-admin_br-000001", got) + } +} + +func TestUnit_NamesTheTemplatedService(t *testing.T) { + if got := Unit(Instance("vp-admin", "br-000001")); got != "dhcp@vp-admin_br-000001.service" { + t.Errorf("Unit = %s", got) + } +} + +func TestSocketPath_SitsUnderTheRunDir(t *testing.T) { + got := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000001")) + if got != "/run/two/dhcp/vp-admin_br-000001.sock" { + t.Errorf("SocketPath = %s", got) + } +} + +func TestStatePath_SitsUnderTheRunDir(t *testing.T) { + got := StatePath(DefaultRunDir, Instance("vp-admin", "br-000001")) + if got != "/run/two/dhcp/vp-admin_br-000001.state" { + t.Errorf("StatePath = %s", got) + } +} + +func TestPaths_NameTheVPCSoAListingIsReadable(t *testing.T) { + got := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000001")) + if !strings.Contains(got, "vp-admin") { + t.Errorf("path = %s, want the vpc visible when listing the run dir", got) + } +} + +func TestPaths_DistinguishTwoSubnetsOfTheSameVPC(t *testing.T) { + a := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000001")) + b := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000002")) + if a == b { + t.Error("two subnets must not share a control socket") + } +} + +func TestSocketPath_StaysUnderTheUnixPathLimit(t *testing.T) { + got := SocketPath(DefaultRunDir, Instance("vp-000000", "br-000000")) + if len(got) > 100 { + t.Errorf("socket path is %d bytes (%s): sun_path caps at 104 on darwin and 108 on linux", len(got), got) + } +} + +func TestDefaultRunDir_MatchesTheWrapperScript(t *testing.T) { + const script = "../../../scripts/run-dhcp-in-netns.sh" + + raw, err := os.ReadFile(script) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + + want := `RUN_DIR="` + DefaultRunDir + `"` + if !strings.Contains(string(raw), want) { + t.Errorf("%s does not set %s: the agent would talk to a socket the server never creates", script, want) + } +} diff --git a/internal/api/dhcp/server.go b/internal/api/dhcp/server.go new file mode 100644 index 0000000..879ac58 --- /dev/null +++ b/internal/api/dhcp/server.go @@ -0,0 +1,190 @@ +package dhcpapi + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "os" + "path/filepath" + + "git.g3e.fr/syonad/two/internal/dhcpd" + "git.g3e.fr/syonad/two/pkg/db/statefile" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +const SocketMode = 0o600 + +type Server struct { + store *dhcpd.Store + listener net.Listener + logger *slog.Logger +} + +func Listen(store *dhcpd.Store, path string, logger *slog.Logger) (*Server, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, statefile.DirMode); err != nil { + return nil, fmt.Errorf("create %s: %w", dir, err) + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("remove stale socket %s: %w", path, err) + } + + listener, err := net.Listen("unix", path) + if err != nil { + return nil, fmt.Errorf("listen on %s: %w", path, err) + } + if err := os.Chmod(path, SocketMode); err != nil { + listener.Close() + return nil, fmt.Errorf("chmod %s: %w", path, err) + } + + return &Server{store: store, listener: listener, logger: logger}, nil +} + +func (s *Server) Addr() string { + return s.listener.Addr().String() +} + +func (s *Server) Close() error { + return s.listener.Close() +} + +func (s *Server) Serve() error { + for { + conn, err := s.listener.Accept() + if err != nil { + return err + } + go s.handleConn(conn) + } +} + +func (s *Server) handleConn(conn net.Conn) { + defer conn.Close() + defer func() { + if r := recover(); r != nil { + s.logger.Error("control connection panicked", "panic", r) + } + }() + + scanner := bufio.NewScanner(conn) + scanner.Buffer(make([]byte, 0, 4096), MaxMessageBytes) + encoder := json.NewEncoder(conn) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var req Request + if err := json.Unmarshal(line, &req); err != nil { + if err := encoder.Encode(failure(fmt.Errorf("malformed request: %w", err))); err != nil { + return + } + continue + } + + if err := encoder.Encode(s.dispatch(req)); err != nil { + return + } + } + if err := scanner.Err(); err != nil { + s.logger.Error("control connection read failed", "error", err) + } +} + +func failure(err error) Response { + return Response{OK: false, Error: err.Error()} +} + +func (s *Server) dispatch(req Request) Response { + switch req.Verb { + case VerbSetSubnet: + if req.Subnet == nil { + return failure(errors.New("set-subnet requires a subnet")) + } + config, err := req.Subnet.toConfig() + if err != nil { + return failure(err) + } + if err := s.store.SetSubnet(config); err != nil { + return failure(err) + } + return Response{OK: true} + + case VerbSetHost: + if req.Host == nil { + return failure(errors.New("set-host requires a host")) + } + host, err := req.Host.toHost() + if err != nil { + return failure(err) + } + if err := s.store.SetHost(host); err != nil { + return failure(err) + } + return Response{OK: true} + + case VerbDelHost: + mac, err := net.ParseMAC(req.MAC) + if err != nil { + return failure(fmt.Errorf("invalid mac %q: %w", req.MAC, err)) + } + if err := s.store.DelHost(mac); err != nil { + return failure(err) + } + return Response{OK: true} + + case VerbGetState: + state := stateFromStore(s.store) + digest, err := Digest(state) + if err != nil { + return failure(err) + } + return Response{OK: true, State: &state, Digest: digest} + + case VerbProbe: + mac, err := net.ParseMAC(req.MAC) + if err != nil { + return failure(fmt.Errorf("invalid mac %q: %w", req.MAC, err)) + } + reply, err := s.store.Probe(mac) + if err != nil { + return failure(err) + } + if reply == nil { + return Response{OK: true, Served: false} + } + return Response{OK: true, Served: true, Lease: leaseFromReply(mac, reply)} + + default: + return failure(fmt.Errorf("unknown verb %q", req.Verb)) + } +} + +func leaseFromReply(mac net.HardwareAddr, reply *dhcpv4.DHCPv4) *Lease { + lease := &Lease{ + MAC: mac.String(), + IP: reply.YourIPAddr.String(), + Netmask: net.IP(reply.SubnetMask()).String(), + DNS: make([]string, 0, 2), + Routes: make([]string, 0, 3), + LeaseSeconds: uint32(reply.IPAddressLeaseTime(0).Seconds()), + } + + if routers := reply.Router(); len(routers) > 0 { + lease.Router = routers[0].String() + } + for _, dns := range reply.DNS() { + lease.DNS = append(lease.DNS, dns.String()) + } + for _, route := range reply.ClasslessStaticRoute() { + lease.Routes = append(lease.Routes, route.Dest.String()+" via "+route.Router.String()) + } + return lease +} diff --git a/internal/client/dhcp/client.go b/internal/client/dhcp/client.go new file mode 100644 index 0000000..d9133ae --- /dev/null +++ b/internal/client/dhcp/client.go @@ -0,0 +1,104 @@ +package dhcpclient + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "net" + "time" + + dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp" +) + +const DefaultTimeout = 5 * time.Second + +var ErrNotServed = errors.New("mac is not served by this subnet") + +type Client struct { + path string + timeout time.Duration +} + +func New(path string) *Client { + return &Client{path: path, timeout: DefaultTimeout} +} + +func (c *Client) WithTimeout(d time.Duration) *Client { + return &Client{path: c.path, timeout: d} +} + +func (c *Client) call(req dhcpapi.Request) (dhcpapi.Response, error) { + conn, err := net.DialTimeout("unix", c.path, c.timeout) + if err != nil { + return dhcpapi.Response{}, fmt.Errorf("dial %s: %w", c.path, err) + } + defer conn.Close() + + if err := conn.SetDeadline(time.Now().Add(c.timeout)); err != nil { + return dhcpapi.Response{}, fmt.Errorf("set deadline on %s: %w", c.path, err) + } + + raw, err := json.Marshal(req) + if err != nil { + return dhcpapi.Response{}, fmt.Errorf("encode %s: %w", req.Verb, err) + } + if _, err := conn.Write(append(raw, '\n')); err != nil { + return dhcpapi.Response{}, fmt.Errorf("send %s: %w", req.Verb, err) + } + + scanner := bufio.NewScanner(conn) + scanner.Buffer(make([]byte, 0, 4096), dhcpapi.MaxMessageBytes) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return dhcpapi.Response{}, fmt.Errorf("read reply to %s: %w", req.Verb, err) + } + return dhcpapi.Response{}, fmt.Errorf("no reply to %s", req.Verb) + } + + var resp dhcpapi.Response + if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil { + return dhcpapi.Response{}, fmt.Errorf("parse reply to %s: %w", req.Verb, err) + } + if !resp.OK { + return resp, fmt.Errorf("%s refused: %s", req.Verb, resp.Error) + } + return resp, nil +} + +func (c *Client) SetSubnet(subnet dhcpapi.Subnet) error { + _, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbSetSubnet, Subnet: &subnet}) + return err +} + +func (c *Client) SetHost(host dhcpapi.Host) error { + _, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbSetHost, Host: &host}) + return err +} + +func (c *Client) DelHost(mac string) error { + _, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbDelHost, MAC: mac}) + return err +} + +func (c *Client) GetState() (dhcpapi.State, string, error) { + resp, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbGetState}) + if err != nil { + return dhcpapi.State{}, "", err + } + if resp.State == nil { + return dhcpapi.State{}, "", errors.New("get-state returned no state") + } + return *resp.State, resp.Digest, nil +} + +func (c *Client) Probe(mac string) (dhcpapi.Lease, error) { + resp, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbProbe, MAC: mac}) + if err != nil { + return dhcpapi.Lease{}, err + } + if !resp.Served || resp.Lease == nil { + return dhcpapi.Lease{}, ErrNotServed + } + return *resp.Lease, nil +} diff --git a/internal/client/dhcp/client_test.go b/internal/client/dhcp/client_test.go new file mode 100644 index 0000000..0bf3866 --- /dev/null +++ b/internal/client/dhcp/client_test.go @@ -0,0 +1,364 @@ +package dhcpclient + +import ( + "errors" + "io" + "log/slog" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp" + "git.g3e.fr/syonad/two/internal/dhcpd" +) + +func shortTempDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "dhcpd") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + return dir +} + +func testSubnet() dhcpapi.Subnet { + return dhcpapi.Subnet{ + Network: "10.0.5.0/24", + InterfaceIP: "10.0.5.1", + VPCRoute: "10.0.0.0/16", + DefaultGateway: "10.0.5.254", + } +} + +func testHost() dhcpapi.Host { + return dhcpapi.Host{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", VM: "vm-test", DefaultRoute: true} +} + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func serve(t *testing.T) (*Client, *dhcpd.Store, string) { + t.Helper() + + dir := shortTempDir(t) + store := dhcpd.NewStore(filepath.Join(dir, "s.state")) + if err := store.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + socketPath := filepath.Join(dir, "s.sock") + server, err := dhcpapi.Listen(store, socketPath, discardLogger()) + if err != nil { + t.Fatalf("Listen: %v", err) + } + go server.Serve() + t.Cleanup(func() { server.Close() }) + + return New(socketPath), store, socketPath +} + +func TestListen_SocketIsOwnerOnly(t *testing.T) { + _, _, socketPath := serve(t) + + info, err := os.Stat(socketPath) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("mode = %o, want 600: whoever reaches it rewrites the subnet addressing", got) + } +} + +func TestListen_ReplacesAStaleSocketFile(t *testing.T) { + dir := shortTempDir(t) + socketPath := filepath.Join(dir, "stale.sock") + if err := os.WriteFile(socketPath, []byte("leftover"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + store := dhcpd.NewStore(filepath.Join(dir, "s.state")) + if err := store.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + server, err := dhcpapi.Listen(store, socketPath, discardLogger()) + if err != nil { + t.Fatalf("a socket left by an unclean stop must not block startup: %v", err) + } + server.Close() +} + +func TestSetSubnet_ReachesTheStore(t *testing.T) { + client, store, _ := serve(t) + + if err := client.SetSubnet(testSubnet()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + if _, configured := store.Subnet(); !configured { + t.Error("the subnet configuration did not reach the store") + } +} + +func TestSetSubnet_InvalidNetworkIsRefused(t *testing.T) { + client, _, _ := serve(t) + + subnet := testSubnet() + subnet.Network = "10.0.5.0" + err := client.SetSubnet(subnet) + if err == nil { + t.Fatal("an invalid network must be refused") + } + if !strings.Contains(err.Error(), "refused") { + t.Errorf("error = %v, want the server refusal to surface", err) + } +} + +func TestSetHost_IsIdempotent(t *testing.T) { + client, store, _ := serve(t) + + for range 3 { + if err := client.SetHost(testHost()); err != nil { + t.Fatalf("SetHost: %v", err) + } + } + if got := len(store.Hosts()); got != 1 { + t.Errorf("hosts = %d, want 1: set-host replaces the entry for that mac", got) + } +} + +func TestSetHost_InvalidMACIsRefused(t *testing.T) { + client, _, _ := serve(t) + + host := testHost() + host.MAC = "nope" + if err := client.SetHost(host); err == nil { + t.Fatal("an invalid mac must be refused") + } +} + +func TestDelHost_RemovesTheEntry(t *testing.T) { + client, store, _ := serve(t) + + if err := client.SetHost(testHost()); err != nil { + t.Fatalf("SetHost: %v", err) + } + if err := client.DelHost(testHost().MAC); err != nil { + t.Fatalf("DelHost: %v", err) + } + if got := len(store.Hosts()); got != 0 { + t.Errorf("hosts = %d, want 0", got) + } +} + +func TestDelHost_UnknownMACIsNotAnError(t *testing.T) { + client, _, _ := serve(t) + + if err := client.DelHost("00:22:33:ff:ff:ff"); err != nil { + t.Errorf("deleting an absent entry must be idempotent, got %v", err) + } +} + +func TestDelHost_InvalidMACIsRefused(t *testing.T) { + client, _, _ := serve(t) + + if err := client.DelHost("not-a-mac"); err == nil { + t.Fatal("an invalid mac must be refused") + } +} + +func TestGetState_ReturnsStateAndDigest(t *testing.T) { + client, _, _ := serve(t) + + if err := client.SetSubnet(testSubnet()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + if err := client.SetHost(testHost()); err != nil { + t.Fatalf("SetHost: %v", err) + } + + state, digest, err := client.GetState() + if err != nil { + t.Fatalf("GetState: %v", err) + } + if state.Subnet == nil || len(state.Hosts) != 1 { + t.Fatalf("state = %+v, want one subnet and one host", state) + } + if digest == "" { + t.Fatal("the digest is what the watchdog compares") + } + + local, err := dhcpapi.Digest(state) + if err != nil { + t.Fatalf("Digest: %v", err) + } + if local != digest { + t.Errorf("digest recomputed locally = %s, server said %s: the canonical form diverges", local, digest) + } +} + +func TestGetState_DigestFollowsTheState(t *testing.T) { + client, _, _ := serve(t) + + if err := client.SetSubnet(testSubnet()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + _, before, err := client.GetState() + if err != nil { + t.Fatalf("GetState: %v", err) + } + + if err := client.SetHost(testHost()); err != nil { + t.Fatalf("SetHost: %v", err) + } + _, after, err := client.GetState() + if err != nil { + t.Fatalf("GetState: %v", err) + } + + if before == after { + t.Error("adding a reservation must change the digest") + } +} + +func TestProbe_DescribesWhatWouldBeSent(t *testing.T) { + client, _, _ := serve(t) + + if err := client.SetSubnet(testSubnet()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + if err := client.SetHost(testHost()); err != nil { + t.Fatalf("SetHost: %v", err) + } + + lease, err := client.Probe("00:22:33:00:00:0A") + if err != nil { + t.Fatalf("Probe: %v", err) + } + if lease.IP != "10.0.5.10" { + t.Errorf("ip = %s, want 10.0.5.10", lease.IP) + } + if lease.Netmask != "255.255.255.0" { + t.Errorf("netmask = %s, want 255.255.255.0", lease.Netmask) + } + if lease.Router != "10.0.5.254" { + t.Errorf("router = %s, want 10.0.5.254", lease.Router) + } + if len(lease.DNS) != 2 { + t.Errorf("dns = %v, want two servers", lease.DNS) + } + if len(lease.Routes) != 3 { + t.Errorf("routes = %v, want metadata, vpc and default", lease.Routes) + } + if lease.LeaseSeconds != 43200 { + t.Errorf("lease = %ds, want 43200", lease.LeaseSeconds) + } + if lease.MAC != "00:22:33:00:00:0a" { + t.Errorf("mac = %s, want the normalized form", lease.MAC) + } +} + +func TestProbe_UnservedMACIsReported(t *testing.T) { + client, _, _ := serve(t) + + if err := client.SetSubnet(testSubnet()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + if _, err := client.Probe("00:22:33:ff:ff:ff"); !errors.Is(err, ErrNotServed) { + t.Fatalf("error = %v, want ErrNotServed", err) + } +} + +func TestProbe_WithoutSubnetConfigurationIsRefused(t *testing.T) { + client, _, _ := serve(t) + + if _, err := client.Probe("00:22:33:00:00:0a"); err == nil { + t.Fatal("probing an unconfigured subnet must be refused") + } +} + +func TestCall_UnknownVerbIsRefused(t *testing.T) { + _, _, socketPath := serve(t) + + conn, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + + if _, err := conn.Write([]byte(`{"verb":"drop-everything"}` + "\n")); err != nil { + t.Fatalf("Write: %v", err) + } + + buf := make([]byte, 512) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("Read: %v", err) + } + if !strings.Contains(string(buf[:n]), "unknown verb") { + t.Errorf("reply = %s, want an unknown verb refusal", buf[:n]) + } +} + +func TestCall_MalformedLineIsRefusedWithoutClosingTheConnection(t *testing.T) { + _, _, socketPath := serve(t) + + conn, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + + if _, err := conn.Write([]byte("{not json\n" + `{"verb":"get-state"}` + "\n")); err != nil { + t.Fatalf("Write: %v", err) + } + + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("Read: %v", err) + } + if !strings.Contains(string(buf[:n]), "malformed request") { + t.Errorf("first reply = %s, want a malformed request refusal", buf[:n]) + } +} + +func TestCall_OnAnAbsentSocketFails(t *testing.T) { + client := New(filepath.Join(shortTempDir(t), "nothing.sock")) + + if err := client.DelHost("00:22:33:00:00:0a"); err == nil { + t.Fatal("an absent socket must be reported") + } +} + +func TestCall_HonoursItsTimeout(t *testing.T) { + socketPath := filepath.Join(shortTempDir(t), "mute.sock") + + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("Listen: %v", err) + } + defer listener.Close() + + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + time.Sleep(3 * time.Second) + }() + + client := New(socketPath).WithTimeout(150 * time.Millisecond) + start := time.Now() + if err := client.DelHost("00:22:33:00:00:0a"); err == nil { + t.Fatal("a mute server must not hang the caller") + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Errorf("returned after %s, want the 150ms deadline to apply", elapsed) + } +} diff --git a/internal/config/agent/dhcp.go b/internal/config/agent/dhcp.go new file mode 100644 index 0000000..f26f023 --- /dev/null +++ b/internal/config/agent/dhcp.go @@ -0,0 +1,17 @@ +package configuration + +import "fmt" + +const ( + BackendDnsmasq = "dnsmasq" + BackendTwo = "two" +) + +func ValidBackend(backend string) error { + switch backend { + case BackendDnsmasq, BackendTwo: + return nil + default: + return fmt.Errorf("unknown dhcp backend %q: expected %q or %q", backend, BackendDnsmasq, BackendTwo) + } +} diff --git a/internal/config/agent/dhcp_test.go b/internal/config/agent/dhcp_test.go new file mode 100644 index 0000000..6796e56 --- /dev/null +++ b/internal/config/agent/dhcp_test.go @@ -0,0 +1,56 @@ +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) + } +} diff --git a/internal/config/agent/struct.go b/internal/config/agent/struct.go index 92a8677..d4afea1 100644 --- a/internal/config/agent/struct.go +++ b/internal/config/agent/struct.go @@ -31,6 +31,9 @@ type Config struct { Metadata struct { RunDir string `mapstructure:"run_dir"` } `mapstructure:"metadata"` + DHCP struct { + Backend string `mapstructure:"backend"` + } `mapstructure:"dhcp"` Admin struct { Enabled bool `mapstructure:"enabled"` Address string `mapstructure:"address"` @@ -67,6 +70,7 @@ func LoadConfig(path string) (*Config, error) { v.SetDefault("dispatcher.timeout_seconds", 300) v.SetDefault("dispatcher.poll_seconds", 2) v.SetDefault("metadata.run_dir", "/run/two/metadata") + v.SetDefault("dhcp.backend", BackendDnsmasq) v.SetDefault("qemu.ovmf_code_path", "/usr/share/OVMF/OVMF_CODE.fd") v.SetDefault("qemu.ovmf_vars_template", "/usr/share/OVMF/OVMF_VARS.fd") v.SetDefault("qemu.uefi_vars_dir", "/run/two/vms/uefi") diff --git a/internal/dhcpd/dhcpd.go b/internal/dhcpd/dhcpd.go new file mode 100644 index 0000000..3d1f95b --- /dev/null +++ b/internal/dhcpd/dhcpd.go @@ -0,0 +1,29 @@ +package dhcpd + +import ( + "net" + "time" +) + +const LeaseTime = 12 * time.Hour + +func DNSServers() []net.IP { + return []net.IP{ + net.IPv4(1, 1, 1, 1), + net.IPv4(8, 8, 8, 8), + } +} + +type SubnetConfig struct { + Network *net.IPNet + InterfaceIP net.IP + VPCRoute *net.IPNet + DefaultGateway net.IP +} + +type Host struct { + MAC net.HardwareAddr + IP net.IP + VM string + DefaultRoute bool +} diff --git a/internal/dhcpd/engine.go b/internal/dhcpd/engine.go new file mode 100644 index 0000000..3c431da --- /dev/null +++ b/internal/dhcpd/engine.go @@ -0,0 +1,60 @@ +package dhcpd + +import ( + "net" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +func answerable(req *dhcpv4.DHCPv4) bool { + switch req.MessageType() { + case dhcpv4.MessageTypeDiscover, dhcpv4.MessageTypeRequest: + return true + default: + return false + } +} + +func (s *Store) Handle(req *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, error) { + if req == nil { + return nil, ErrNoRequest + } + if !answerable(req) { + return nil, nil + } + + subnet, configured := s.Subnet() + if !configured { + return nil, nil + } + + host, known := s.Lookup(req.ClientHWAddr) + if !known { + return nil, nil + } + + return BuildReply(subnet, host, req) +} + +func (s *Store) Probe(mac net.HardwareAddr) (*dhcpv4.DHCPv4, error) { + if len(mac) == 0 { + return nil, ErrNoMAC + } + + subnet, configured := s.Subnet() + if !configured { + return nil, ErrNotConfigured + } + + host, known := s.Lookup(mac) + if !known { + return nil, nil + } + + req, err := dhcpv4.New(dhcpv4.WithMessageType(dhcpv4.MessageTypeRequest), dhcpv4.WithHwAddr(mac)) + if err != nil { + return nil, err + } + + return BuildReply(subnet, host, req) +} diff --git a/internal/dhcpd/engine_test.go b/internal/dhcpd/engine_test.go new file mode 100644 index 0000000..ae40cc3 --- /dev/null +++ b/internal/dhcpd/engine_test.go @@ -0,0 +1,195 @@ +package dhcpd + +import ( + "errors" + "net" + "testing" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +func configuredStore(t *testing.T) *Store { + t.Helper() + s, _ := loadedStore(t) + if err := s.SetSubnet(fullConfig(t)); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + if err := s.SetHost(testHost(t)); err != nil { + t.Fatalf("SetHost: %v", err) + } + return s +} + +func TestHandle_KnownMACGetsAnOfferOnDiscover(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("Handle: %v", err) + } + if reply == nil { + t.Fatal("a known mac must be answered") + } + if reply.MessageType() != dhcpv4.MessageTypeOffer { + t.Errorf("message type = %s, want OFFER", reply.MessageType()) + } + if !reply.YourIPAddr.Equal(net.ParseIP("10.0.5.10")) { + t.Errorf("yiaddr = %s, want the reserved 10.0.5.10", reply.YourIPAddr) + } +} + +func TestHandle_UnknownMACIsAnsweredWithSilence(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:ff:ff:ff"))) + if err != nil { + t.Fatalf("an unknown mac is not an error: %v", err) + } + if reply != nil { + t.Error("an unknown mac must get no reply, not a NAK") + } +} + +func TestHandle_UnconfiguredSubnetIsAnsweredWithSilence(t *testing.T) { + s, _ := loadedStore(t) + if err := s.SetHost(testHost(t)); err != nil { + t.Fatalf("SetHost: %v", err) + } + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("Handle: %v", err) + } + if reply != nil { + t.Error("without a subnet configuration the server must stay silent") + } +} + +func TestHandle_ReleaseIsANoOp(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeRelease, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("a RELEASE is not an error: %v", err) + } + if reply != nil { + t.Error("a RELEASE must get no reply: reservations are static") + } +} + +func TestHandle_DeclineIsANoOp(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeDecline, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("a DECLINE is not an error: %v", err) + } + if reply != nil { + t.Error("a DECLINE must get no reply: there is nothing to release") + } +} + +func TestHandle_RequestIsAnsweredWithAnAck(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeRequest, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("Handle: %v", err) + } + if reply == nil || reply.MessageType() != dhcpv4.MessageTypeAck { + t.Fatalf("reply = %v, want an ACK", reply) + } +} + +func TestHandle_NilRequestIsRejected(t *testing.T) { + s := configuredStore(t) + + if _, err := s.Handle(nil); !errors.Is(err, ErrNoRequest) { + t.Fatalf("error = %v, want ErrNoRequest", err) + } +} + +func TestHandle_DeletedHostStopsBeingAnswered(t *testing.T) { + s := configuredStore(t) + if err := s.DelHost(mac(t, "00:22:33:00:00:0a")); err != nil { + t.Fatalf("DelHost: %v", err) + } + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("Handle: %v", err) + } + if reply != nil { + t.Error("a deleted host must no longer be served") + } +} + +func TestProbe_ReturnsWhatWouldBeSentToTheMAC(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Probe(mac(t, "00:22:33:00:00:0A")) + if err != nil { + t.Fatalf("Probe: %v", err) + } + if reply == nil { + t.Fatal("a known mac must be described") + } + if !reply.YourIPAddr.Equal(net.ParseIP("10.0.5.10")) { + t.Errorf("yiaddr = %s, want 10.0.5.10", reply.YourIPAddr) + } + if got := reply.ClasslessStaticRoute(); len(got) != 3 { + t.Errorf("routes = %s, want metadata, vpc and default", got) + } +} + +func TestProbe_UnknownMACReturnsNothing(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Probe(mac(t, "00:22:33:ff:ff:ff")) + if err != nil { + t.Fatalf("Probe: %v", err) + } + if reply != nil { + t.Error("an unknown mac must describe no reply") + } +} + +func TestProbe_WithoutSubnetConfigurationIsRejected(t *testing.T) { + s, _ := loadedStore(t) + + if _, err := s.Probe(mac(t, "00:22:33:00:00:0a")); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("error = %v, want ErrNotConfigured", err) + } +} + +func TestProbe_EmptyMACIsRejected(t *testing.T) { + s := configuredStore(t) + + if _, err := s.Probe(nil); !errors.Is(err, ErrNoMAC) { + t.Fatalf("error = %v, want ErrNoMAC", err) + } +} + +func TestHandle_SecondaryInterfaceGetsNoDefaultRoute(t *testing.T) { + s := configuredStore(t) + h := testHost(t) + h.MAC = mac(t, "00:22:33:00:00:0b") + h.IP = net.ParseIP("10.0.5.11") + h.DefaultRoute = false + if err := s.SetHost(h); err != nil { + t.Fatalf("SetHost: %v", err) + } + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeRequest, mac(t, "00:22:33:00:00:0b"))) + if err != nil { + t.Fatalf("Handle: %v", err) + } + if got := reply.Router(); len(got) != 0 { + t.Errorf("router option = %v, want none on a secondary interface", got) + } + for _, r := range reply.ClasslessStaticRoute() { + if ones, _ := r.Dest.Mask.Size(); ones == 0 { + t.Errorf("unexpected default route for a secondary interface: %s", reply.ClasslessStaticRoute()) + } + } +} diff --git a/internal/dhcpd/fuzz_test.go b/internal/dhcpd/fuzz_test.go new file mode 100644 index 0000000..b152bb9 --- /dev/null +++ b/internal/dhcpd/fuzz_test.go @@ -0,0 +1,59 @@ +package dhcpd + +import ( + "net" + "testing" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +func FuzzBuildReply(f *testing.F) { + mac, err := net.ParseMAC("00:22:33:00:00:0a") + if err != nil { + f.Fatalf("ParseMAC: %v", err) + } + for _, kind := range []dhcpv4.MessageType{ + dhcpv4.MessageTypeDiscover, + dhcpv4.MessageTypeRequest, + dhcpv4.MessageTypeRelease, + dhcpv4.MessageTypeDecline, + } { + req, err := dhcpv4.New(dhcpv4.WithMessageType(kind), dhcpv4.WithHwAddr(mac)) + if err != nil { + f.Fatalf("New request: %v", err) + } + f.Add(req.ToBytes()) + } + + _, network, err := net.ParseCIDR("10.0.5.0/24") + if err != nil { + f.Fatalf("ParseCIDR: %v", err) + } + _, vpcRoute, err := net.ParseCIDR("10.0.0.0/16") + if err != nil { + f.Fatalf("ParseCIDR: %v", err) + } + + c := SubnetConfig{ + Network: network, + InterfaceIP: net.ParseIP("10.0.5.1"), + VPCRoute: vpcRoute, + DefaultGateway: net.ParseIP("10.0.5.254"), + } + h := Host{MAC: mac, IP: net.ParseIP("10.0.5.10"), VM: "vm-fuzz", DefaultRoute: true} + + f.Fuzz(func(t *testing.T, raw []byte) { + req, err := dhcpv4.FromBytes(raw) + if err != nil { + return + } + reply, err := BuildReply(c, h, req) + if err != nil { + return + } + if reply == nil { + t.Fatal("nil reply without an error") + } + reply.ToBytes() + }) +} diff --git a/internal/dhcpd/listener.go b/internal/dhcpd/listener.go new file mode 100644 index 0000000..af7bde5 --- /dev/null +++ b/internal/dhcpd/listener.go @@ -0,0 +1,68 @@ +package dhcpd + +import ( + "log/slog" + "net" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +const MaxDatagramBytes = 1500 + +var clientBroadcast = net.IPv4bcast + +func replyTo(peer net.Addr) net.Addr { + udp, ok := peer.(*net.UDPAddr) + if !ok { + return peer + } + if udp.IP == nil || udp.IP.IsUnspecified() { + return &net.UDPAddr{IP: clientBroadcast, Port: udp.Port} + } + return udp +} + +func (s *Store) serveDatagram(conn net.PacketConn, raw []byte, peer net.Addr, logger *slog.Logger) { + defer func() { + if r := recover(); r != nil { + logger.Error("dhcp datagram handling panicked", "peer", peer, "panic", r) + } + }() + + req, err := dhcpv4.FromBytes(raw) + if err != nil { + logger.Debug("malformed dhcp datagram", "peer", peer, "error", err) + return + } + + reply, err := s.Handle(req) + if err != nil { + logger.Error("building dhcp reply failed", + "peer", peer, "mac", req.ClientHWAddr, "type", req.MessageType(), "error", err) + return + } + if reply == nil { + logger.Debug("no reply for datagram", "mac", req.ClientHWAddr, "type", req.MessageType()) + return + } + + target := replyTo(peer) + if _, err := conn.WriteTo(reply.ToBytes(), target); err != nil { + logger.Error("sending dhcp reply failed", "target", target, "mac", req.ClientHWAddr, "error", err) + return + } + logger.Info("dhcp reply sent", + "mac", req.ClientHWAddr, "type", reply.MessageType(), "ip", reply.YourIPAddr, "target", target) +} + +func (s *Store) Serve(conn net.PacketConn, logger *slog.Logger) error { + buf := make([]byte, MaxDatagramBytes) + + for { + n, peer, err := conn.ReadFrom(buf) + if err != nil { + return err + } + s.serveDatagram(conn, buf[:n], peer, logger) + } +} diff --git a/internal/dhcpd/listener_test.go b/internal/dhcpd/listener_test.go new file mode 100644 index 0000000..26000a7 --- /dev/null +++ b/internal/dhcpd/listener_test.go @@ -0,0 +1,206 @@ +package dhcpd + +import ( + "io" + "log/slog" + "net" + "sync" + "testing" + "time" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +func discard() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func loopbackPair(t *testing.T) (*net.UDPConn, *net.UDPConn) { + t.Helper() + + server, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("ListenUDP server: %v", err) + } + t.Cleanup(func() { server.Close() }) + + client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("ListenUDP client: %v", err) + } + t.Cleanup(func() { client.Close() }) + + return server, client +} + +func exchange(t *testing.T, s *Store, raw []byte) *dhcpv4.DHCPv4 { + t.Helper() + + server, client := loopbackPair(t) + go s.Serve(server, discard()) + + if _, err := client.WriteToUDP(raw, server.LocalAddr().(*net.UDPAddr)); err != nil { + t.Fatalf("WriteToUDP: %v", err) + } + + if err := client.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + buf := make([]byte, MaxDatagramBytes) + n, _, err := client.ReadFromUDP(buf) + if err != nil { + return nil + } + + reply, err := dhcpv4.FromBytes(buf[:n]) + if err != nil { + t.Fatalf("the reply must be a valid dhcp packet: %v", err) + } + return reply +} + +func TestServe_AnswersAKnownMAC(t *testing.T) { + s := configuredStore(t) + + reply := exchange(t, s, request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")).ToBytes()) + if reply == nil { + t.Fatal("a known mac must be answered on the wire") + } + if reply.MessageType() != dhcpv4.MessageTypeOffer { + t.Errorf("message type = %s, want OFFER", reply.MessageType()) + } + if !reply.YourIPAddr.Equal(net.ParseIP("10.0.5.10")) { + t.Errorf("yiaddr = %s, want 10.0.5.10", reply.YourIPAddr) + } +} + +func TestServe_StaysSilentForAnUnknownMAC(t *testing.T) { + s := configuredStore(t) + + if reply := exchange(t, s, request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:ff:ff:ff")).ToBytes()); reply != nil { + t.Errorf("an unknown mac must get nothing on the wire, got %s", reply.MessageType()) + } +} + +func TestServe_StaysSilentOnARelease(t *testing.T) { + s := configuredStore(t) + + if reply := exchange(t, s, request(t, dhcpv4.MessageTypeRelease, mac(t, "00:22:33:00:00:0a")).ToBytes()); reply != nil { + t.Errorf("a RELEASE must get nothing on the wire, got %s", reply.MessageType()) + } +} + +func TestServe_SurvivesAMalformedDatagram(t *testing.T) { + s := configuredStore(t) + server, client := loopbackPair(t) + go s.Serve(server, discard()) + + target := server.LocalAddr().(*net.UDPAddr) + for _, garbage := range [][]byte{{}, {0x01}, make([]byte, 1200)} { + if _, err := client.WriteToUDP(garbage, target); err != nil { + t.Fatalf("WriteToUDP: %v", err) + } + } + + if _, err := client.WriteToUDP(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")).ToBytes(), target); err != nil { + t.Fatalf("WriteToUDP: %v", err) + } + if err := client.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + buf := make([]byte, MaxDatagramBytes) + if _, _, err := client.ReadFromUDP(buf); err != nil { + t.Fatalf("the loop must survive garbage and keep serving: %v", err) + } +} + +func TestServe_ReturnsWhenTheConnectionCloses(t *testing.T) { + s := configuredStore(t) + server, _ := loopbackPair(t) + + done := make(chan error, 1) + go func() { done <- s.Serve(server, discard()) }() + + server.Close() + select { + case err := <-done: + if err == nil { + t.Error("Serve must report why it stopped") + } + case <-time.After(time.Second): + t.Fatal("Serve did not return after the connection closed") + } +} + +type explodingConn struct { + net.PacketConn + mu sync.Mutex + writes int +} + +func (c *explodingConn) WriteTo(b []byte, addr net.Addr) (int, error) { + c.mu.Lock() + first := c.writes == 0 + c.writes++ + c.mu.Unlock() + + if first { + panic("write exploded") + } + return c.PacketConn.WriteTo(b, addr) +} + +func TestServe_SurvivesAPanicWhileHandlingADatagram(t *testing.T) { + s := configuredStore(t) + server, client := loopbackPair(t) + go s.Serve(&explodingConn{PacketConn: server}, discard()) + + target := server.LocalAddr().(*net.UDPAddr) + raw := request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")).ToBytes() + + for range 2 { + if _, err := client.WriteToUDP(raw, target); err != nil { + t.Fatalf("WriteToUDP: %v", err) + } + time.Sleep(50 * time.Millisecond) + } + + if err := client.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + buf := make([]byte, MaxDatagramBytes) + if _, _, err := client.ReadFromUDP(buf); err != nil { + t.Fatalf("a panic on one datagram must not kill the serving loop: %v", err) + } +} + +func TestReplyTo_BroadcastsWhenTheClientHasNoAddress(t *testing.T) { + got := replyTo(&net.UDPAddr{IP: net.IPv4zero, Port: 68}) + + udp, ok := got.(*net.UDPAddr) + if !ok { + t.Fatalf("target = %T, want *net.UDPAddr", got) + } + if !udp.IP.Equal(net.IPv4bcast) { + t.Errorf("target = %s, want 255.255.255.255: the client cannot be reached by unicast yet", udp.IP) + } + if udp.Port != 68 { + t.Errorf("port = %d, want the client port to be kept", udp.Port) + } +} + +func TestReplyTo_KeepsTheUnicastPeerWhenItHasAnAddress(t *testing.T) { + got := replyTo(&net.UDPAddr{IP: net.ParseIP("10.0.5.10"), Port: 68}) + + udp := got.(*net.UDPAddr) + if !udp.IP.Equal(net.ParseIP("10.0.5.10")) { + t.Errorf("target = %s, want the renewing client itself", udp.IP) + } +} + +func TestReplyTo_BroadcastsWhenThePeerIPIsNil(t *testing.T) { + udp := replyTo(&net.UDPAddr{Port: 68}).(*net.UDPAddr) + if !udp.IP.Equal(net.IPv4bcast) { + t.Errorf("target = %s, want 255.255.255.255", udp.IP) + } +} diff --git a/internal/dhcpd/reply.go b/internal/dhcpd/reply.go new file mode 100644 index 0000000..a603b41 --- /dev/null +++ b/internal/dhcpd/reply.go @@ -0,0 +1,97 @@ +package dhcpd + +import ( + "errors" + "fmt" + "net" + + "git.g3e.fr/syonad/two/internal/metadata" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +var ( + ErrNoInterfaceIP = errors.New("interface ip is required: guests would have no route to the metadata server") + ErrNoNetwork = errors.New("subnet network is required") + ErrNoHostIP = errors.New("host ip is required") + ErrNoRequest = errors.New("request is nil") +) + +func metadataRoute() *net.IPNet { + return &net.IPNet{ + IP: net.ParseIP(metadata.ServiceIP).To4(), + Mask: net.CIDRMask(32, 32), + } +} + +func defaultRoute() *net.IPNet { + return &net.IPNet{ + IP: net.IPv4zero.To4(), + Mask: net.CIDRMask(0, 32), + } +} + +func Routes(c SubnetConfig, h Host) (dhcpv4.Routes, error) { + if c.InterfaceIP == nil { + return nil, ErrNoInterfaceIP + } + + routes := dhcpv4.Routes{{Dest: metadataRoute(), Router: c.InterfaceIP}} + + if c.VPCRoute != nil { + routes = append(routes, &dhcpv4.Route{Dest: c.VPCRoute, Router: c.InterfaceIP}) + } + if h.DefaultRoute && c.DefaultGateway != nil { + routes = append(routes, &dhcpv4.Route{Dest: defaultRoute(), Router: c.DefaultGateway}) + } + return routes, nil +} + +func replyType(req *dhcpv4.DHCPv4) (dhcpv4.MessageType, error) { + switch req.MessageType() { + case dhcpv4.MessageTypeDiscover: + return dhcpv4.MessageTypeOffer, nil + case dhcpv4.MessageTypeRequest: + return dhcpv4.MessageTypeAck, nil + default: + return 0, fmt.Errorf("no reply built for message type %s", req.MessageType()) + } +} + +func BuildReply(c SubnetConfig, h Host, req *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, error) { + if req == nil { + return nil, ErrNoRequest + } + if c.Network == nil { + return nil, ErrNoNetwork + } + if h.IP == nil { + return nil, ErrNoHostIP + } + + kind, err := replyType(req) + if err != nil { + return nil, err + } + + routes, err := Routes(c, h) + if err != nil { + return nil, err + } + + mods := []dhcpv4.Modifier{ + dhcpv4.WithMessageType(kind), + dhcpv4.WithServerIP(c.InterfaceIP), + dhcpv4.WithYourIP(h.IP), + dhcpv4.WithNetmask(c.Network.Mask), + dhcpv4.WithLeaseTime(uint32(LeaseTime.Seconds())), + dhcpv4.WithOption(dhcpv4.OptServerIdentifier(c.InterfaceIP)), + dhcpv4.WithOption(dhcpv4.OptDNS(DNSServers()...)), + dhcpv4.WithOption(dhcpv4.OptClasslessStaticRoute(routes...)), + } + if h.DefaultRoute && c.DefaultGateway != nil { + mods = append(mods, dhcpv4.WithOption(dhcpv4.OptRouter(c.DefaultGateway))) + } + + return dhcpv4.NewReplyFromRequest(req, mods...) +} diff --git a/internal/dhcpd/reply_test.go b/internal/dhcpd/reply_test.go new file mode 100644 index 0000000..702b430 --- /dev/null +++ b/internal/dhcpd/reply_test.go @@ -0,0 +1,339 @@ +package dhcpd + +import ( + "bytes" + "errors" + "net" + "testing" + + "git.g3e.fr/syonad/two/internal/metadata" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +func cidr(t *testing.T, s string) *net.IPNet { + t.Helper() + _, n, err := net.ParseCIDR(s) + if err != nil { + t.Fatalf("ParseCIDR(%q): %v", s, err) + } + return n +} + +func testConfig(t *testing.T) SubnetConfig { + t.Helper() + return SubnetConfig{ + Network: cidr(t, "10.0.5.0/24"), + InterfaceIP: net.ParseIP("10.0.5.1"), + } +} + +func fullConfig(t *testing.T) SubnetConfig { + t.Helper() + c := testConfig(t) + c.VPCRoute = cidr(t, "10.0.0.0/16") + c.DefaultGateway = net.ParseIP("10.0.5.254") + return c +} + +func mac(t *testing.T, s string) net.HardwareAddr { + t.Helper() + m, err := net.ParseMAC(s) + if err != nil { + t.Fatalf("ParseMAC(%q): %v", s, err) + } + return m +} + +func testHost(t *testing.T) Host { + t.Helper() + return Host{MAC: mac(t, "00:22:33:00:00:0a"), IP: net.ParseIP("10.0.5.10"), VM: "vm-test", DefaultRoute: true} +} + +func request(t *testing.T, kind dhcpv4.MessageType, mac net.HardwareAddr) *dhcpv4.DHCPv4 { + t.Helper() + req, err := dhcpv4.New(dhcpv4.WithMessageType(kind), dhcpv4.WithHwAddr(mac)) + if err != nil { + t.Fatalf("New request: %v", err) + } + return req +} + +func encodedRoute(t *testing.T, routes dhcpv4.Routes, dest string) []byte { + t.Helper() + for _, r := range routes { + if r.Dest.String() == dest { + return dhcpv4.Routes{r}.ToBytes() + } + } + t.Fatalf("no route to %s in %s", dest, routes) + return nil +} + +func TestRoutes_AlwaysCarriesTheMetadataRoute(t *testing.T) { + c := testConfig(t) + routes, err := Routes(c, Host{IP: net.ParseIP("10.0.5.10")}) + if err != nil { + t.Fatalf("Routes: %v", err) + } + if len(routes) != 1 { + t.Fatalf("expected the metadata route alone, got %s", routes) + } + if got := routes[0].Dest.String(); got != metadata.ServiceIP+"/32" { + t.Errorf("destination = %s, want %s/32", got, metadata.ServiceIP) + } + if !routes[0].Router.Equal(c.InterfaceIP) { + t.Errorf("next-hop = %s, want the subnet interface ip %s", routes[0].Router, c.InterfaceIP) + } +} + +func TestRoutes_WithoutInterfaceIPIsRejected(t *testing.T) { + _, err := Routes(SubnetConfig{Network: cidr(t, "10.0.5.0/24")}, testHost(t)) + if !errors.Is(err, ErrNoInterfaceIP) { + t.Fatalf("error = %v, want ErrNoInterfaceIP", err) + } +} + +func TestRoutes_VPCRouteUsesTheInterfaceIPAsNextHop(t *testing.T) { + c := testConfig(t) + c.VPCRoute = cidr(t, "10.0.0.0/16") + c.DefaultGateway = net.ParseIP("192.0.2.1") + + routes, err := Routes(c, testHost(t)) + if err != nil { + t.Fatalf("Routes: %v", err) + } + for _, r := range routes { + if r.Dest.String() != "10.0.0.0/16" { + continue + } + if !r.Router.Equal(c.InterfaceIP) { + t.Fatalf("vpc route next-hop = %s, want %s", r.Router, c.InterfaceIP) + } + return + } + t.Fatalf("no vpc route in %s", routes) +} + +func TestRoutes_NoDefaultRouteWithoutDefaultGateway(t *testing.T) { + routes, err := Routes(testConfig(t), testHost(t)) + if err != nil { + t.Fatalf("Routes: %v", err) + } + for _, r := range routes { + if ones, _ := r.Dest.Mask.Size(); ones == 0 { + t.Fatalf("unexpected default route in %s", routes) + } + } +} + +func TestRoutes_NoDefaultRouteWhenTheInterfaceDoesNotReceiveIt(t *testing.T) { + c := testConfig(t) + c.DefaultGateway = net.ParseIP("10.0.5.254") + + h := testHost(t) + h.DefaultRoute = false + + routes, err := Routes(c, h) + if err != nil { + t.Fatalf("Routes: %v", err) + } + for _, r := range routes { + if ones, _ := r.Dest.Mask.Size(); ones == 0 { + t.Fatalf("a secondary interface must not receive the default route, got %s", routes) + } + } +} + +func TestRoutes_DefaultRouteEncodesZeroDestinationOctets(t *testing.T) { + c := testConfig(t) + c.DefaultGateway = net.ParseIP("10.0.5.254") + + routes, err := Routes(c, testHost(t)) + if err != nil { + t.Fatalf("Routes: %v", err) + } + + want := []byte{0x00, 10, 0, 5, 254} + if got := encodedRoute(t, routes, "0.0.0.0/0"); !bytes.Equal(got, want) { + t.Errorf("default route encoding = % x, want % x", got, want) + } +} + +func TestRoutes_UnalignedPrefixEncodesOnlyItsSignificantOctets(t *testing.T) { + c := testConfig(t) + c.VPCRoute = cidr(t, "10.16.0.0/12") + + routes, err := Routes(c, testHost(t)) + if err != nil { + t.Fatalf("Routes: %v", err) + } + + want := []byte{0x0c, 10, 16, 10, 0, 5, 1} + if got := encodedRoute(t, routes, "10.16.0.0/12"); !bytes.Equal(got, want) { + t.Errorf("/12 encoding = % x, want % x", got, want) + } +} + +func TestRoutes_MetadataRouteEncodesOnFourDestinationOctets(t *testing.T) { + routes, err := Routes(testConfig(t), testHost(t)) + if err != nil { + t.Fatalf("Routes: %v", err) + } + + want := []byte{0x20, 169, 254, 169, 254, 10, 0, 5, 1} + if got := encodedRoute(t, routes, metadata.ServiceIP+"/32"); !bytes.Equal(got, want) { + t.Errorf("metadata route encoding = % x, want % x", got, want) + } +} + +func TestBuildReply_DiscoverIsAnsweredWithAnOffer(t *testing.T) { + h := testHost(t) + reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeDiscover, h.MAC)) + if err != nil { + t.Fatalf("BuildReply: %v", err) + } + if reply.MessageType() != dhcpv4.MessageTypeOffer { + t.Errorf("message type = %s, want OFFER", reply.MessageType()) + } +} + +func TestBuildReply_RequestIsAnsweredWithAnAck(t *testing.T) { + h := testHost(t) + reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, h.MAC)) + if err != nil { + t.Fatalf("BuildReply: %v", err) + } + if reply.MessageType() != dhcpv4.MessageTypeAck { + t.Errorf("message type = %s, want ACK", reply.MessageType()) + } +} + +func TestBuildReply_ReleaseGetsNoReply(t *testing.T) { + h := testHost(t) + if _, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRelease, h.MAC)); err == nil { + t.Fatal("a RELEASE must not produce a reply") + } +} + +func TestBuildReply_DeclineGetsNoReply(t *testing.T) { + h := testHost(t) + if _, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeDecline, h.MAC)); err == nil { + t.Fatal("a DECLINE must not produce a reply") + } +} + +func TestBuildReply_CarriesAddressMaskLeaseAndServerIdentifier(t *testing.T) { + c := testConfig(t) + h := testHost(t) + + reply, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)) + if err != nil { + t.Fatalf("BuildReply: %v", err) + } + + if !reply.YourIPAddr.Equal(h.IP) { + t.Errorf("yiaddr = %s, want %s", reply.YourIPAddr, h.IP) + } + if got := net.IP(reply.SubnetMask()).String(); got != net.IP(c.Network.Mask).String() { + t.Errorf("netmask = %s, want %s", got, net.IP(c.Network.Mask)) + } + if got := reply.IPAddressLeaseTime(0); got != LeaseTime { + t.Errorf("lease time = %s, want %s", got, LeaseTime) + } + if got := reply.ServerIdentifier(); !got.Equal(c.InterfaceIP) { + t.Errorf("server identifier = %s, want %s", got, c.InterfaceIP) + } + if got := reply.DNS(); len(got) != 2 || !got[0].Equal(net.IPv4(1, 1, 1, 1)) || !got[1].Equal(net.IPv4(8, 8, 8, 8)) { + t.Errorf("dns = %v, want 1.1.1.1 and 8.8.8.8", got) + } +} + +func TestBuildReply_NoRouterOptionWithoutDefaultRoute(t *testing.T) { + h := testHost(t) + reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, h.MAC)) + if err != nil { + t.Fatalf("BuildReply: %v", err) + } + if got := reply.Router(); len(got) != 0 { + t.Errorf("router option = %v, want none: the guest would use the server as its gateway", got) + } +} + +func TestBuildReply_RouterOptionCarriesTheDefaultGateway(t *testing.T) { + c := testConfig(t) + c.DefaultGateway = net.ParseIP("10.0.5.254") + + h := testHost(t) + reply, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)) + if err != nil { + t.Fatalf("BuildReply: %v", err) + } + + got := reply.Router() + if len(got) != 1 || !got[0].Equal(c.DefaultGateway) { + t.Errorf("router option = %v, want [%s]", got, c.DefaultGateway) + } +} + +func TestBuildReply_SecondaryInterfaceGetsNoRouterOption(t *testing.T) { + c := testConfig(t) + c.DefaultGateway = net.ParseIP("10.0.5.254") + + h := testHost(t) + h.DefaultRoute = false + + reply, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)) + if err != nil { + t.Fatalf("BuildReply: %v", err) + } + if got := reply.Router(); len(got) != 0 { + t.Errorf("router option = %v, want none on a secondary interface", got) + } +} + +func TestBuildReply_ClasslessStaticRouteIsPresent(t *testing.T) { + h := testHost(t) + reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, h.MAC)) + if err != nil { + t.Fatalf("BuildReply: %v", err) + } + if got := reply.ClasslessStaticRoute(); len(got) == 0 { + t.Fatal("option 121 missing: cloud-init would have no route to the metadata server") + } +} + +func TestBuildReply_WithoutInterfaceIPIsRejected(t *testing.T) { + c := testConfig(t) + c.InterfaceIP = nil + + h := testHost(t) + if _, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)); !errors.Is(err, ErrNoInterfaceIP) { + t.Fatalf("error = %v, want ErrNoInterfaceIP", err) + } +} + +func TestBuildReply_WithoutNetworkIsRejected(t *testing.T) { + c := testConfig(t) + c.Network = nil + + h := testHost(t) + if _, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)); !errors.Is(err, ErrNoNetwork) { + t.Fatalf("error = %v, want ErrNoNetwork", err) + } +} + +func TestBuildReply_WithoutHostIPIsRejected(t *testing.T) { + h := testHost(t) + h.IP = nil + + if _, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, testHost(t).MAC)); !errors.Is(err, ErrNoHostIP) { + t.Fatalf("error = %v, want ErrNoHostIP", err) + } +} + +func TestBuildReply_NilRequestIsRejected(t *testing.T) { + if _, err := BuildReply(testConfig(t), testHost(t), nil); !errors.Is(err, ErrNoRequest) { + t.Fatalf("error = %v, want ErrNoRequest", err) + } +} diff --git a/internal/dhcpd/store.go b/internal/dhcpd/store.go new file mode 100644 index 0000000..86b756d --- /dev/null +++ b/internal/dhcpd/store.go @@ -0,0 +1,255 @@ +package dhcpd + +import ( + "errors" + "fmt" + "net" + "sort" + "sync" + + "git.g3e.fr/syonad/two/pkg/db/statefile" +) + +var ( + ErrNoMAC = errors.New("host mac is required") + ErrNotConfigured = errors.New("subnet is not configured") +) + +type diskSubnet struct { + Network string `json:"network"` + InterfaceIP string `json:"interface_ip"` + VPCRoute string `json:"vpc_route,omitempty"` + DefaultGateway string `json:"default_gateway,omitempty"` +} + +type diskHost struct { + MAC string `json:"mac"` + IP string `json:"ip"` + VM string `json:"vm,omitempty"` + DefaultRoute bool `json:"default_route"` +} + +type diskState struct { + Subnet *diskSubnet `json:"subnet,omitempty"` + Hosts []diskHost `json:"hosts"` +} + +type Store struct { + mu sync.RWMutex + file *statefile.File[diskState] + subnet SubnetConfig + configured bool + hosts map[string]Host +} + +func NewStore(path string) *Store { + return &Store{ + file: statefile.New[diskState](path), + hosts: make(map[string]Host), + } +} + +func (s *Store) Path() string { + return s.file.Path() +} + +func (s *Store) Load() error { + state, err := s.file.Load() + if err != nil { + return err + } + + subnet := SubnetConfig{} + configured := false + if state.Subnet != nil { + parsed, err := subnetFromDisk(*state.Subnet) + if err != nil { + return err + } + subnet = parsed + configured = true + } + + hosts := make(map[string]Host, len(state.Hosts)) + for _, h := range state.Hosts { + host, err := hostFromDisk(h) + if err != nil { + return err + } + hosts[host.MAC.String()] = host + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.subnet = subnet + s.configured = configured + s.hosts = hosts + return nil +} + +func subnetFromDisk(d diskSubnet) (SubnetConfig, error) { + _, network, err := net.ParseCIDR(d.Network) + if err != nil { + return SubnetConfig{}, fmt.Errorf("invalid network %q: %w", d.Network, err) + } + interfaceIP := net.ParseIP(d.InterfaceIP) + if interfaceIP == nil { + return SubnetConfig{}, ErrNoInterfaceIP + } + + c := SubnetConfig{Network: network, InterfaceIP: interfaceIP} + + if d.VPCRoute != "" { + if _, c.VPCRoute, err = net.ParseCIDR(d.VPCRoute); err != nil { + return SubnetConfig{}, fmt.Errorf("invalid vpc route %q: %w", d.VPCRoute, err) + } + } + if d.DefaultGateway != "" { + if c.DefaultGateway = net.ParseIP(d.DefaultGateway); c.DefaultGateway == nil { + return SubnetConfig{}, fmt.Errorf("invalid default gateway %q", d.DefaultGateway) + } + } + return c, nil +} + +func hostFromDisk(d diskHost) (Host, error) { + mac, err := net.ParseMAC(d.MAC) + if err != nil { + return Host{}, fmt.Errorf("invalid mac %q: %w", d.MAC, err) + } + ip := net.ParseIP(d.IP) + if ip == nil { + return Host{}, fmt.Errorf("invalid host ip %q", d.IP) + } + return Host{MAC: mac, IP: ip, VM: d.VM, DefaultRoute: d.DefaultRoute}, nil +} + +func (s *Store) SetSubnet(c SubnetConfig) error { + if c.Network == nil { + return ErrNoNetwork + } + if c.InterfaceIP == nil { + return ErrNoInterfaceIP + } + + s.mu.Lock() + defer s.mu.Unlock() + + previous, wasConfigured := s.subnet, s.configured + s.subnet, s.configured = c, true + + if err := s.persist(); err != nil { + s.subnet, s.configured = previous, wasConfigured + return err + } + return nil +} + +func (s *Store) SetHost(h Host) error { + if len(h.MAC) == 0 { + return ErrNoMAC + } + if h.IP == nil { + return ErrNoHostIP + } + + key := h.MAC.String() + + s.mu.Lock() + defer s.mu.Unlock() + + previous, existed := s.hosts[key] + s.hosts[key] = h + + if err := s.persist(); err != nil { + if existed { + s.hosts[key] = previous + } else { + delete(s.hosts, key) + } + return err + } + return nil +} + +func (s *Store) DelHost(mac net.HardwareAddr) error { + if len(mac) == 0 { + return ErrNoMAC + } + key := mac.String() + + s.mu.Lock() + defer s.mu.Unlock() + + previous, existed := s.hosts[key] + delete(s.hosts, key) + + if err := s.persist(); err != nil { + if existed { + s.hosts[key] = previous + } + return err + } + return nil +} + +func (s *Store) Lookup(mac net.HardwareAddr) (Host, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + h, ok := s.hosts[mac.String()] + return h, ok +} + +func (s *Store) Subnet() (SubnetConfig, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.subnet, s.configured +} + +func (s *Store) Hosts() []Host { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.sortedHosts() +} + +func (s *Store) sortedHosts() []Host { + hosts := make([]Host, 0, len(s.hosts)) + for _, h := range s.hosts { + hosts = append(hosts, h) + } + sort.Slice(hosts, func(i, j int) bool { return hosts[i].MAC.String() < hosts[j].MAC.String() }) + return hosts +} + +func (s *Store) persist() error { + state := diskState{Hosts: make([]diskHost, 0, len(s.hosts))} + + if s.configured { + sub := diskSubnet{ + Network: s.subnet.Network.String(), + InterfaceIP: s.subnet.InterfaceIP.String(), + } + if s.subnet.VPCRoute != nil { + sub.VPCRoute = s.subnet.VPCRoute.String() + } + if s.subnet.DefaultGateway != nil { + sub.DefaultGateway = s.subnet.DefaultGateway.String() + } + state.Subnet = &sub + } + + for _, h := range s.sortedHosts() { + state.Hosts = append(state.Hosts, diskHost{ + MAC: h.MAC.String(), + IP: h.IP.String(), + VM: h.VM, + DefaultRoute: h.DefaultRoute, + }) + } + + return s.file.Save(state) +} diff --git a/internal/dhcpd/store_test.go b/internal/dhcpd/store_test.go new file mode 100644 index 0000000..11a57d5 --- /dev/null +++ b/internal/dhcpd/store_test.go @@ -0,0 +1,327 @@ +package dhcpd + +import ( + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "testing" +) + +func statePath(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "vp-admin_br-000001.state") +} + +func loadedStore(t *testing.T) (*Store, string) { + t.Helper() + path := statePath(t) + s := NewStore(path) + if err := s.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + return s, path +} + +func TestStore_LoadCreatesTheStateFileWhenAbsent(t *testing.T) { + _, path := loadedStore(t) + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("the state file must be created on load: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("mode = %o, want 600: the file exposes tenant mac and ip", got) + } +} + +func TestStore_LoadOnEmptyFileYieldsNoSubnet(t *testing.T) { + path := statePath(t) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + s := NewStore(path) + if err := s.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if _, configured := s.Subnet(); configured { + t.Error("an empty state file must not report a configured subnet") + } +} + +func TestStore_LoadRejectsCorruptedState(t *testing.T) { + path := statePath(t) + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := NewStore(path).Load(); err == nil { + t.Fatal("a corrupted state file must be reported, not silently ignored") + } +} + +func TestStore_LoadRejectsAnInvalidStoredMAC(t *testing.T) { + path := statePath(t) + raw := []byte(`{"hosts":[{"mac":"nope","ip":"10.0.5.10","default_route":true}]}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := NewStore(path).Load(); err == nil { + t.Fatal("an unparseable stored mac must be reported") + } +} + +func TestStore_SetSubnetIsPersisted(t *testing.T) { + s, path := loadedStore(t) + if err := s.SetSubnet(fullConfig(t)); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + + reloaded := NewStore(path) + if err := reloaded.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + c, configured := reloaded.Subnet() + if !configured { + t.Fatal("subnet lost across a restart") + } + if got := c.Network.String(); got != "10.0.5.0/24" { + t.Errorf("network = %s, want 10.0.5.0/24", got) + } + if !c.InterfaceIP.Equal(net.ParseIP("10.0.5.1")) { + t.Errorf("interface ip = %s, want 10.0.5.1", c.InterfaceIP) + } + if got := c.VPCRoute.String(); got != "10.0.0.0/16" { + t.Errorf("vpc route = %s, want 10.0.0.0/16", got) + } + if !c.DefaultGateway.Equal(net.ParseIP("10.0.5.254")) { + t.Errorf("default gateway = %s, want 10.0.5.254", c.DefaultGateway) + } +} + +func TestStore_SetSubnetRejectsAMissingInterfaceIP(t *testing.T) { + s, _ := loadedStore(t) + c := fullConfig(t) + c.InterfaceIP = nil + + if err := s.SetSubnet(c); !errors.Is(err, ErrNoInterfaceIP) { + t.Fatalf("error = %v, want ErrNoInterfaceIP", err) + } +} + +func TestStore_SetSubnetRejectsAMissingNetwork(t *testing.T) { + s, _ := loadedStore(t) + c := fullConfig(t) + c.Network = nil + + if err := s.SetSubnet(c); !errors.Is(err, ErrNoNetwork) { + t.Fatalf("error = %v, want ErrNoNetwork", err) + } +} + +func TestStore_SetHostIsPersistedAndFound(t *testing.T) { + s, path := loadedStore(t) + if err := s.SetHost(testHost(t)); err != nil { + t.Fatalf("SetHost: %v", err) + } + + reloaded := NewStore(path) + if err := reloaded.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + h, known := reloaded.Lookup(mac(t, "00:22:33:00:00:0a")) + if !known { + t.Fatal("host lost across a restart") + } + if !h.IP.Equal(net.ParseIP("10.0.5.10")) { + t.Errorf("ip = %s, want 10.0.5.10", h.IP) + } + if h.VM != "vm-test" { + t.Errorf("vm = %q, want vm-test", h.VM) + } + if !h.DefaultRoute { + t.Error("default route flag lost across a restart") + } +} + +func TestStore_SetHostIsIdempotentOnTheSameMAC(t *testing.T) { + s, _ := loadedStore(t) + h := testHost(t) + if err := s.SetHost(h); err != nil { + t.Fatalf("SetHost: %v", err) + } + + h.IP = net.ParseIP("10.0.5.11") + h.DefaultRoute = false + if err := s.SetHost(h); err != nil { + t.Fatalf("SetHost: %v", err) + } + + hosts := s.Hosts() + if len(hosts) != 1 { + t.Fatalf("hosts = %d, want 1: the mac is the key", len(hosts)) + } + if !hosts[0].IP.Equal(net.ParseIP("10.0.5.11")) || hosts[0].DefaultRoute { + t.Errorf("entry = %+v, want the second order to have replaced the first", hosts[0]) + } +} + +func TestStore_LookupNormalizesTheMACCase(t *testing.T) { + s, _ := loadedStore(t) + h := testHost(t) + h.MAC = mac(t, "00:22:33:AA:BB:CC") + if err := s.SetHost(h); err != nil { + t.Fatalf("SetHost: %v", err) + } + + if _, known := s.Lookup(mac(t, "00:22:33:aa:bb:cc")); !known { + t.Error("an uppercase mac must be found in lowercase: the key would diverge") + } +} + +func TestStore_LoadNormalizesTheMACCase(t *testing.T) { + path := statePath(t) + raw := []byte(`{"hosts":[{"mac":"00:22:33:AA:BB:CC","ip":"10.0.5.12","default_route":true}]}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + s := NewStore(path) + if err := s.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if _, known := s.Lookup(mac(t, "00:22:33:aa:bb:cc")); !known { + t.Error("a reloaded uppercase mac must be keyed in lowercase: the host would silently stop being served") + } +} + +func TestStore_SetHostRejectsAMissingMAC(t *testing.T) { + s, _ := loadedStore(t) + h := testHost(t) + h.MAC = nil + + if err := s.SetHost(h); !errors.Is(err, ErrNoMAC) { + t.Fatalf("error = %v, want ErrNoMAC", err) + } +} + +func TestStore_SetHostRejectsAMissingIP(t *testing.T) { + s, _ := loadedStore(t) + h := testHost(t) + h.IP = nil + + if err := s.SetHost(h); !errors.Is(err, ErrNoHostIP) { + t.Fatalf("error = %v, want ErrNoHostIP", err) + } +} + +func TestStore_DelHostRemovesTheEntry(t *testing.T) { + s, path := loadedStore(t) + if err := s.SetHost(testHost(t)); err != nil { + t.Fatalf("SetHost: %v", err) + } + if err := s.DelHost(mac(t, "00:22:33:00:00:0A")); err != nil { + t.Fatalf("DelHost: %v", err) + } + + reloaded := NewStore(path) + if err := reloaded.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if got := len(reloaded.Hosts()); got != 0 { + t.Errorf("hosts = %d, want 0 after deletion", got) + } +} + +func TestStore_DelHostOnAnUnknownMACIsNotAnError(t *testing.T) { + s, _ := loadedStore(t) + if err := s.DelHost(mac(t, "00:22:33:ff:ff:ff")); err != nil { + t.Errorf("deleting an absent entry must be idempotent, got %v", err) + } +} + +func TestStore_DelHostRejectsAnEmptyMAC(t *testing.T) { + s, _ := loadedStore(t) + if err := s.DelHost(nil); !errors.Is(err, ErrNoMAC) { + t.Fatalf("error = %v, want ErrNoMAC", err) + } +} + +func TestStore_HostsAreSortedByMAC(t *testing.T) { + s, _ := loadedStore(t) + for _, m := range []string{"00:22:33:00:00:0c", "00:22:33:00:00:0a", "00:22:33:00:00:0b"} { + h := testHost(t) + h.MAC = mac(t, m) + if err := s.SetHost(h); err != nil { + t.Fatalf("SetHost: %v", err) + } + } + + hosts := s.Hosts() + for i := 1; i < len(hosts); i++ { + if hosts[i-1].MAC.String() >= hosts[i].MAC.String() { + t.Fatalf("hosts are not sorted: %v", hosts) + } + } +} + +func TestStore_PersistedStateIsSortedOnDisk(t *testing.T) { + s, path := loadedStore(t) + for _, m := range []string{"00:22:33:00:00:0c", "00:22:33:00:00:0a"} { + h := testHost(t) + h.MAC = mac(t, m) + if err := s.SetHost(h); err != nil { + t.Fatalf("SetHost: %v", err) + } + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var state struct { + Hosts []struct { + MAC string `json:"mac"` + } `json:"hosts"` + } + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatalf("the state file must stay parseable: %v", err) + } + if len(state.Hosts) != 2 || state.Hosts[0].MAC != "00:22:33:00:00:0a" { + t.Errorf("hosts on disk = %+v, want sorted by mac", state.Hosts) + } +} + +func TestStore_PersistRestoresTheModeAfterAnExternalChmod(t *testing.T) { + s, path := loadedStore(t) + if err := os.Chmod(path, 0o644); err != nil { + t.Fatalf("Chmod: %v", err) + } + + if err := s.SetHost(testHost(t)); err != nil { + t.Fatalf("SetHost: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("mode = %o, want 600: each write must replace the file, not edit it in place", got) + } +} + +func TestStore_PathReportsTheStateFile(t *testing.T) { + s, path := loadedStore(t) + if got := s.Path(); got != path { + t.Errorf("Path = %s, want %s", got, path) + } +} diff --git a/pkg/db/statefile/statefile.go b/pkg/db/statefile/statefile.go new file mode 100644 index 0000000..fc62524 --- /dev/null +++ b/pkg/db/statefile/statefile.go @@ -0,0 +1,92 @@ +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 { + if err := os.Remove(f.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove %s: %w", f.path, err) + } + return nil +} diff --git a/pkg/db/statefile/statefile_test.go b/pkg/db/statefile/statefile_test.go new file mode 100644 index 0000000..70e2a85 --- /dev/null +++ b/pkg/db/statefile/statefile_test.go @@ -0,0 +1,189 @@ +package statefile + +import ( + "os" + "path/filepath" + "testing" +) + +type payload struct { + Name string `json:"name"` + Items []string `json:"items"` +} + +func path(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "component.state") +} + +func TestLoad_CreatesTheFileWhenAbsent(t *testing.T) { + p := path(t) + f := New[payload](p) + + value, err := f.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if value.Name != "" || len(value.Items) != 0 { + t.Errorf("value = %+v, want the zero value", value) + } + + info, err := os.Stat(p) + if err != nil { + t.Fatalf("the file must be created on load: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("mode = %o, want 600", got) + } +} + +func TestLoad_CreatesTheDirectoryWhenAbsent(t *testing.T) { + p := filepath.Join(t.TempDir(), "nested", "deeper", "component.state") + + if _, err := New[payload](p).Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + info, err := os.Stat(filepath.Dir(p)) + if err != nil { + t.Fatalf("the directory must be created: %v", err) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Errorf("directory mode = %o, want 700", got) + } +} + +func TestLoad_EmptyFileYieldsTheZeroValue(t *testing.T) { + p := path(t) + if err := os.WriteFile(p, nil, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + value, err := New[payload](p).Load() + if err != nil { + t.Fatalf("an empty file is a valid starting point: %v", err) + } + if value.Name != "" { + t.Errorf("value = %+v, want the zero value", value) + } +} + +func TestLoad_CorruptedFileIsReported(t *testing.T) { + p := path(t) + if err := os.WriteFile(p, []byte("{not json"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if _, err := New[payload](p).Load(); err == nil { + t.Fatal("a corrupted file must be reported, not silently ignored") + } +} + +func TestSave_RoundTrips(t *testing.T) { + p := path(t) + f := New[payload](p) + + want := payload{Name: "vp-admin_br-000001", Items: []string{"a", "b"}} + if err := f.Save(want); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := New[payload](p).Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Name != want.Name || len(got.Items) != len(want.Items) { + t.Errorf("value = %+v, want %+v", got, want) + } +} + +func TestSave_RestoresTheModeAfterAnExternalChmod(t *testing.T) { + p := path(t) + f := New[payload](p) + if _, err := f.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if err := os.Chmod(p, 0o644); err != nil { + t.Fatalf("Chmod: %v", err) + } + + if err := f.Save(payload{Name: "x"}); err != nil { + t.Fatalf("Save: %v", err) + } + + info, err := os.Stat(p) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("mode = %o, want 600: each save must replace the file, not edit it in place", got) + } +} + +func TestSave_LeavesNoTemporaryFileBehind(t *testing.T) { + p := path(t) + f := New[payload](p) + + for range 3 { + if err := f.Save(payload{Name: "x"}); err != nil { + t.Fatalf("Save: %v", err) + } + } + + entries, err := os.ReadDir(filepath.Dir(p)) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + if len(entries) != 1 { + t.Errorf("directory holds %d entries, want only the state file: %v", len(entries), entries) + } +} + +func TestSave_DoesNotTruncateOnEncodingFailure(t *testing.T) { + p := path(t) + good := New[payload](p) + if err := good.Save(payload{Name: "kept"}); err != nil { + t.Fatalf("Save: %v", err) + } + + broken := New[chan int](p) + if err := broken.Save(make(chan int)); err == nil { + t.Fatal("an unencodable value must be reported") + } + + got, err := good.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Name != "kept" { + t.Errorf("value = %+v, want the previous state untouched", got) + } +} + +func TestRemove_DeletesTheFile(t *testing.T) { + p := path(t) + f := New[payload](p) + if _, err := f.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + if err := f.Remove(); err != nil { + t.Fatalf("Remove: %v", err) + } + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Errorf("the file must be gone, got %v", err) + } +} + +func TestRemove_OnAnAbsentFileIsNotAnError(t *testing.T) { + if err := New[payload](path(t)).Remove(); err != nil { + t.Errorf("removing an absent file must be idempotent, got %v", err) + } +} + +func TestPath_ReportsTheFileItOwns(t *testing.T) { + p := path(t) + if got := New[payload](p).Path(); got != p { + t.Errorf("Path = %s, want %s", got, p) + } +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 07d7668..d6b1828 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 metadata@.service" ;; + kvm) echo "agent.service dnsmasq@.service dhcp@.service metadata@.service" ;; intel) echo "" ;; *) return 1 ;; esac @@ -124,7 +124,7 @@ profile_units () { profile_binaries () { case "${1}" in - kvm) echo "agent metadata run-dnsmasq-in-netns.sh" ;; + kvm) echo "agent metadata dhcp run-dnsmasq-in-netns.sh run-dhcp-in-netns.sh" ;; intel) echo "" ;; *) return 1 ;; esac diff --git a/scripts/run-dhcp-in-netns.sh b/scripts/run-dhcp-in-netns.sh new file mode 100644 index 0000000..c09050f --- /dev/null +++ b/scripts/run-dhcp-in-netns.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +# Expects one argument: netns_bridge (e.g. vpc-00003_br-00002 or vpc1_br0) +# The netns is only needed here, to enter it. The server is handed its bridge +# and its two file paths, and knows nothing of the namespace it runs in. +arg="$1" +NETNS="${arg%%_*}" +BRIDGE="${arg#*_}" +RUN_DIR="/run/two/dhcp" + +if [[ "${NETNS}" == "${arg}" || -z "${NETNS}" || -z "${BRIDGE}" ]] +then + echo "instance ${arg} is not _" >&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 new file mode 100644 index 0000000..e29ff73 --- /dev/null +++ b/systemd/dhcp@.service @@ -0,0 +1,10 @@ +[Unit] +Description=two dhcp server in netns %i +After=network.target + +[Service] +Type=simple +ExecStart=/opt/two/bin/run-dhcp-in-netns.sh %i + +[Install] +WantedBy=multi-user.target