← Retour au site Chargement…

an answer written for this machine

rescriptum

Documentation complète

Généré le 30 août 2026

Guide

Ce qu'est rescriptum

Ce qu’est rescriptum

rescriptum — en droit romain, la réponse écrite d’une autorité à une question soulevée par un cas particulier. Vous exposiez votre situation ; vous receviez un document rédigé pour elle.

rescriptum sert la configuration d’installation qu’une machine réclame en s’installant, et la compose machine par machine à partir des couches que toute une baie partage. Un seul serveur répond à tous les installateurs que vous faites tourner. Servir un fichier est facile — décider lequel est tout le travail.

Le problème

Tout installateur automatisé va chercher sa configuration en HTTP, et chaque machine en a besoin d’une différente. L’URL est gravée dans le média, donc elle est identique sur chaque machine — un serveur de fichiers statique ne peut donc pas les servir. Les installateurs demandent de deux façons.

Ils POSTent ce qu’ils ont trouvé

Depuis Proxmox VE 8.2, un installateur préparé avec --fetch-from http POST une description JSON du matériel qu’il a trouvé — cartes réseau et adresses MAC, disques, DMI — et attend le fichier de réponse dans le corps de la réponse HTTP :

{
  "network_interfaces": [{ "mac": "98:fa:9b:50:d8:10", "link": "up" }],
  "dmi": { "system": { "serial": "7ABC123", "product": "PowerEdge R620" } }
}

La réponse dépend de la requête. Un serveur de fichiers statique ne sait pas faire ça : il a une réponse pour une URL, et l’URL est gravée dans l’ISO — identique sur chaque machine.

Ils GETent avec leur identité dans la query string

Tout le reste a la forme inverse. Un kickstart, un preseed, un autoinstall Ubuntu, une configuration Ignition, un profil AutoYaST sont récupérés, avec l’identité de la machine dans la query string, parce qu’iPXE la substitue dans l’URL qu’on lui a dit d’aller chercher :

GET /rhel/ks?serial=7ABC123&mac=98:fa:9b:50:d8:10

Dans les deux cas, la réponse doit être choisie — et le plus souvent assemblée — machine par machine.

Les quatre idées

1. L’endpoint déclare le format. Un client kickstart veut du kickstart et s’étranglerait avec du TOML. Donc /rhel/ks sert des documents .ks et rien d’autre, /proxmox/answer sert du .toml, /ubuntu/ sert du YAML. La conséquence qui fait comprendre le modèle : la réponse d’une machine est spécifique au système d’exploitation auquel elle est destinée, donc 98fa9b50d810/proxmox.toml n’est pas « cette machine » mais « cette machine en tant que Proxmox » — et 98fa9b50d810/debian.preseed, dans le même répertoire, est le même matériel en tant que Debian. Les deux existent en même temps. → Un document par système d’exploitation

2. Une machine est revendiquée, pas cherchée. Nommez un répertoire d’après la MAC et il gagne. Ou listez la machine dans les members d’un groupe. Ou écrivez un bloc [match] et laissez la machine être revendiquée pour ce qu’elle est — un Dell R620 dont le numéro de série commence par 7ABC. La résolution est déterministe : nommer bat matcher, plus de critères bat moins, les égalités se départagent sur le nom trié. → Comment une réponse est choisie

3. Les réponses se composent. Une baie de machines partage tout sauf ses adresses MAC. Mettez la partie commune dans un groupe ; une machine qui diffère reçoit un document contenant seulement la différence. Les formats structurés fusionnent vraiment — les maps clé par clé, les tableaux remplacés pour qu’une liste puisse encore être raccourcie. Ajoutez des placeholders {{ serial }} et un seul document de groupe couvre cinq cents machines. → Groupes et fusion · Templating

4. Ce qui est servi est relisible avant d’être servi. La fusion crée un document que personne n’a jamais écrit, et une mauvaise fusion se manifeste par une installation automatisée ratée à 3 h du matin. rescriptum render affiche exactement ce qu’une machine donnée recevrait ; rescriptum check rend tout et signale ce qui casse, en appelant le validateur de l’installateur lui-même quand il est dans le PATH. → Valider ce qui sera servi

Ce que ce n’est pas

  • Pas un serveur DHCP, sous aucune forme. Ni répondeur, ni proxy, ni derrière un drapeau. Les sites qui déploient ceci en ont déjà un, et le faire pointer vers un serveur de démarrage est un problème résolu depuis trente ans.
  • Pas un système de gestion de configuration. Il livre un document au moment de l’installation et n’a ensuite plus rien à voir avec la machine — rien de ce qu’il installe n’en dépend ensuite.
  • Pas un validateur de schéma. Il prouve que vos documents sont bien formés et fusionnent proprement. Savoir si le résultat est du Proxmox valide est le travail de proxmox-auto-install-assistant, et check l’appellera s’il est installé.

Deux réalités de déploiement

Les deux sont réelles, et la conception doit satisfaire les deux :

  • Un Synology DS416j — ARMv7, 512 Mo, DSM 7, pas de Docker. La motivation d’origine, et la raison pour laquelle c’est un binaire statique unique sans runtime ni interpréteur.
  • Un hôte de datacenter encaissant une rafale de provisioning, avec un répertoire de réponses par machine. La raison pour laquelle il est asynchrone, borne sa propre concurrence, et met en cache le listing du répertoire au lieu de le parcourir à chaque requête.

À 2 000 machines, une baie servie depuis un seul groupe rend 13 000 requêtes/seconde sans rien parser par requête — le groupement est le chemin rapide, pas seulement le plus propre.

Où aller ensuite

Vous travaillez sur rescriptum plutôt qu’avec ? L’espace Développement est l’autre moitié de ce site.

Installation

Installation

rescriptum est un binaire autonome. Pas de runtime à installer, pas d’interpréteur, pas d’image de conteneur, et rien d’écrit en dehors du répertoire que vous lui indiquez. Copiez-le quelque part et lancez-le.

Télécharger une release

Les binaires de chaque cible publiée sont attachés à chaque release, avec une somme SHA-256 à côté.

CiblePour
armv7-unknown-linux-gnueabihfSynology DS416j et autres NAS ARMv7 (glibc ≥ 2.17)
aarch64-unknown-linux-muslNAS ARM récents, Raspberry Pi
x86_64-unknown-linux-muslla plupart des autres hôtes Linux
aarch64-apple-darwindéveloppement local, Apple silicon
x86_64-apple-darwindéveloppement local, Mac Intel
$ VERSION=0.2.0 TARGET=x86_64-unknown-linux-musl
$ curl -fsSLO https://github.com/z29k/rescriptum/releases/download/v$VERSION/rescriptum-$VERSION-$TARGET.tar.gz
$ curl -fsSLO https://github.com/z29k/rescriptum/releases/download/v$VERSION/rescriptum-$VERSION-$TARGET.tar.gz.sha256
$ shasum -a 256 -c rescriptum-$VERSION-$TARGET.tar.gz.sha256
$ tar xzf rescriptum-$VERSION-$TARGET.tar.gz
$ sudo install -m755 rescriptum-$VERSION-$TARGET/rescriptum /usr/local/bin/

Vérifiez la somme. Ce binaire tourne en root sur du matériel que vous êtes sur le point d’installer, ce qui représente à peu près toute la confiance qu’un programme puisse obtenir.

Sur un Synology

Prenez plutôt le .spk de votre modèle — rescriptum-<version>-armv7.spk pour le DS416j et les autres machines armada38x, -x86_64.spk pour tous les modèles Intel — et installez-le par Package Center → Installation manuelle. Il crée le dossier partagé, enregistre le port auprès du pare-feu, lie le CLI dans le PATH et démarre au boot. Les détails, et ce qu’il ne fait délibérément pas pour vous, sont sur la page Synology.

Les builds Linux sont liés à musl statiquement — sauf armv7, qui vise la glibc 2.17 parce que musl 1.2 ne peut pas tourner sur les noyaux 3.10 de Synology (voir la page de build) :

$ file /usr/local/bin/rescriptum
ELF 64-bit LSB executable, x86-64, ... statically linked, stripped

Ou le construire

Un build natif ne demande qu’une toolchain Rust :

$ git clone https://github.com/z29k/rescriptum && cd rescriptum
$ ./build.sh

La compilation croisée pour le NAS demande cargo-zigbuild et Zig, qui remplacent une toolchain croisée complète. La page de build donne les détails, y compris ce qu’il faut vérifier selon la cible : que les builds musl sont bien statiques, et que le build armv7 ne réclame pas une glibc plus récente que celle du NAS — les deux échouent au moment de l’exec, sur la machine, et non au build sur votre portable.

Le lancer

$ mkdir -p /srv/answers
$ RESCRIPTUM_ANSWERS_DIR=/srv/answers rescriptum
2026-08-22T18:00:00Z - rescriptum 0.1.0 listening on 0.0.0.0:8000 — store=files:/srv/answers workers=8 max_conn=2048 timeout=10s
2026-08-22T18:00:00Z - warning: /srv/answers does not exist yet — every request will 404 until it does

La ligne de démarrage mérite d’être lue plutôt que défilée :

ChampSignification
listening onl’adresse réellement bindée, pas celle demandée — avec :0 elles diffèrent
store=files:<dir> ou sqlite:<path>, pour qu’un store mal configuré saute aux yeux
workers=threads du runtime, nombre de CPU par défaut. Pas une limite de concurrence
max_conn=connexions en vol avant que le serveur ne délestage en 503
timeout=délai de lecture des en-têtes et échéance de la connexion entière

Tout ce qui cloche dans le jeu de réponses — un groupe qui étend un groupe inexistant, un document qui ne parse pas — est aussi signalé ici, une fois, au démarrage. C’est également signalé par rescriptum check, qui est le meilleur endroit pour l’apprendre.

Confirmez qu’il est vivant :

$ curl http://localhost:8000/health
OK

GET /health est le seul endpoint qui n’exige jamais de jeton et n’est jamais limité en débit, donc une supervision continue de fonctionner même pendant que le serveur refuse tout le reste.

Où il regarde par défaut

RESCRIPTUM_ANSWERS_DIR vaut par défaut /srv/answers, et RESCRIPTUM_DB_PATH /srv/answers.db. /srv est l’endroit où la norme de hiérarchie des fichiers range les données servies par le système, ce qu’elles sont. Rien ne crée le répertoire pour vous ; la ligne de démarrage le signale s’il manque.

Tout se configure par l’environnement ; il n’y a pas de format de configuration à apprendre ni de ligne de commande à se tromper. Si vous n’avez nulle part où mettre un jeton — DSM 7, par exemple — RESCRIPTUM_ENV_FILE nomme un fichier contenant les mêmes variables. La liste complète est dans la référence de configuration.

Ensuite

Servir sa première réponse

Servir sa première réponse

Cinq minutes, un terminal, aucun installateur nécessaire. Tout ici est testable hors ligne : rescriptum render résout une réponse exactement comme le ferait le serveur, donc vous pouvez avoir la bonne réponse avant qu’aucune machine ne démarre.

1. Un répertoire et un document

$ mkdir -p answers/groups/rack-a

Un répertoire par identité. Un répertoire à la racine est une machine, nommé d’après elle ; groups/ contient ceux qui sont partagés. Dans l’un comme dans l’autre, l’extension nomme le format et le reste du nom de fichier n’est qu’une étiquette. Commencez par un groupe, puisque c’est la forme que prend presque tout déploiement réel — une baie de machines d’accord sur tout sauf sur les disques qu’elles ont :

# answers/groups/rack-a/proxmox.toml
members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"]

[global]
keyboard = "fr"
country = "fr"
timezone = "Europe/Paris"
root-password-hashed = "$6$rounds=656000$REPLACE$ME"

[network]
source = "from-dhcp"

[disk-setup]
filesystem = "zfs"
zfs.raid = "raid1"
disk-list = ["sda", "sdb"]

members liste les machines pour lesquelles ce groupe répond. Le style de séparateur n’a pas d’importance — 98:fa:9b:50:d8:10, 98-FA-9B-50-D8-10 et 98fa9b50d810 sont une seule MAC, des deux côtés de la comparaison. members est une clé de rescriptum, pas de Proxmox, et elle est retirée de ce que l’installateur reçoit.

2. Voir ce qu’une machine recevrait

$ RESCRIPTUM_ANSWERS_DIR=answers rescriptum render 98:fa:9b:50:d8:11
# format=toml group=rack-a

[global]
keyboard = "fr"
country = "fr"
timezone = "Europe/Paris"
root-password-hashed = "$6$rounds=656000$REPLACE$ME"

[network]
source = "from-dhcp"

[disk-setup]
filesystem = "zfs"
zfs.raid = "raid1"
disk-list = ["sda", "sdb"]

La première ligne part sur stderr et dit comment la réponse a été obtenue — la famille de format, quel document machine a matché, quel groupe s’est appliqué. Le document lui-même part sur stdout, donc render … > answer.toml ne vous donne que le document.

3. Une machine qui diffère

Le second nœud de la baie a quatre disques. Il reçoit un répertoire nommé d’après sa MAC, contenant un document avec seulement la différence :

# answers/98-fa-9b-50-d8-10/proxmox.toml
[global]
fqdn = "node01.example.com"

[disk-setup]
zfs.raid = "raid10"
disk-list = ["sda", "sdb", "sdc", "sdd"]
$ RESCRIPTUM_ANSWERS_DIR=answers rescriptum render 98:fa:9b:50:d8:10
# format=toml machine=98-fa-9b-50-d8-10 group=rack-a

[global]
keyboard = "fr"
country = "fr"
timezone = "Europe/Paris"
root-password-hashed = "$6$rounds=656000$REPLACE$ME"
fqdn = "node01.example.com"

[network]
source = "from-dhcp"

[disk-setup]
filesystem = "zfs"
zfs.raid = "raid10"
disk-list = ["sda", "sdb", "sdc", "sdd"]

Le groupe d’abord, le document propre à la machine par-dessus, et la machine a gagné partout où les deux étaient en désaccord. Les tables ont fusionné clé par clé ; disk-list a été remplacée, pas concaténée — une liste qui ne pourrait que grandir ne pourrait jamais être raccourcie depuis une couche supérieure.

4. Vérifier l’ensemble

$ RESCRIPTUM_ANSWERS_DIR=answers rescriptum check
checking files:answers
  1 group(s), 1 machine document(s)
  note: toml answers not schema-checked — proxmox-auto-install-assistant is not on PATH
  ok — everything renders

check rend chaque machine et chaque membre de groupe, et signale tout ce qui casse : un document qui ne parse pas, un groupe qui en étend un inexistant, un placeholder que rien ne peut remplir. Là où le validateur de l’installateur est dans le PATH, il le lance aussi, et dit quels formats il n’a pas pu vérifier.

C’est la commande à mettre en CI si vos réponses vivent dans git.

5. Les servir pour de vrai

$ RESCRIPTUM_ANSWERS_DIR=answers rescriptum
2026-08-24T08:43:36Z - rescriptum 0.1.0 listening on 0.0.0.0:8000 — store=files:answers workers=10 max_conn=2048 timeout=10s

Dans un autre terminal, imitez ce qu’envoie l’installateur Proxmox :

$ curl -s -X POST http://localhost:8000/answer \
    -d '{"network_interfaces":[{"mac":"98:fa:9b:50:d8:10","link":"up"}]}'

et regardez le serveur dire ce qu’il a fait :

2026-08-24T08:43:37Z 127.0.0.1:61721 POST /answer body=102 200 format=toml machine=98fa9b50d810 group=rack-a bytes=431

Cette ligne est tout le diagnostic disponible quand un déploiement dérape : qui a demandé, quelle taille faisait son corps, ce qu’il a reçu, et à partir de quoi c’était construit.

Les nouveaux documents sont pris en compte au fur et à mesure — pas de redémarrage, pas de signal de rechargement. L’apparition ou la disparition du répertoire entier d’une machine est vue immédiatement ; un document ajouté ou modifié à l’intérieur de l’un d’eux est pris en compte en moins d’une seconde.

À lire ensuite

Préparer les médias d'installation

Préparer les médias d’installation

Chaque installateur se voit dire, au moment de la fabrication du média, où récupérer sa configuration. Cette URL fait ici deux choses :

  1. Elle atteint le serveur. N’importe quel chemin fonctionne — POST et GET sont traités sur tous, précisément pour que l’URL gravée dans une ISO ne soit jamais fausse.
  2. Son chemin déclare le format. Un segment nommant un alias connu restreint la réponse aux documents de ce format, pour qu’un client kickstart ne reçoive pas du TOML.

Donnez à chaque installateur son URL, et un seul serveur répond pour tous.

Proxmox VE

$ proxmox-auto-install-assistant prepare-iso proxmox-ve.iso \
    --fetch-from http \
    --url http://SERVER:8000/proxmox/answer \
    --output proxmox-auto.iso

L’installateur POST un inventaire JSON du matériel trouvé et attend la réponse dans le corps de la réponse HTTP. /proxmox/ restreint la réponse aux documents .toml ; /answer seul ne nomme aucun alias et ne contraint rien, ce qui est pourquoi un déploiement existant continue de fonctionner sans changement.

Pour exiger une authentification, préparez l’ISO avec un jeton et donnez le même au serveur :

$ proxmox-auto-install-assistant prepare-iso proxmox-ve.iso \
    --fetch-from http --url http://SERVER:8000/proxmox/answer \
    --answer-auth-token 'une-longue-chaine-aleatoire' --output proxmox-auto.iso

$ export RESCRIPTUM_ANSWER_TOKEN='une-longue-chaine-aleatoire'

Voir Sécurité pour ce que cela protège et ce que cela ne protège pas.

Sans refabriquer l’ISO

Proxmox peut aussi découvrir l’URL au démarrage, ce qui évite de refabriquer le média quand l’adresse change :

  • un enregistrement DNS TXT sur proxmox-auto-installer.<votre-domaine>, ou
  • l’option DHCP 250.

Les deux sont hors du périmètre de ce serveur — il lui suffit d’être à l’adresse qu’ils nomment.

Tout le reste, via iPXE

Les autres installateurs récupèrent leur configuration et s’identifient dans la query string, parce qu’iPXE substitue ses propres variables dans l’URL avant de la chercher :

VariableVaut
${net0/mac}l’adresse MAC de la première carte réseau
${uuid}l’UUID système SMBIOS
${serial}le numéro de série système
${manufacturer}, ${product}fabricant et modèle DMI

Ces valeurs deviennent des faits sur lesquels un document peut être sélectionné, et alimentent aussi la botte de foin — donc un document nommé d’après une MAC résout que la MAC soit arrivée dans un corps POST ou dans une query string.

InstallateurParamètre de boot
RHEL / CentOS / Fedora / Alma / Rockyinst.ks=http://SERVER:8000/rhel/ks?mac=${net0/mac}
Debian preseedurl=http://SERVER:8000/debian/preseed?mac=${net0/mac}
Ubuntu autoinstallautoinstall ds=nocloud-net;s=http://SERVER:8000/ubuntu/?mac=${net0/mac}
Flatcar / Fedora CoreOSignition.config.url=http://SERVER:8000/flatcar/config?mac=${net0/mac}
openSUSE / SLESautoyast=http://SERVER:8000/suse/profile?mac=${net0/mac}
Windowsrécupéré par votre propre outillage depuis http://SERVER:8000/windows/unattend

Un fragment de script iPXE complet :

#!ipxe
set base http://SERVER:8000
kernel ${base}/images/rhel9/vmlinuz inst.ks=${base}/rhel/ks?mac=${net0/mac}&serial=${serial}
initrd ${base}/images/rhel9/initrd.img
boot

rescriptum sert la réponse, pas le noyau — le netboot reste au serveur TFTP/HTTP que vous faites déjà tourner.

Ubuntu et cloud-init NoCloud

La source de données NoCloud de cloud-init récupère deux fichiers nommés depuis l’URL de seed — user-data et meta-data — et ignore complètement la source de données si l’un des deux manque. Comme ce serveur répond sur n’importe quel chemin, les deux requêtes recevraient sinon le même document et l’installation ne démarrerait jamais.

Le dernier segment du chemin est disponible comme fait file, ce qui permet de les distinguer avec un sélecteur :

# answers/groups/ubuntu-web/ubuntu.yaml
match:
  file: "user-data"
  product: "PowerEdge R6*"
# answers/groups/ubuntu-meta/ubuntu.yaml
match:
  file: "meta-data"

instance-id: iid-local01

Notez le slash final dans s=http://SERVER:8000/ubuntu/ — cloud-init y accole le nom de fichier.

NoCloud peut aussi développer __dmi.chassis-serial-number__ dans l’URL de seed, ce qui met l’identité de la machine dans le chemin plutôt que dans la query. Les segments de chemin alimentent aussi la botte de foin, donc un document nommé d’après ce numéro de série résout quand même.

Choisir l’alias

Segment d’URLSert les documents d’extension
proxmox, pve, toml.toml
debian, preseed.preseed, .seed
rhel, centos, fedora, alma, rocky, kickstart, ks.ks
ubuntu, autoinstall, cloudinit, nocloud, yaml, yml.yaml, .yml
flatcar, coreos, ignition, ign.ign, .json
suse, opensuse, autoyast.autoyast, .xml
windows, unattend.unattend, .xml
json.json, .ign
xml, cfg, ipxel’extension correspondante

N’importe quel segment du chemin peut nommer l’alias, donc /rhel/ks, /ks et /provision/rhel/node.cfg restreignent tous au kickstart. Une URL n’en nommant aucun — /answer — ne contraint rien.

La table complète, et pourquoi seed n’est délibérément pas un alias, sont dans la référence des formats.

Ensuite

Écrire des réponses

Écrire des réponses

Une réponse est le document que reçoit un installateur : un answer.toml Proxmox, un user-data d’autoinstall Ubuntu, un kickstart, un preseed, une configuration Ignition, un profil AutoYaST, un unattend.xml Windows. Tout le travail de rescriptum consiste à choisir le bon pour la machine qui demande et à le lui remettre, assemblé à partir du nombre de couches que vous avez écrites.

L’agencement

Un répertoire par identité. Une machine est un répertoire nommé d’après elle, qui contient un document par système d’exploitation :

answers/
├── 98fa9b50d810/           une machine
│   ├── proxmox.toml            en tant que Proxmox
│   └── debian.preseed          …et le même matériel en tant que Debian
├── aabbccddeeff/           une autre machine
│   └── ubuntu.yaml             en tant qu'Ubuntu
├── default/                quand rien d'autre ne correspond
│   └── proxmox.toml
└── groups/
    ├── rack-a/             partagé par une baie, revendique ses membres
    │   ├── proxmox.toml
    │   └── debian.preseed
    └── rhel-compute/       revendique des machines pour ce qu'elles sont
        └── rhel.ks
  • Une machine est un répertoire nommé d’après elle — une adresse MAC, dans n’importe quel style de séparateur — qui porte la configuration de cette machine, ou seulement la part qui diffère de son groupe.
  • Un groupe est un répertoire sous groups/ et il est partagé. Il revendique des machines en les listant dans members, ou par un bloc match testé contre la requête.
  • default/ répond quand rien d’autre ne le fait. Un document par format : un défaut TOML ne doit pas répondre à un client qui a demandé du kickstart.

L’extension décide ; le nom, non

Dans un répertoire, l’extension est le format et ce qui précède ne veut rien dire. proxmox.toml et answer.toml sont le même document pour le serveur ; le nom est là pour qui ouvre le dossier. rescriptum en écrit des lisibles — proxmox.toml, ubuntu.yaml, debian.preseed, boot.ipxe — et ne renomme jamais les vôtres.

La seule règle qui en découle : un répertoire contient au plus un document par format. Deux .toml dans un même répertoire est signalé comme un problème plutôt que tranché, parce que rien ne pourrait choisir entre eux d’une façon que vous auriez prévue. Deux formats différents ne sont pas un doublon — c’est justement l’intérêt du répertoire.

L’organisation du stockage et l’URL restent délibérément séparées : un dossier peut être réorganisé, une URL gravée dans une ISO non. Voir formats.

:::note[Migration depuis un répertoire plat] Les réponses étaient des fichiers à la racine du répertoire : 98fa9b50d810.toml à côté de 98fa9b50d810.preseed. Ils ne sont plus servis, et chacun est signalé par son nom avec son nouveau chemin. rescriptum migrate montre ce qu’il déplacerait ; rescriptum migrate --apply les déplace. :::

Les cinq choses à savoir

Comment une réponse est choisiePar nom, par liste de membres, ou par ce que la machine est. Nommer gagne toujours ; entre sélecteurs, plus de critères gagne ; les égalités se départagent sur le nom trié
Un document par système d’exploitationL’extension est le format, l’endpoint choisit entre eux, et une machine peut exister en plusieurs systèmes à la fois
Groupes et fusionLes couches s’appliquent de la plus basse à la plus haute et la machine gagne toujours. Les maps fusionnent ; les tableaux remplacent
Templating{{ serial }} rempli depuis la requête, pour qu’un fichier de groupe couvre une baie
Validationrender montre ce qu’une machine recevrait ; check rend tout et signale ce qui casse

Les clés de contrôle

Trois clés pilotent la résolution et sont retirées avant que la réponse ne soit envoyée, donc l’installateur ne les voit jamais :

CléRôle
membersles machines pour lesquelles ce groupe répond
matchdes critères testés contre les faits de la requête
extendsle groupe au-dessus duquel ce document se superpose

Elles voyagent dans ce que chaque format permet — clés de premier niveau en TOML, YAML et JSON, un élément <answer-meta> en XML, des directives # answer: en kickstart et preseed. L’écriture par format est dans formats.

Exemples travaillés

Le répertoire examples/ du dépôt contient un exemple commenté de chaque format supporté, tous sélectionnés différemment — par matériel, par liste de membres, par nom de répertoire — et ils sont exercés par la suite de tests :

$ RESCRIPTUM_ANSWERS_DIR=examples rescriptum check
$ RESCRIPTUM_ANSWERS_DIR=examples rescriptum render --query "path=/rhel/ks&serial=7ABC123"

C’est le seul endroit où les formats sont montrés en train de se composer ensemble ; commencez par là si vous ne savez pas à quoi ressemble un vrai fichier.

Comment une réponse est choisie

Comment une réponse est choisie

Un document n’est pas cherché ; il revendique la requête. Trois façons de le faire, ordonnées par la finesse avec laquelle elles visent une machine.

1. Par le nom

Nommez un répertoire d’après l’adresse MAC de la machine, et mettez-y ses documents :

answers/
├── 98-fa-9b-50-d8-10/
│   └── proxmox.toml
├── aabbccddeeff/
│   └── proxmox.toml
└── default/
    └── proxmox.toml

Quand une requête arrive, le serveur passe en minuscules tout ce qu’elle porte et retire chaque caractère non alphanumérique, fait de même avec le nom de chaque répertoire, et sert le premier dont le nom apparaît à l’intérieur de la requête. Ainsi 98-fa-9b-50-d8-10, 98:fa:9b:50:d8:10 et 98fa9b50d810 nomment tous la même machine — vous n’avez jamais à vous soucier du style de séparateur que Proxmox utilise cette version-ci, ni de la façon dont il structure son JSON.

L’identité est le nom du répertoire ; les noms de fichiers qu’il contient ne choisissent rien, ils ne portent que le format dans leur extension.

Cette normalisation est toute l’astuce, et c’est pourquoi cela survit à un changement de format de corps entre versions de Proxmox : c’est un test de sous-chaîne sur des octets, pas un schéma.

Rien n’empêche de nommer un répertoire d’après un numéro de série, un code d’inventaire ou un nom d’hôte. N’importe quelle chaîne apparaissant dans ce que la machine envoie fera l’affaire.

2. Par liste de membres

Un groupe revendique un ensemble de machines en les listant :

# answers/groups/rack-a/proxmox.toml
members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", "98:fa:9b:50:d8:12"]

Les chaînes de members sont normalisées exactement comme les noms de répertoires, donc le style de séparateur n’a pas d’importance ici non plus. Une machine listée n’a besoin d’aucun répertoire propre à moins d’avoir quelque chose à surcharger — voir groupes.

3. Par ce que la machine est

Un bloc match revendique une machine par ses propriétés plutôt que par son identité :

# answers/groups/dell-r620/proxmox.toml
[match]
manufacturer = "Dell Inc."
product      = "PowerEdge R620"
serial       = "7ABC*"          # * et ? fonctionnent

Tous les critères doivent tenir pour que le groupe revendique la requête. * correspond à n’importe quelle suite de caractères et ? à exactement un ; les deux côtés sont normalisés avant comparaison, donc la casse et le style de séparateur n’ont jamais d’importance.

Un document machine peut porter un bloc match lui aussi — utile pour « quelle que soit la machine actuellement dans cet emplacement de châssis ».

Les faits qu’un sélecteur peut tester

Les faits viennent de trois endroits, délibérément étagés du plus structuré au moins structuré.

Les paramètres de query

?mac=…&uuid=…&serial=… — la façon dont tout installateur autre que Proxmox s’identifie, parce qu’iPXE substitue les valeurs dans l’URL avant de la chercher. Fiable, clés arbitraires, aucune devinette.

Trois autres sont synthétisés depuis l’URL elle-même :

FaitVaut
pathle chemin entier, débarrassé de ses slashes — rhel/ks
fileson dernier segment — ks. C’est ce qui distingue le user-data de cloud-init de son meta-data
segmentchaque segment, comme valeurs séparées — rhel et ks

Un corps JSON POSTé

Quand le corps est réellement du JSON, il est aplati en à la fois ses chemins pointés complets et ses noms de feuilles nus :

{ "dmi": { "system": { "serial": "7ABC123" } } }

donne à la fois dmi.system.serial et serial tout court. La forme feuille est l’essentiel. La documentation de Proxmox avertit elle-même que le contenu de dmi « peut varier énormément selon le système », donc un sélecteur disant « un champ nommé serial, où qu’il se trouve » survit à une réorganisation qu’un chemin figé ne supporterait pas.

Les indices de tableau font partie du chemin mais pas du nom de feuille, donc network_interfaces.0.mac est aussi atteignable par mac tout court.

Un corps qui n’est pas du JSON n’est pas une erreur — il n’apporte simplement rien d’autre que la botte de foin.

Le corps brut

Normalisé en alphanumériques minuscules : la botte de foin de sous-chaînes qui fait fonctionner la correspondance par nom. Les valeurs de query et les segments de chemin y sont aussi ajoutés, donc un répertoire nommé d’après une MAC résout que la MAC soit arrivée dans un corps POST ou dans une query string. Sans cela, un GET — qui n’a aucun corps — ne pourrait jamais correspondre par nom.

Quand plusieurs documents revendiquent la même requête

La règle est fixe, et un test l’épingle :

  1. Nommer une machine gagne toujours. Quel que soit le nombre de critères d’un sélecteur, une correspondance d’identité le bat — nommer une machine est la chose la plus précise qu’on puisse faire.
  2. Entre sélecteurs, plus de critères gagne. Trois critères satisfaits battent deux ; une règle plus délibérée est une règle plus spécifique.
  3. Les égalités se départagent sur le nom trié. Le premier par ordre alphabétique.

La réponse ne dépend jamais de l’ordre du système de fichiers ni de l’ordre dans lequel les lignes sont sorties d’une base. matchbox, l’antécédent le plus proche, documente que sa propre résolution entre groupes concurrents « ne sera pas déterministe ». Celle-ci l’est.

Seul le premier groupe correspondant s’applique. La composition s’exprime avec extends, pas en fusionnant tous les groupes qui correspondent — l’ordre entre plusieurs groupes correspondants serait arbitraire, et un ordre arbitraire est la façon dont une machine reçoit discrètement le mauvais schéma de disques.

Et si rien ne correspond

default.<ext> est servi, s’il en existe un pour le format demandé par l’endpoint. Sinon la réponse est 404 — journalisée en no answer file applies.

Essayer avant de démarrer quoi que ce soit

render résout exactement comme le ferait le serveur, à partir des faits que vous fournissez :

$ rescriptum render 98:fa:9b:50:d8:10                              # par identité
$ rescriptum render --query "serial=7ABC123&mac=98:fa:9b:50:d8:10" # par étiquette
$ rescriptum render --query "path=/rhel/ks&serial=7ABC123"         # endpoint compris
$ rescriptum render --body captured-request.json                   # un vrai corps capturé

Un identifiant nu ne prétend rien sur quel genre d’identifiant il est — il remplit la botte de foin et rien d’autre. C’est assez pour la correspondance par nom, mais un sélecteur sur serial a besoin de --query "serial=…" pour avoir quelque chose à tester. check fonctionne pareil, ce qui est pourquoi un template ayant besoin d’un fait propre à la requête est signalé comme problème.

Pour capturer ce que vos machines envoient réellement, voir Capturer les requêtes.

Un document par système d'exploitation

Un document par système d’exploitation

Un installateur qui va chercher une URL attend une chose bien précise en retour. Un client kickstart veut du kickstart et s’étranglerait avec du TOML. C’est le protocole, pas une convention que quelqu’un aurait choisie. Donc :

  • l’endpoint déclare le format/rhel/ks demande du kickstart ;
  • le document le porte comme extensiongroups/rhel-compute/rhel.ks ;
  • seuls les documents de ce format peuvent répondre.

La conséquence qui fait comprendre

La réponse d’une machine est spécifique au système d’exploitation auquel elle est destinée. Donc ceci n’est pas une machine et deux fichiers :

answers/
└── 98fa9b50d810/
    ├── proxmox.toml        « cette machine, en tant que Proxmox »
    └── debian.preseed      « cette machine, en tant que Debian »

C’est un même matériel avec deux réponses, et les deux peuvent exister en même temps. Celle qu’une requête reçoit dépend de l’URL sur laquelle elle est arrivée — /proxmox/answer obtient le TOML, /debian/preseed obtient le preseed. Aucune n’est plus « la » réponse que l’autre.

En interne, c’est pourquoi un document est indexé par (identifiant, format) plutôt que par identifiant seul — et pourquoi un répertoire contient un document par format, pas davantage.

Le stockage n’est pas l’URL

Les documents sont regroupés par identité, jamais par format, et c’est délibéré. Répertoires et lignes de base sont un espace de recherche — ils doivent rester libres d’être réorganisés. Une URL est un contrat public gravé dans une ISO — elle ne doit pas bouger parce que quelqu’un a renommé un dossier. Une conception antérieure faisait du nom de répertoire le segment d’URL et a été écartée pour exactement cette raison.

La même règle explique pourquoi le nom de fichier dans le répertoire d’une machine ne porte aucun sens : proxmox.toml se lit bien et fait écho à l’endpoint /proxmox/, mais seul le .toml est porteur. Renommez-le answer.toml et rien ne change.

Quel alias sert quelle extension est dans la référence des formats ; comment en choisir un pour votre média est dans préparer les médias d’installation.

Les formats

ExtensionPourSuperposition
tomlProxmox VEfusion structurelle
yaml, ymlautoinstall Ubuntu, cloud-initfusion structurelle
json, ignIgnition, Flatcar, Fedora CoreOSfusion structurelle
xml, autoyast, unattendAutoYaST, unattend.xml Windowsfusion structurelle, par élément
kskickstart — RHEL, CentOS, Fedora, Alma, Rockyconcaténation
preseed, seedpreseed Debianconcaténation
cfg, ipxescripts de boot et autres configurations en lignesconcaténation

La liste blanche est délibérée : txt n’y est pas, pour qu’un fichier de notes égaré à côté de vos réponses ne devienne jamais un candidat.

.autoyast et .unattend sont du XML sous un nom qui dit lequel, pour qu’un store contenant à la fois un profil SUSE et un unattend Windows puisse les distinguer. Le .xml simple répond encore aux deux, ce qui va très bien jusqu’au jour où vous avez les deux.

Fusion structurelle

Pour toml, yaml, json et xml, la superposition est une vraie fusion :

  • Les maps fusionnent clé par clé, récursivement — y compris les tables inline et pointées de TOML.
  • Toute autre valeur est remplacée intégralement par la couche supérieure.
  • Les tableaux remplacent, ils ne concatènent pas. Concaténer rendrait une liste impossible à raccourcir depuis une couche supérieure, et « ce nœud a deux disques, pas quatre » doit rester exprimable.

Les détails, avec exemples, sont dans groupes.

Concaténation

Pour ks, preseed, cfg, seed et ipxe, la superposition est une concaténation dans l’ordre des couches, et le module le dit plutôt que de prétendre le contraire. Une directive d’une couche supérieure suit celle d’une couche inférieure au lieu de la supprimer.

Savoir si cela équivaut à une surcharge est l’affaire du format cible : la dernière réponse gagne en preseed, ce n’est pas toujours le cas en kickstart. Rendez le résultat et lisez-le avant de confier une baie à cela.

Une chose à savoir avant d’écrire une dissertation en tête d’un kickstart : les commentaires ordinaires sont servis. Seules les lignes de directive # answer: sont retirées. C’est très bien — kickstart et preseed autorisent tous deux les commentaires — mais l’installateur verra tout le reste.

XML

XML apparie les frères par nom d’élément plus un attribut discriminantname, id, key, alias ou pass. C’est ce qui rend

<settings pass="specialize">
  <component name="Microsoft-Windows-Shell-Setup" …>

fusionnable : surcharger une pass laisse les autres tranquilles, et surcharger un component ne remplace pas tous les autres composants du fichier. Des frères répétés sans attribut discriminant sont traités comme une liste, et le config:type="list" d’AutoYaST est respecté.

Ce qui survit à une fusion : la déclaration <?xml?>, le <!DOCTYPE>, les espaces de noms et les attributs. Ce qui n’y survit pas : l’indentation d’origine et le placement des commentaires — la sortie est re-rendue, pas rustinée.

Il ne comprend aucun schéma. Rendez et vérifiez avant de lui confier une baie.

Où vivent les clés de contrôle

Les clés de contrôle voyagent dans ce que chaque format permet, et sont retirées avant que la réponse ne soit envoyée.

TOML

extends = "base"
members = ["98:fa:9b:50:d8:10"]

[match]
product = "PowerEdge R6*"

YAML / JSON — les mêmes trois, en clés de premier niveau :

extends: base
members: ["98:fa:9b:50:d8:10"]
match:
  file: "user-data"
  product: "PowerEdge R6*"

XML — un élément <answer-meta>, avec extends en attribut dessus :

<answer-meta extends="base">
  <member>52:54:00:11:22:33</member>
  <match manufacturer="Dell Inc." product="PowerEdge R6*" />
</answer-meta>

Kickstart, preseed et tout ce qui est en lignes — des directives # answer: (// fonctionne aussi, pour les formats qui commentent ainsi) :

# answer: extends base
# answer: member 00:11:22:33:44:55, 00:11:22:33:44:56
# answer: match serial=7ABC* product=PowerEdge*

match prend des paires clé=motif séparées par des espaces, member une liste séparée par des virgules.

Une réponse, un format

Toutes les couches d’une même réponse doivent être du même format. Un document machine YAML au-dessus d’un groupe TOML est refusé, pas servi à moitié, et extends se résout à l’intérieur d’un format pour la même raison — superposer un preseed sur une base TOML n’a aucun sens.

Le groupement n’est par ailleurs pas affecté par tout cela : une baie partage un groupe par format, et une machine qui existe en deux systèmes d’exploitation rejoint deux d’entre eux.

default suit la même règle — un .toml dans default/ répond à une requête qui a demandé du TOML, et jamais à une qui a demandé du kickstart.

Groupes et fusion

Groupes et fusion

Une baie de machines partage d’habitude tout sauf ses adresses MAC. L’écrire une fois par machine est la façon dont la configuration d’un parc dérive. Donc les réponses se composent.

answers/
├── groups/
│   ├── base/
│   │   └── proxmox.toml       partagé par tout
│   └── rack-a/
│       └── proxmox.toml       extends = "base" ; members = [ … ]
├── 98-fa-9b-50-d8-10/
│   └── proxmox.toml           les surcharges d'une machine (optionnel)
└── default/
    └── proxmox.toml           seulement quand rien d'autre ne correspond

La partie partagée

# answers/groups/rack-a/proxmox.toml
members = [
  "98:fa:9b:50:d8:10",
  "98:fa:9b:50:d8:11",
  "98:fa:9b:50:d8:12",
]

[global]
keyboard = "fr"
country  = "fr"
timezone = "Europe/Paris"

[disk-setup]
filesystem = "zfs"
zfs.raid   = "raid1"
disk-list  = ["sda", "sdb"]

La différence

Une machine qui diffère reçoit un document contenant seulement la différence :

# answers/98-fa-9b-50-d8-10/proxmox.toml
[global]
fqdn = "node01.example.com"

[disk-setup]
zfs.raid  = "raid10"                       # celle-ci a quatre disques
disk-list = ["sda", "sdb", "sdc", "sdd"]

…et reçoit les deux fusionnés, ses propres valeurs l’emportant :

$ rescriptum render 98:fa:9b:50:d8:10
# format=toml machine=98-fa-9b-50-d8-10 group=rack-a

[global]
keyboard = "fr"
country = "fr"
timezone = "Europe/Paris"
fqdn = "node01.example.com"

[disk-setup]
filesystem = "zfs"
zfs.raid = "raid10"
disk-list = ["sda", "sdb", "sdc", "sdd"]

Règles de fusion

Couchesla chaîne de groupes d’abord, le document machine en dernier — la machine gagne toujours
Mapsfusionnent récursivement, y compris les tables inline et pointées de TOML
Autres valeursremplacées intégralement par la couche supérieure
Tableauxremplacent, ils ne concatènent pas
Formats texteconcaténés dans l’ordre des couches — voir formats

Pourquoi les tableaux remplacent. Concaténer est le choix intuitif jusqu’au moment où il faut raccourcir une liste. disk-list = ["sda", "sdb"] dans un groupe et ["sda"] dans un document machine n’a qu’un seul sens raisonnable — celle-ci n’a qu’un disque — et la concaténation ne peut pas l’exprimer. La règle vaut dans tous les formats, pour que vous n’ayez jamais à vous rappeler dans lequel vous êtes.

extends

Un groupe peut en étendre un autre, ce qui donne une chaîne — ce que toutes les baies partagent dans un fichier, les différences par baie dans un autre :

# answers/groups/base/proxmox.toml
[global]
mailto   = "ops@example.com"
timezone = "Europe/Paris"
root-ssh-keys = ["ssh-ed25519 AAAA…REPLACE ops@example.com"]
# answers/groups/rack-a/proxmox.toml
extends = "base"
members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"]

[disk-setup]
filesystem = "zfs"

Les couches s’appliquent alors baserack-a → document machine.

extends dans un document machine l’emporte sur l’appartenance. C’est l’échappatoire pour une machine qui a besoin d’un groupe où elle n’est pas listée :

# answers/98-fa-9b-50-d8-99/proxmox.toml
extends = "rack-a"          # même si rack-a ne liste pas cette MAC

[global]
fqdn = "spare01.example.com"

extends se résout à l’intérieur d’un format — superposer un preseed sur une base TOML n’a aucun sens, et la fusion le refuserait de toute façon.

Seul le premier groupe correspondant s’applique

Si deux groupes revendiquent la même machine, un seul s’applique — le plus spécifique, les égalités départagées sur le nom trié. Composez avec extends plutôt que de compter sur plusieurs groupes correspondant à la fois : l’ordre entre eux serait arbitraire, et un ordre arbitraire est la façon dont une machine reçoit discrètement le mauvais schéma de disques.

Quand un groupe est cassé

Les cycles et les parents manquants sont détectés à la lecture du store, signalés une fois dans le log, et le groupe cassé est écarté plutôt qu’appliqué à moitié :

2026-08-24T08:43:36Z - warning: group "rack-a": extends unknown group "base"

Un groupe cassé n’empêche pas les autres baies de s’installer. Une machine qui avait besoin de ce groupe reçoit un 500 bruyant plutôt qu’une réponse à moitié construite — servir une configuration dont la base manque installerait la machine à moitié configurée, et personne ne s’en apercevrait avant qu’elle ne tourne.

rescriptum check signale les mêmes problèmes, ce qui est un meilleur endroit pour l’apprendre que le log à 3 h du matin.

Le groupement est le chemin rapide

Mesuré à 2 000 machines, 3 000 requêtes à 100 en concurrence :

AgencementDébit
2 000 documents machine, aucun groupe12 132 req/s
un groupe de 2 000 membres, aucun document machine13 036 req/s
2 000 documents machine plus un groupe (une fusion par requête)8 816 req/s

Un groupe sans surcharge machine et sans placeholder est rendu une fois, à la lecture du store, puis servi comme une chaîne préparée. Le cas courant en datacenter ne parse rien par requête. Ajouter une surcharge par machine coûte une fusion par requête — ça vaut le coup là où c’est nécessaire, et ça vaut le coup de l’éviter ailleurs.

L’autre moitié du même argument, c’est ce que coûte une lecture. Le store entier est relu au plus une fois par seconde, et avec un répertoire par identité cette lecture ajoute un readdir par machine au fichier qu’elle ouvrait déjà — mesuré à 2 000 machines sur un M1 Pro : 28 ms avant le changement d’agencement, 63 ms après. C’est amorti sur une seconde de requêtes dans les deux cas, et les débits ci-dessus n’ont pas bougé de façon mesurable ; mais un groupe qui dispense d’un répertoire par machine évite ce coût aussi.

Ensuite

  • Templating{{ serial }} supprime la dernière raison d’avoir un répertoire par machine.
  • Validation — une réponse fusionnée est un document que personne n’a écrit ; regardez-le avant qu’une baie ne le fasse.

Templating

Templating

Le groupement supprime la duplication entre machines d’accord. Le templating supprime la dernière raison d’écrire un document par machine : les valeurs qui doivent différer.

# answers/groups/rack-a/proxmox.toml
members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", "…"]

[global]
fqdn = "node-{{ serial }}.example.com"

[network]
filter.ID_NET_NAME_MAC = "*{{ mac }}"

Cinq cents machines, un document. Sans cela, un nom d’hôte par machine signifie un document par machine — et cinq cents documents qui diffèrent d’une ligne chacun.

Les placeholders fonctionnent dans tous les formats : TOML, YAML, JSON, XML, kickstart, preseed.

Dans les formats structurés, la substitution porte sur des valeurs de chaîne parsées — un commentaire n’est donc qu’un commentaire. Dans les formats en lignes (ks, preseed, cfg, ipxe, seed), le document est une chaîne opaque : un placeholder écrit dans un commentaire reste un placeholder et doit quand même se résoudre. Mentionner {{ serial }} dans une ligne # pour l’expliquer au prochain lecteur fera échouer le rendu exactement comme un vrai.

Ce qu’on peut mettre dedans

PlaceholderRempli depuis
{{ mac }}, {{ serial }}, {{ uuid }}, …n’importe quel fait porté par la requête — paramètres de query, et champs d’un corps JSON POSTé par nom de feuille ou chemin complet
{{ dmi.system.serial }}le même corps, par son chemin exact
{{ path }}, {{ file }}, {{ segment }}l’URL sur laquelle la requête est arrivée
{{ group }}le nom du groupe qui s’est appliqué
{{ machine }}l’identifiant du document machine qui a matché

Les espaces à l’intérieur des accolades sont optionnels : {{serial}} et {{ serial }} sont identiques.

machine exige un document machine

{{ machine }} est l’identifiant du document machine qui a matché — il n’est donc disponible que lorsque la machine a un document à elle. Une machine revendiquée par la liste members d’un groupe, sans répertoire à elle, n’a pas de valeur machine et le rendu échoue avec template needs {{ machine }}, but this request carries no "machine".

Dans un groupe, utilisez plutôt un fait de la requête :

[global]
fqdn = "node-{{ mac }}.example.com"      # fonctionne pour chaque membre

{{ machine }} sert à un document machine qui veut se nommer sans répéter sa propre MAC.

Une valeur manquante est une erreur

Un placeholder que la requête ne peut pas remplir est un 500 avec la raison, jamais une chaîne vide :

$ rescriptum render 98:fa:9b:50:d8:10
error: template needs {{ serial }}, but this request carries no "serial"

C’est délibéré. Servir node-.example.com installe une machine avec un nom d’hôte cassé et personne ne le remarque avant plus tard — possiblement bien plus tard, sur une machine déjà en production. Faire échouer l’installation est le résultat le moins coûteux.

Les caractères de contrôle sont refusés pour la même classe de raison : un saut de ligne dans une valeur kickstart injecterait une directive dans le fichier que l’installateur exécute.

$ rescriptum render --query "mac=aa:bb&serial=$(printf 'a\nb')"
error: value for "serial" contains a control character and will not be substituted

La substitution est sûre vis-à-vis de l’échappement

La substitution se fait sur des valeurs parsées, jamais sur le texte brut du document. La valeur est placée dans le modèle de données du document et le sérialiseur du format l’écrit — donc c’est le sérialiseur qui fait l’échappement.

Un numéro de série contenant un guillemet ne peut pas casser le TOML dans lequel il atterrit :

$ rescriptum render --query 'mac=aa:bb&serial=a"b'"'"'c<d>e'
[global]
fqdn = """node-a"b'c<d>e.example.com"""

L’écrivain TOML a choisi de lui-même une chaîne multi-ligne. La même valeur dans un document XML revient échappée en entités, et en JSON, échappée en JSON. Un test fait passer a"b'c<d>e&f dans les quatre formats structurés et reparse la sortie.

C’est pourquoi le templating peut sans risque être alimenté par une requête que contrôle une machine que vous n’avez jamais vue.

check et les faits propres à la requête

rescriptum check rend chaque machine à partir de sa seule identité — il n’a aucune requête sur laquelle s’appuyer, puisqu’il n’y a pas de requête. Un template ayant besoin de serial, qui n’arrive jamais que dans un corps ou une query string, est donc signalé comme un problème :

$ rescriptum check
  FAIL group "rack-a" member "98fa9b50d811": template needs {{ serial }}, but this request carries no "serial"

C’est honnête — check ne peut réellement pas prouver que cette réponse se rend — mais c’est bruyant pour un jeu qui template délibérément sur des faits de requête. Vérifiez ceux-là avec render et des faits représentatifs :

$ rescriptum render --query "mac=98:fa:9b:50:d8:11&serial=7ABC123"

Le coût

Nul, quand vous ne l’utilisez pas. Un groupe dont la chaîne préparée ne contient pas {{ est servi tel quel, sans être parsé par requête — la vérification de présence de placeholders se fait une fois, à la lecture du store. Le templating ne fait basculer un groupe sur le chemin « fusion par requête » que pour les documents qui en contiennent réellement un.

Ensuite

Valider ce qui sera servi

Valider ce qui sera servi

Avant que les réponses ne se composent, un administrateur écrivait un document complet et le validait :

$ proxmox-auto-install-assistant validate-answer answer.toml

Une fois qu’une réponse est assemblée à partir d’une chaîne de groupes, plus un document machine, plus un remplissage de template, le document que reçoit l’installateur est un document que personne n’a jamais vu — et une mauvaise fusion se manifeste par une installation automatisée ratée à 3 h du matin. Deux sous-commandes existent pour combler ce manque, et tout changement de la fusion doit les garder fonctionnelles.

render — ce que cette machine recevrait

$ rescriptum render 98:fa:9b:50:d8:10                              # par identité
$ rescriptum render --query "serial=7ABC123&mac=98:fa:9b:50:d8:10" # par étiquette
$ rescriptum render --query "path=/rhel/ks&serial=7ABC123"         # endpoint compris
$ rescriptum render --body captured-request.json                   # un vrai corps capturé

Il résout exactement comme le serveur — même correspondance, même superposition, même remplissage de template — et affiche le résultat. Le document part sur stdout ; la ligne expliquant comment il a été obtenu part sur stderr :

$ rescriptum render 98:fa:9b:50:d8:10
# format=toml machine=98-fa-9b-50-d8-10 group=rack-a
[global]

donc une redirection ne vous donne que le document :

$ rescriptum render 98:fa:9b:50:d8:10 > /tmp/answer.toml

Ajoutez path=… à --query quand vous voulez vérifier ce qu’un endpoint particulier répondrait — sans cela, la résolution n’est pas contrainte par le format et peut choisir un document que la vraie URL aurait exclu.

Le code de sortie est 0 quand quelque chose s’est résolu, non nul quand rien ne s’appliquait (le serveur aurait renvoyé un 404) ou quand le rendu a échoué.

check — tout rendre, signaler ce qui casse

$ rescriptum check
checking files:examples
  10 group(s), 8 machine document(s)
  group "rhel-compute" selects on serial=7ABC*
    (verify with: rescriptum render --query "...")
  group "ubuntu-web" selects on file=user-data product=PowerEdge R6*
    (verify with: rescriptum render --query "...")
  1 answer(s) validated by their installer's own tool
  note: no schema validator exists for preseed answers
  note: toml answers not schema-checked — proxmox-auto-install-assistant is not on PATH
  ok — everything renders

Well-formed and merging cleanly is not the same as valid for an
installer. Where a validator exists and is installed it was used above;
install proxmox-auto-install-assistant, xmllint or ksvalidator for the rest.

Ce qu’il fait :

  • Signale les problèmes de chargement — un groupe qui en étend un inexistant, un cycle entre groupes, un document qui ne parse pas.
  • Rend chaque document machine, et chaque membre de chaque groupe. C’est ce qui exerce réellement la fusion.
  • Nomme les groupes qui sélectionnent sur un bloc match et dit qu’il n’a pas pu les essayer, plutôt que de laisser croire qu’ils ont été vérifiés — un sélecteur a besoin d’une vraie requête.
  • Signale un groupe sans members ni match comme atteignable seulement via extends, au cas où ce ne serait pas l’intention.
  • Appelle le validateur de l’installateur là où il existe et est dans le PATH, et dit quels formats il n’a pas pu vérifier.

Le code de sortie est 0 quand tout se rend, 1 dès que quelque chose a échoué — il tombe donc directement dans une CI.

Les validateurs qu’il connaît

FormatOutil
tomlproxmox-auto-install-assistant validate-answer
xml, autoyast, unattendxmllint --noout
ksksvalidator
yaml, json, ign, preseed, cfg, ipxeaucun n’existe — rendez et lisez

Un outil manquant est signalé une fois comme note, jamais traité comme un échec. Un vérificateur qui refuse de tourner sans outillage optionnel est un vérificateur que personne ne lance.

check n’est pas lui-même un vérificateur de schéma : il prouve que vos documents sont bien formés et fusionnent proprement. Pour tout ce dont il ne peut pas appeler un validateur, faites-le vous-même :

$ rescriptum render 98:fa:9b:50:d8:10 > /tmp/answer.toml
$ proxmox-auto-install-assistant validate-answer /tmp/answer.toml

Ce que check ne peut pas prouver

check rend chaque machine à partir de sa seule identité. Il n’a pas de requête, donc il ne peut pas fournir de faits qui n’arrivent qu’avec une requête — un serial depuis un corps POSTé, une mac depuis une query string. Un template en ayant besoin est signalé comme problème :

FAIL group "rack-a" member "98fa9b50d811": template needs {{ serial }}, but this request carries no "serial"

C’est exact — check ne peut réellement pas prouver que cette réponse se rend — mais cela signifie qu’un jeu templatisant délibérément sur des faits de requête ne reviendra pas propre. Vérifiez ceux-là avec render --query et des faits représentatifs. Voir templating.

En CI

Si vos réponses vivent dans git, cela mérite un job à part :

# .github/workflows/answers.yml
name: answers
on: [push, pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Get rescriptum
        run: |
          curl -fsSL https://github.com/z29k/rescriptum/releases/latest/download/rescriptum-x86_64-unknown-linux-musl.tar.gz \
            | tar xz --strip-components=1
      - run: RESCRIPTUM_ANSWERS_DIR=answers ./rescriptum check

Ajoutez proxmox-auto-install-assistant au runner et le même job vérifie aussi le schéma du TOML.

Avant de déployer

deploy.sh lance check avant d’expédier quoi que ce soit, et refuse de déployer si les réponses ne reviennent pas propres. Servir un jeu de réponses cassé est pire que ne pas déployer.

L'exploiter

L’exploiter

rescriptum est un processus, configuré entièrement par l’environnement, qui n’écrit rien en dehors du store que vous lui indiquez. Bien l’exploiter consiste surtout à décider ce qu’il a le droit de servir, et à qui.

  • Déploiement — une unité systemd, un conteneur, ou rien du tout.
  • Synology DSM 7 — la cible d’origine : une installation par Package Center qui crée le partage, enregistre le port et démarre au boot.
  • Sécurité — les deux jetons, pourquoi ils se comportent différemment, et ce qu’aucun des deux ne protège.
  • Capturer les requêtes — enregistrer ce que les machines envoient réellement, et le rejouer hors ligne.
  • Le store SQLite — pour un parc administré par outillage plutôt qu’à la main.
  • L’API d’administration — gérer les réponses en HTTP, sur son propre listener, avec une écriture qui ne peut pas casser le parc.
  • Dépannage — la ligne de log est tout le diagnostic disponible.
  • Servir les médias de démarrage — le noyau, l’initrd et l’image de l’installeur, depuis le même serveur.
  • Démarrer une machine par le réseau — TFTP, le chargeur, le menu, et les deux lignes de leur DHCP.

La forme d’un déploiement

Un processuspas d’arbre de supervision, pas de workers à dimensionner, pas de sidecar
Un port par défautplus un second, seulement si vous activez l’API d’administration
Aucune écritureen dehors du répertoire de réponses ou de la base, et aucune du tout à moins d’activer l’API d’administration ou la capture
Aucun étatentre les requêtes. Un redémarrage ne perd rien
Arrêt propresur SIGTERM (ce qu’envoie le planificateur DSM) et Ctrl-C

La configuration se fait par variables d’environnement uniquement. Une valeur numérique nulle ou impossible à parser retombe sur sa valeur par défaut plutôt que de démarrer un serveur qui accepte des connexions sans jamais répondre.

Ce dont il a besoin du réseau

Que l’installateur puisse l’atteindre, et c’est tout. Il n’ouvre aucune connexion sortante, n’a besoin d’aucun DNS, et se moque d’être derrière un NAT.

Le HTTP en clair est le choix normal sur un réseau de provisioning. Si vous avez besoin de TLS — certaines versions d’installateur demandent une empreinte de certificat — terminez-le devant avec nginx ou Caddy et pointez l’ISO dessus. Voir Sécurité.

Déploiement

Déploiement

Le binaire se suffit à lui-même : copiez-le quelque part, donnez-lui un répertoire de réponses, et lancez-le. Tout ce qui suit sert à le faire de façon reproductible.

Pour Synology DSM 7 — qui n’a pas de systemd — voir sa page dédiée.

Un fichier d’environnement

Gardez la configuration dans un fichier lisible par root seul, plutôt que dans une unité ou sur une ligne de commande. Tout ce qui est sur une ligne de commande est visible par tous les utilisateurs de la machine via ps, ce qui compte dès qu’un jeton entre en jeu :

# /etc/rescriptum.env   (chmod 600, appartenant à root)
RESCRIPTUM_ANSWERS_DIR=/srv/answers
RESCRIPTUM_LISTEN_ADDR=0.0.0.0:8000
RESCRIPTUM_TIMEOUT_SECS=10
# RESCRIPTUM_ANSWER_TOKEN=…

Sous systemd, l’EnvironmentFile= ci-dessous le lit et vous n’avez besoin de rien d’autre. Ailleurs — et sur DSM 7, qui n’a pas de systemd — pointez RESCRIPTUM_ENV_FILE sur le même fichier et le binaire le lit lui-même, en refusant de démarrer s’il n’y arrive pas.

Une unité systemd

# /etc/systemd/system/rescriptum.service
[Unit]
Description=rescriptum — per-machine answer files for unattended installs
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/rescriptum
EnvironmentFile=/etc/rescriptum.env
Restart=on-failure
RestartSec=2

# Il doit lire un répertoire et binder un port. Rien d'autre.
DynamicUser=yes
ReadOnlyPaths=/srv/answers
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6
SystemCallFilter=@system-service

[Install]
WantedBy=multi-user.target
$ sudo systemctl enable --now rescriptum
$ curl -s http://localhost:8000/health
OK

Ajustez selon ce que vous activez réellement :

  • Store SQLite — la base doit être accessible en écriture, donc ReadWritePaths=/srv et retirez ReadOnlyPaths.
  • Capture des requêtesReadWritePaths= sur le répertoire de capture.
  • Un port sous 1024 — ajoutez AmbientCapabilities=CAP_NET_BIND_SERVICE.

Les logs partent sur stderr, donc journalctl -u rescriptum -f est la vue en direct.

En conteneur

Il n’y a rien à installer, donc l’image est le binaire :

FROM scratch
COPY rescriptum /rescriptum
ENV RESCRIPTUM_ANSWERS_DIR=/answers RESCRIPTUM_LISTEN_ADDR=0.0.0.0:8000
EXPOSE 8000
ENTRYPOINT ["/rescriptum"]

Utilisez le build de la bonne architecture — les builds musl sont liés statiquement, ce qui est ce qui fait fonctionner FROM scratch. Montez le répertoire de réponses en lecture seule.

Le dimensionner

Les valeurs par défaut sont déjà bonnes aux deux extrémités de la gamme pour laquelle il a été conçu.

RéglageDéfautLe changer quand
RESCRIPTUM_WORKERSnombre de CPUvous partagez une petite machine et voulez plafonner les threads
RESCRIPTUM_MAX_CONNECTIONS2048vous voyez des 503 pendant une rafale — ou voulez délester plus tôt
RESCRIPTUM_TIMEOUT_SECS10les clients sont sur un lien lent, ou vous voulez couper le slowloris plus tôt

MAX_CONNECTIONS n’est pas une limite de débit. Au-delà du plafond, le serveur écrit un 503 immédiat et ferme plutôt que de mettre en file — un client à qui on dit de réessayer s’en sort mieux qu’un client garé dans une file qui ne se videra pas.

Un déploiement de 2 000 machines se termine en moins de deux secondes au débit mesuré, donc le dimensionnement est rarement le problème intéressant. Le dépannage l’est généralement.

Remplacer une instance en cours

$ ./deploy.sh admin@nas
$ ./deploy.sh admin@nas /volume1/netboot        # un autre répertoire distant

Ce qu’il fait, dans l’ordre :

  1. Construit pour la cible (TARGET, par défaut armv7-unknown-linux-gnueabihf).
  2. Vérifie les réponses locales avec rescriptum check et refuse de continuer si quoi que ce soit échoue — expédier un jeu de réponses cassé est pire que ne pas déployer.
  3. Copie le binaire sous un nom temporaire, puis le renomme en place. Remplacer un binaire en cours d’exécution sur place est la façon dont un fichier à moitié copié se fait exécuter.
  4. Arrête l’instance en cours, démarre la nouvelle en détaché, et confirme qu’elle est restée en vie.
  5. Confirme que /health répond par le réseau, pour qu’un problème de pare-feu soit signalé comme tel plutôt que comme un silence mystérieux.
EnvironnementDéfaut
TARGETarmv7-unknown-linux-gnueabihf
ANSWERS<répertoire-distant>/answers
PORT8000

Il remplace ce qui tourne ; il n’installe pas l’autostart. Sur DSM c’est une entrée du planificateur de tâches ; avec systemd c’est systemctl enable.

Mettre à jour

Les réponses sont des données, pas de l’état : rien n’est migré, et un nouveau binaire lit le même répertoire. Remplacez-le et redémarrez.

L’exception est le store SQLite, qui porte une version de schéma. Il n’y en a qu’une pour l’instant, donc rien à migrer ; ce que la version apporte, c’est l’autre sens : un binaire plus ancien refuse d’ouvrir une base écrite par un plus récent plutôt que de la deviner. Voir le store SQLite.

Synology DSM 7

Synology DSM 7

Un Synology DS416j est la raison d’être de ce projet : ARMv7, 512 Mo de RAM, DSM 7, pas de Docker. Un binaire statique sans runtime n’y est pas une préférence esthétique — c’est la seule chose qui rentre.

DSM 7 fait tourner systemd, mais il n’offre aucun endroit supporté pour une unité à vous : les fichiers de /usr/lib/systemd/system appartiennent à Synology, et une mise à jour de DSM est libre de les remplacer. La route supportée vers un service, c’est un paquet — installez-en un et DSM génère pkgctl-rescriptum.service à partir de lui. C’est par là que cette page commence ; la route par le planificateur de tâches fonctionne toujours et reste en bas.

Installer le paquet

Téléchargez le .spk de votre modèle depuis la page des releases :

FichierPour
rescriptum-<version>-armv7.spkDS416j et les autres modèles Marvell armada38x
rescriptum-<version>-x86_64.spktous les modèles Intel

Un doute ? Demandez à la machine :

$ ssh admin@nas synogetkeyvalue /etc.defaults/synoinfo.conf unique
synology_armada38x_ds416j

Puis Package Center → Installation manuelle, choisissez le fichier, et passez l’avertissement disant que le paquet n’est pas vérifié par Synology. Cet avertissement ne vise pas ce paquet en particulier : DSM 7 a supprimé la signature tierce et n’offre plus de réglage de niveau de confiance, donc tout paquet non-Synology l’affiche. Notre vérification à nous, c’est la somme SHA-256 publiée à côté du .spk :

$ shasum -a 256 -c rescriptum-0.2.0-1-armv7.spk.sha256

L’assistant pose deux questions — où vivent les réponses et sur quel port écouter — puis le paquet :

  • crée un dossier partagé rescriptum et s’accorde un accès lecture/écriture dessus (si vous en avez déjà un de ce nom, il est conservé et gagne simplement le droit) ;
  • crée le répertoire answers dedans à chaque démarrage ;
  • enregistre le port auprès du pare-feu DSM, pour que le service soit sélectionnable par son nom ;
  • lie rescriptum-cli dans /usr/local/bin ;
  • démarre au boot, et s’arrête et redémarre depuis Package Center comme n’importe quoi d’autre.

Ce que le paquet ne fait pas

Cinq choses à savoir avant qu’elles ne vous surprennent.

  • Il ne peut pas ouvrir le port 69 tout seul. DSM 7 n’autorise pas un paquet non signé à tourner en root, donc le TFTP vous demande une commande root, une seule fois — voir Le TFTP demande une commande root. Tant qu’elle n’est pas donnée, le serveur avertit, continue de répondre et de servir les images, et seule la livraison du chargeur est coupée.
  • Il n’ouvre pas le pare-feu. Enregistrer le port fait apparaître rescriptum par son nom dans l’éditeur de règles au lieu d’un numéro à taper. Si votre pare-feu est actif avec une règle par défaut qui refuse, il faut toujours créer la règle.
  • Il ne vous annonce pas les mises à jour. Il n’y a pas de source de paquets à interroger — le modèle de distribution est : téléchargez le nouveau .spk depuis la page des releases et installez-le à la main, pour une mise à jour comme pour une première installation. Surveillez les releases.
  • Un chemin de réponses personnalisé, les permissions sont à vous. Le paquet tourne sans privilèges et ne peut pas s’accorder l’accès à un dossier que vous nommez ; si vous pointez hors du partage rescriptum, donnez vous-même l’accès en lecture à l’utilisateur rescriptum.
  • Le droit sur le partage est réappliqué à chaque démarrage. Si vous le restreignez délibérément, vous le retrouverez rétabli au démarrage suivant du paquet.

Où vit quoi

QuoiSurvit à une mise à jourSurvit à la désinstallation
binaire, rescriptum-cli, le fichier d’environnement d’exemple/var/packages/rescriptum/target/non — remplacénon
le fichier d’environnement/var/packages/rescriptum/etc/rescriptum.envouioui — voir ci-dessous
journal, pidfile, captures/var/packages/rescriptum/var/ouioui
les réponses/var/packages/rescriptum/shares/rescriptum/answers/ouioui — toujours
la base SQLite, si vous en utilisez uneà côté des réponses, dans le même partageouioui — toujours

Utilisez le chemin shares/ plutôt que /volume1/… : c’est un lien symbolique maintenu par DSM, donc il continue de marcher sur un NAS dont les données ne sont pas sur le volume 1.

La désinstallation laisse le dossier partagé et tout ce qu’il contient tranquilles. C’est à la fois le comportement de DSM et le nôtre : quand le magasin est SQLite, la base est vos réponses.

Elle laisse aussi votre configuration derrière elle, et ça vaut d’être su. etc/ et var/ sont des liens vers /volume1/@appconf/rescriptum et /volume1/@appdata/rescriptum, que DSM conserve — le fichier d’environnement reste donc sur le volume après la disparition du paquet, avec les jetons qu’il contient. Une réinstallation le reprend, ce qui est généralement ce qu’on veut. Si vous retirez rescriptum pour de bon et qu’il portait un jeton, supprimez /volume1/@appconf/rescriptum vous-même.

L’application de bureau

Le paquet installe une application sur le bureau DSM — l’icône est dans le menu principal, et le bouton Ouvrir de Package Center y mène. C’est une vraie application DSM, bâtie sur le framework d’interface du bureau : elle est dans le thème DSM et dans la langue de DSM. Le français d’un DSM en français est aussi celui de l’application.

Elle a trois onglets :

  • Réglages — chaque variable de configuration, sous forme de formulaire. Chaque champ dit d’où vient sa valeur, et une valeur définie dans l’environnement est affichée mais verrouillée, parce que modifier le fichier n’y changerait rien. Enregistrer écrit le fichier et propose de redémarrer le paquet, le serveur ne lisant sa configuration qu’une fois, au démarrage.
  • État — la version, si le paquet tourne, le dossier des réponses et s’il est vraiment lisible par l’utilisateur du service, et la sortie de check.
  • Journal — les dernières lignes du journal des requêtes et de startup.log.

Trois propriétés valent mieux d’être sues que découvertes :

  • Elle édite le fichier, pas le serveur qui tourne. Elle fonctionne donc encore quand le serveur refuse de démarrer, c’est-à-dire précisément quand un panneau de réglages sert à quelque chose. Une modification qui laisserait le serveur incapable de démarrer est refusée avant toute écriture, avec la raison affichée.
  • Elle ne vous montre jamais un jeton. RESCRIPTUM_ANSWER_TOKEN et RESCRIPTUM_ADMIN_TOKEN apparaissent comme défini ou non défini, et un champ vide veut dire « n’y touche pas », jamais « efface-le ». En saisir un nouveau le remplace.
  • Elle exige un administrateur DSM. Être connecté à DSM ne suffit pas. Voir sécurité pour pourquoi ce contrôle est toute la porte.

Redémarrer maintenant arrête et redémarre le paquet via DSM lui-même, donc DSM ferme la fenêtre pendant ce temps — rouvrez-la pour voir le nouvel état. L’application le dit à côté du bouton plutôt que de vous laisser la surprise.

Elle demande DSM 7.1 ou plus récent (os_min_ver="7.1-42661"). Elle est bâtie sur le framework ExtJS de DSM, présent en 7.1.1 comme en 7.2.2 — les deux mesurés. DSM 7.2 embarque un framework Vue plus récent, et le guide actuel de Synology ne documente que celui-là ; mais le DS416j qui justifie ce projet plafonne en 7.1.1, où Vue n’existe pas. ExtJS couvre donc tous les DSM que ce paquet prend en charge plutôt que les seuls récents. Le 7.0 n’est pas revendiqué : rien n’y a jamais tourné.

Le configurer

L’application ci-dessus est la voie confortable. Tout ce qu’elle fait se fait aussi depuis un shell, et sur une machine où le bureau n’est pas à portée c’est plus rapide :

$ sudo rescriptum-cli config
env file: /var/packages/rescriptum/etc/rescriptum.env

  RESCRIPTUM_STORE            files                             default
  RESCRIPTUM_ANSWERS_DIR      /var/packages/rescriptum/shares/rescriptum/answers   file
  RESCRIPTUM_LISTEN_ADDR      0.0.0.0:8000                      file


$ sudo rescriptum-cli config set RESCRIPTUM_LOG=problems
wrote /var/packages/rescriptum/etc/rescriptum.env

config set conserve les commentaires du fichier, décommente un réglage au lieu de le dupliquer, et refuse une modification qui empêcherait le serveur de démarrer. Son code de sortie dit si la configuration en est une sur laquelle le serveur démarrerait, ce qui le rend utilisable depuis un script.

Dessous, c’est le même fichier, et l’éditer à la main reste parfaitement raisonnable :

$ sudo vi /var/packages/rescriptum/etc/rescriptum.env

postinst l’écrit complet à une installation neuve, avec les variables utilisées décommentées et les autres commentées avec une ligne disant à quoi elles servent. Arrêtez et redémarrez le paquet depuis Package Center pour appliquer un changement — le serveur lit le fichier à chaque démarrage.

Une mise à jour n’y touche jamais. L’exemple complet pour la version que vous avez est dans /var/packages/rescriptum/target/etc/rescriptum.env.example, réécrit à chaque installation et chaque mise à jour : c’est ainsi qu’une nouvelle variable devient visible sans déranger votre fichier vivant. Toutes les variables sont dans la référence de configuration.

Le fichier est en chmod 600 et appartient à l’utilisateur du paquet. C’est là que vivent RESCRIPTUM_ANSWER_TOKEN et RESCRIPTUM_ADMIN_TOKEN et — étant sous etc/ — c’est un passager plausible d’une sauvegarde de configuration DSM. Mieux vaut le savoir que le découvrir.

L’API d’administration est désactivée par défaut et, quand vous l’activez, devrait rester sur la boucle locale et être atteinte par un tunnel SSH ; elle n’est délibérément pas enregistrée auprès du pare-feu. Elle exige aussi RESCRIPTUM_STORE=sqlite et un jeton d’au moins 16 caractères, deux erreurs de démarrage — donc se tromper se manifeste par un paquet qui ne démarre pas, avec la raison dans /var/log/packages/rescriptum.log.

Mettre les réponses en place

Déposez les fichiers dans le répertoire answers du dossier partagé rescriptum, via File Station ou en SSH, exactement comme ailleurs — voir écrire des réponses. Puis validez-les en tant qu’utilisateur du paquet :

$ sudo -u rescriptum rescriptum-cli check

Le sudo -u compte. Lancé en root, il réussit quoi que disent les permissions du dossier partagé, ce qui rend un succès dénué de sens. rescriptum-cli est l’enveloppe fournie par le paquet : elle nomme le fichier d’environnement, pour que check et render regardent les réponses de cette machine plutôt que /srv/answers.

Le pare-feu

Panneau de configuration → Sécurité → Pare-feu — créez une règle autorisant rescriptum depuis votre réseau de provisionnement. Le service apparaît par son nom parce que le paquet a enregistré son port.

Le pare-feu de DSM est la première raison pour laquelle une machine « ne contacte jamais le serveur ».

Si vous changez le port plus tard, modifiez RESCRIPTUM_LISTEN_ADDR dans le fichier d’environnement puis déplacez l’entrée du pare-feu, qui ne suit pas toute seule :

$ sudo /usr/syno/sbin/synopkghelper update rescriptum port-config

Servir les médias d’installation, et le PXE

Le paquet sait aussi servir l’installeur lui-même — noyaux, initrds et images — depuis le NAS qui décide déjà la réponse. C’est éteint jusqu’à ce que vous l’allumiez :

  1. Décommentez RESCRIPTUM_MEDIA_DIR dans le fichier d’environnement et redémarrez le paquet.
  2. Posez une ISO dans le dossier media du partage rescriptum, via File Station ou SMB.
  3. Enregistrez-la, pour qu’elle soit vérifiée et analysée une fois plutôt qu’à chaque requête :
$ rescriptum-cli media add /volume1/rescriptum/media/proxmox-ve_8.4-1.iso \
    --sha256 9f86d081884c7d65…
$ rescriptum-cli media list

Le listener média est sur le port 8001, déjà déclaré au pare-feu à côté du port de réponse — il reste à créer la règle.

Aucune image n’est livrée avec le paquet, et aucune ne le sera jamais : une ISO est l’artefact de quelqu’un d’autre, elle pèse des gigaoctets, et elle évolue à son rythme. Ce dossier est là où vous les gardez, et c’est l’archive — rien ici ne modifie une image après son arrivée. Préparer une image Proxmox produit un fichier compagnon de deux cents octets et une injection appliquée au fil de l’eau, donc les octets sur disque restent exactement ce que Proxmox a publié et leur somme reste vérifiable contre celle de Proxmox. Voir Servir les médias de démarrage.

Le TFTP demande une commande root

C’est rescriptum le serveur TFTP ici, pas DSM. Le port 69 est privilégié et DSM 7 refuse qu’un paquet non signé tourne en root : le paquet ne peut donc pas s’accorder le port lui-même — mais il n’a pas besoin de root pour s’en servir, seulement qu’on l’y autorise une fois :

$ sudo setcap cap_net_bind_service=+ep /volume1/@appstore/rescriptum/bin/rescriptum
$ sudo synopkg restart rescriptum

Après quoi le paquet ouvre udp/69 sous son propre utilisateur non privilégié rescriptum, à côté de 8000 et 8001. Les trois sont enregistrés auprès du pare-feu.

Rendez-la durable, car une mise à jour la perd. Installer une nouvelle version remplace le binaire, et les capacités de fichier appartiennent au fichier — elles partent donc avec l’ancien. Panneau de configuration → Planificateur de tâches → Créer → Tâche déclenchée → Script défini par l’utilisateur, utilisateur root, événement Démarrage, avec la ligne setcap comme script. Relancez-la depuis cette page après chaque mise à jour, ou redémarrez.

Rien d’autre ne casse pendant ce temps. Un port TFTP qu’on ne peut pas ouvrir est le seul écouteur de ce serveur dont l’échec n’est pas fatal, et c’est délibéré : les réponses sont le produit, et une mise à jour ne doit pas couper les installations d’une flotte pour signaler qu’un second port n’a pas pu être ouvert. Ce que vous obtenez à la place, c’est un avertissement dans le journal, une ligne tftp: dans l’onglet État du panneau de réglages, et :

$ rescriptum-cli boot check
  BROKEN nothing answers on 0.0.0.0:69 and it cannot be bound either: Permission denied.
  Port 69 is privileged: run as root and set RESCRIPTUM_USER to drop afterwards, or grant
  the binary cap_net_bind_service with setcap — the server still answers and still serves
  media, but a machine sent here by DHCP asks for a loader and gets nothing

Notez qu’il demande un chargeur au port plutôt que d’essayer de l’ouvrir. Ouvrir le port prouve le contraire de ce qu’on croit : une ouverture qui réussit signifie que personne n’écoute.

Les chargeurs sont dans le paquet. Le dossier boot du partage arrive rempli au premier démarrage, et une mise à jour les rafraîchit — il n’y a pas de second téléchargement. C’est iPXE, en GPLv2, des fichiers séparés servis à côté plutôt que soudés dans quoi que ce soit, et le NOTICE posé avec eux nomme le commit amont exact dont ils sont issus.

$ rescriptum-cli boot check
  ok   0.0.0.0:69 handed over ipxe-undionly.kpxe

Les remplacer est possible, mais pas en modifiant ce dossier : une mise à jour réécrit les noms de fichiers que ce paquet fournit. Pointez plutôt RESCRIPTUM_BOOT_DIR ailleurs, et rien ici n’y écrira jamais.

Puis faites pointer le DHCP vers ce NAS — Panneau de configuration → Serveur DHCP → PXE si le NAS sert le DHCP, ou votre propre serveur avec ce qu’imprime :

$ rescriptum-cli boot dhcp-snippet --format dnsmasq

Si vous préférez éviter setcap

RESCRIPTUM_TFTP_ADDR accepte un port non privilégié, qui ne demande aucune capacité — il faut alors le dire à votre serveur DHCP, puisqu’une ROM PXE a 69 gravé dedans et que seul un premier étage de chaînage peut être redirigé. Ou mettez-le à off et laissez un autre service de ce NAS livrer le chargeur ; DSM a son propre serveur TFTP sous Panneau de configuration → Services de fichiers → Avancé, pointé sur le dossier boot du partage. Ce sont deux contournements pour un déploiement qui les veut, pas ce que le paquet attend.

L’onglet Images

L’application a un quatrième onglet, et c’est là que les images d’installation se gèrent sans toucher à un terminal : ce qui est présent, un catalogue où piocher, et un champ URL pour ce que le catalogue ne propose pas.

Le catalogue n’est pas une liste livrée par ce paquet. Chaque entrée nomme l’index de sommes que l’éditeur publie déjà à côté de ses propres images ; en choisir un lit cet index par le réseau, donc les versions proposées sont celles que l’éditeur a aujourd’hui et l’empreinte vérifiée est la sienne. Cela veut dire aussi que cet onglet a besoin que le NAS atteigne Internet — la seule partie de ce paquet qui en a besoin.

Le téléchargement d’une image de 1,5 Go ne peut pas être tenu par une requête web : l’onglet le lance et le suit. media add écrit dans un fichier .part à côté de sa destination et ne le renomme qu’une fois l’empreinte vérifiée — la taille du fichier partiel est donc l’avancement, et sa disparition est la fin. Fermer la fenêtre n’arrête pas le téléchargement.

Préparer une image Proxmox est aussi un bouton. Toutes les autres familles prennent l’URL de leur réponse sur la ligne de commande du noyau : il n’y a rien à préparer, et l’onglet le dit plutôt que de proposer une étape sans effet.

Un réglage qui mérite d’être rempli

RESCRIPTUM_PUBLIC_HOST=192.168.1.10

Chaque script généré nomme cette adresse. Laissée vide, elle est déduite en interrogeant la table de routage, et le panneau de réglages affiche ce que cela a donné plutôt qu’une case vide — donc sur un NAS à une seule interface, il n’y a rien à remplir ici.

C’est le NAS à deux interfaces qui mérite la lecture. La déduction en retient une, et le journal de démarrage nomme les autres à côté :

warning: RESCRIPTUM_PUBLIC_HOST is not set — derived 192.168.1.10, which is what every
generated URL will name. This host also has 10.0.0.10. If the machines reach it on one of
those instead, set it explicitly.

Se tromper produit une machine qui démarre, enchaîne, et se bloque sur une adresse qui n’existe pas — long à diagnostiquer depuis la machine.

Le journal

RESCRIPTUM_LOG_FILE pointe le serveur vers /var/packages/rescriptum/var/rescriptum.log, et le paquet installe une strophe logrotate pour lui — hebdomadaire, huit conservés, copytruncate (le serveur ouvre son journal une fois et ne le rouvre jamais, donc tout le reste arrêterait silencieusement la journalisation). À côté, var/startup.log contient ce que le serveur dit avant de savoir où vit son journal : une erreur de configuration, un fichier d’environnement mal formé.

Une fois qu’un déploiement devient routinier, RESCRIPTUM_LOG=problems garde les échecs et laisse tomber les réponses réussies, seule chose à fort volume là-dedans.

Quand ça ne démarre pas

Trois endroits disent pourquoi, dans cet ordre :

$ cat /var/log/packages/rescriptum.log        # la sortie des scripts du paquet
$ cat /var/packages/rescriptum/var/startup.log  # ce que le serveur a dit avant d'avoir un journal
$ cat /var/packages/rescriptum/var/rescriptum.log
$ systemctl status pkgctl-rescriptum          # ce qu'a vu le gestionnaire de services

Une configuration refusée — un jeton d’administration de moins de 16 caractères, un magasin impossible à ouvrir — est signalée après que le serveur sait où vit son journal : elle atterrit donc dans rescriptum.log ; un fichier d’environnement mal formé est signalé avant, et atterrit dans startup.log. Le start du paquet affiche la fin des deux quand le serveur sort immédiatement, pour que Package Center vous montre la raison et pas seulement l’échec.

DSM ne relance pas le processus s’il meurt. L’unité qu’il génère est Type=oneshot avec RemainAfterExit=yes et sans Restart= : un serveur qui sort reste arrêté jusqu’à ce que vous le redémarriez depuis Package Center. Ce n’est pas une régression — la route par le planificateur ne le relançait pas non plus — mais mieux vaut le savoir avant de compter dessus.

Un paquet qui s’installe, démarre, puis répond 404 à tout, c’est presque toujours le répertoire des réponses : vérifiez avec sudo -u rescriptum rescriptum-cli check. Sur un NAS avec un dossier partagé chiffré, c’est aussi à cela que ressemble un démarrage avant que le volume soit déverrouillé — déverrouillez-le et redémarrez le paquet.

Vérifier

$ curl http://IP_DU_NAS:8000/health
OK

Sans le paquet

La route manuelle fonctionne toujours, et c’est le choix honnête si vous préférez ne rien installer du tout.

Utilisez le build armv7-unknown-linux-gnueabihf (ou x86_64-unknown-linux-musl, ou aarch64-unknown-linux-musl pour un modèle ARM plus récent) de la page des releases, ou compilez-en un vous-même (voir construire).

$ scp rescriptum admin@nas:/volume1/netboot/rescriptum
$ ssh admin@nas chmod +x /volume1/netboot/rescriptum
$ ssh admin@nas mkdir -p /volume1/netboot/answers

Si ARMv7 se comporte mal, confirmez la vraie architecture avant de supposer :

$ ssh admin@nas uname -m
armv7l

Prenez le build ARMv7 publié, pas un build musl que vous auriez fait vous-même. Le binaire armv7 publié est lié à la glibc 2.17, que DSM possède ; un build musl du même code s’installe, répond à --version, puis meurt dès qu’il veut l’heure. Les noyaux 3.10 de Synology répondent EINVAL aux appels time64 là où musl 1.2 n’attend qu’ENOSYS pour se replier — la page de build porte la mesure. Les builds x86_64 et aarch64 sont en musl statique et ne sont pas concernés.

$ file rescriptum
ELF 32-bit LSB pie executable, ARM, EABI5 version 1 (SYSV), dynamically linked, ...

RESCRIPTUM_ANSWERS_DIR vaut /srv/answers par défaut, qui n’existe pas sur DSM : il faut donc la définir explicitement. Le fichier d’environnement ci-dessous est l’endroit le plus propre pour le faire.

Panneau de configuration → Planificateur de tâches → Créer → Tâche déclenchée → Script défini par l’utilisateur

ChampValeur
ÉvénementDémarrage
Utilisateurroot
Commandevoir ci-dessous

Si vous utilisez un jeton, ne le mettez pas dans cette case. Tout ce qui se trouve dans les arguments d’un processus — et, dans le cas de DSM, dans la définition de la tâche — est lisible par tous les utilisateurs de la machine via ps. Mettez la configuration dans un fichier réservé à root et nommez-le :

# /volume1/netboot/rescriptum.env   (chmod 600, appartenant à root)
RESCRIPTUM_ANSWERS_DIR=/volume1/netboot/answers
RESCRIPTUM_LOG_FILE=/volume1/netboot/rescriptum.log
RESCRIPTUM_STORE=sqlite
RESCRIPTUM_DB_PATH=/volume1/netboot/answers.db
RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001
RESCRIPTUM_ADMIN_TOKEN=
RESCRIPTUM_ANSWER_TOKEN=
# l'entrée du planificateur de tâches exécute ceci
RESCRIPTUM_ENV_FILE=/volume1/netboot/rescriptum.env exec /volume1/netboot/rescriptum

Préférez ceci au sourcing. La forme plus ancienne — . /volume1/netboot/rescriptum.env && exec … — fonctionne, et fonctionne toujours, mais elle échoue silencieusement : oubliez le . du début, tapez une ligne de travers, ou ratez les permissions, et le shell ne source rien pendant que le serveur démarre sur ses valeurs par défaut — le répertoire de réponses par défaut, aucun jeton d’administration, et pas un mot dans le journal. Avec RESCRIPTUM_ENV_FILE, le binaire lit le fichier lui-même et refuse de démarrer s’il ne peut pas. Il prévient aussi si le fichier est lisible par quelqu’un d’autre que root, et nomme toute clé qu’il ne reconnaît pas, de sorte qu’un RESCRIPTUM_ADMIN_TOKENN est attrapé plutôt qu’ignoré en silence.

Les détails du format sont dans la référence de configuration.

Lancez la tâche une fois à la main depuis le planificateur plutôt que d’attendre un reboot pour découvrir qu’elle ne marche pas. Puis ouvrez le port dans le pare-feu par son numéro, et faites tourner le journal vous-même — le serveur ne le fait pas, et rien d’autre non plus.

Remplacer une instance en cours

$ ./deploy.sh admin@nas

Il compile pour ARMv7, vérifie les réponses d’abord, copie le binaire sous un nom temporaire pour qu’un fichier à moitié copié ne soit jamais exécuté, le redémarre, et confirme que /health répond. Détails dans déploiement.

L’entrée du planificateur reste ce qui le démarre après un reboot — deploy.sh ne remplace que ce qui tourne maintenant. Sur une installation par paquet, passez par Package Center.

Arrêt

Les deux routes envoient SIGTERM, que le serveur gère : il arrête d’accepter et sort. Il n’y a rien à perdre dans un cas comme dans l’autre.

Ce qu’on peut attendre d’un DS416j

512 Mo et un cœur ARMv7, ce n’est pas grand-chose, et il n’y a pas besoin que ça le soit. Mesuré sur un DS416j faisant tourner le paquet, à travers le réseau local : 3 à 4 ms pour composer et servir une réponse, aller-retour réseau compris, pour une machine revendiquée par un groupe et fusionnée avec son propre fichier. Une connexion coûte des kilooctets plutôt qu’un thread, le listing du répertoire est mis en cache et invalidé par la mtime plutôt que parcouru à chaque requête, et un groupe sans surcharge par machine est rendu une fois au chargement puis servi comme une chaîne préparée.

La chose qui vaut d’être sue : le travail sur le système de fichiers se fait sur un pool de threads bloquants, parce que read_dir sur un NAS dont le disque dort n’est pas un appel rapide, et bloquer un worker asynchrone bloquerait toutes les autres connexions qu’il pilote.

Sécurité

Sécurité

Les documents de réponse portent root-password-hashed et root-ssh-keys. Qui peut les lire peut se connecter à chaque machine que vous installez ; qui peut les écrire décide de ces identifiants. C’est tout le modèle de menace, et il mérite d’être dit franchement.

L’endpoint de réponse est ouvert par défaut

Par défaut, quiconque atteint le port peut récupérer une réponse. Ce n’est pas un oubli : la plupart des installateurs n’ont aucun identifiant à présenter. Un client kickstart qui va chercher inst.ks=http://… n’a rien à offrir, et le refuser reviendrait à refuser l’installation.

Le bon contrôle principal, c’est le réseau. Un VLAN de provisioning où ne se trouvent que des machines en cours de démarrage PXE vaut plus que n’importe quel jeton.

RESCRIPTUM_ANSWER_TOKEN

Proxmox peut présenter un identifiant quand son ISO a été préparée pour :

$ proxmox-auto-install-assistant prepare-iso … --answer-auth-token 'une-longue-chaine-aleatoire'
$ export RESCRIPTUM_ANSWER_TOKEN='une-longue-chaine-aleatoire'

L’installateur envoie alors Authorization: Bearer …, et le serveur refuse tout ce qui ne l’a pas, en comparant en temps constant.

Les échecs ici sont journalisés mais jamais limités en débit. Une baie entière peut se trouver derrière une seule adresse, et l’exclure transformerait un mauvais jeton en déploiement raté. L’API d’administration, à laquelle aucun installateur ne parle, verrouille bel et bien.

Un jeton de moins de 16 caractères est un avertissement au démarrage, pas une erreur — refuser de démarrer laisserait un parc incapable de s’installer.

GET /health reste ouvert dans tous les cas, pour que la supervision ne s’éteigne pas.

Le jeton de l’API d’administration

RESCRIPTUM_ADMIN_TOKEN est une chose différente protégeant une surface différente, et il est traité en conséquence. L’API d’administration décide du mot de passe root et des clés SSH de chaque machine installée ensuite, donc :

  • elle ne partage jamais le listener de l’endpoint de réponse — elle a son propre RESCRIPTUM_ADMIN_ADDR ;
  • le serveur refuse de démarrer sans jeton, avec un jeton de moins de 16 caractères, ou au-dessus du store fichiers — des erreurs, pas des avertissements ;
  • une adresse qui insiste est exclue : cinq échecs en une minute valent un blocage, doublant à chaque récidive jusqu’à quinze minutes, et le blocage s’applique aussi à un jeton correct venant de cette adresse — sinon deviner jusqu’à tomber juste ne coûterait rien.

Générez-en un vrai. Pas un mot auquel vous avez pensé :

$ openssl rand -hex 24        # ou : head -c 24 /dev/urandom | base64

Détails complets sur la page de l’API d’administration.

Pourquoi une comparaison en temps constant

Un == ordinaire s’arrête dès que deux octets diffèrent, donc un mauvais jeton partageant un préfixe plus long met mesurablement plus de temps à être rejeté. Cette différence suffit à récupérer un jeton octet par octet — quelques milliers de requêtes plutôt qu’un nombre impossible. Comparer tous les octets quoi qu’il arrive supprime le signal.

Sur un réseau, le timing se perd généralement dans la gigue, donc c’est une précaution. Ça coûte cinq lignes.

Ne mettez pas un jeton sur une ligne de commande

Tout ce qui se trouve dans les arguments d’un processus est visible par tous les autres utilisateurs de la machine via ps. Cela inclut le mettre directement dans une tâche planifiée DSM. Gardez-le dans un fichier réservé à root :

# /etc/rescriptum.env   (chmod 600, appartenant à root)
RESCRIPTUM_STORE=sqlite
RESCRIPTUM_DB_PATH=/srv/answers.db
RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001
RESCRIPTUM_ADMIN_TOKEN=

Puis donnez-le au serveur — EnvironmentFile=/etc/rescriptum.env sous systemd, ou RESCRIPTUM_ENV_FILE=/etc/rescriptum.env partout ailleurs. La seconde forme fait lire le fichier par le binaire : un fichier illisible devient donc une erreur de démarrage plutôt qu’un serveur tournant discrètement sans le jeton que vous croyiez avoir posé. Le fichier n’est jamais découvert tout seul — il n’y a pas de ./.env, délibérément : ce processus tourne en root, et un fichier ramassé dans le répertoire courant serait un moyen de donner le jeton admin à quelqu’un.

Ce que le serveur refuse de lui-même

Traversée de cheminun chemin de système de fichiers n’est jamais construit à partir de données de requête. Seules les entrées directes du répertoire de réponses sont lues. Les identifiants arrivant à l’API d’administration n’acceptent que lettres, chiffres et - _ . :, parce qu’export les retransforme en noms de fichiers
Corps surdimensionnésun Content-Length invraisemblable est refusé depuis l’en-tête, avant toute lecture ; le corps est plafonné à 1 Mo quoi qu’il arrive
Clients lentsun délai de lecture des en-têtes et une échéance sur la connexion entière, pour qu’un client qui promet un corps sans l’envoyer ne puisse pas garer une connexion
Rafalesau-delà de RESCRIPTUM_MAX_CONNECTIONS en vol, un 503 immédiat et fermeture, plutôt qu’une mise en file jusqu’à l’épuisement mémoire
Entrées malforméesun échec de parsing est une réponse d’erreur et une ligne de log, jamais un panic emportant une connexion — ou un serveur — en pleine installation

TLS

Le serveur parle HTTP en clair. Sur un réseau de provisioning de confiance c’est normalement très bien, et c’est ce qui garde le binaire petit et sans dépendance.

Si vous avez besoin de TLS — certaines versions d’installateur veulent une empreinte de certificat lors d’une récupération en HTTPS — terminez-le devant avec nginx ou Caddy et pointez l’ISO dessus. L’endpoint de réponse se moque de ce qu’il y a en amont.

L’API d’administration est le seul endroit où cela compte par défaut : elle parle aussi HTTP en clair, donc le jeton traverse le réseau en clair. Sur la boucle locale c’est sans objet. Ailleurs, mettez un proxy terminant TLS devant.

L’application de bureau

Sur Synology uniquement, et uniquement là — l’application DSM fait partie du paquet, pas du serveur. Son backend est un CGI que DSM sert depuis /webman/3rdparty/rescriptum/, et deux choses concernant ce chemin décident de tout son modèle de sécurité. Les deux ont été mesurées sur une machine DSM 7.2.2 plutôt que lues dans un guide, qui n’en mentionne aucune :

  1. Un CGI y tourne sous le propriétaire du script. DSM attribue l’arborescence d’un paquet à l’utilisateur du paquet : le backend tourne donc en tant que rescriptum, la même identité qui possède le fichier d’environnement en 0600 et le journal. C’est ce qui lui permet d’éditer la configuration et de lire le journal pendant que le serveur est arrêté, c’est-à-dire précisément quand un panneau de réglages sert à quelque chose. Ce n’est pas root, et il ne peut devenir personne : il n’a aucun droit de démarrer ou d’arrêter le paquet, et c’est pourquoi le redémarrage passe par l’API de DSM avec la session de l’administrateur. (Un script resté possédé par root, lui, tourne bien en root là-bas. Bon à savoir, et à ne jamais faire.)
  2. DSM n’authentifie pas ce chemin. Une requête non authentifiée atteint le script et reçoit une réponse. DSM protège ses propres pages ; celles d’un paquet regardent le paquet.

Mis ensemble : les contrôles à l’intérieur du script sont la seule chose devant lui. Il en fait donc trois, dans cet ordre, avant de toucher à quoi que ce soit.

  • Une session DSM. Il exécute l’authenticate.cgi de DSM, qui affiche le nom de l’utilisateur connecté et n’affiche rien du tout s’il n’y a pas de session.
  • Un administrateur. Être connecté ne suffit pas ; l’utilisateur doit appartenir à administrators. Moins que cela laisserait n’importe quel compte du NAS fixer le mot de passe root de chaque machine qu’il installe.
  • L’intention, pour une écriture. Une écriture doit porter un en-tête que l’application envoie et qu’un formulaire d’un autre site ne peut pas : un navigateur n’envoie pas un en-tête inventé en cross-origin sans un préalable (preflight), et ce script n’y répond pas. Le SynoToken de DSM est envoyé en plus, ce qui garde l’application fonctionnelle avec la protection contre la falsification de requête inter-sites activée.

check-spk.sh vérifie que les deux premiers sont toujours dans le script, et lifecycle-test.sh le pilote avec un authentificateur bouchonné pour prouver que les trois refusent réellement. Ils ont été vus échouer : retirer le contrôle de session fait passer quatre verts au rouge.

L’application ne reçoit jamais de jeton. RESCRIPTUM_ANSWER_TOKEN et RESCRIPTUM_ADMIN_TOKEN lui parviennent comme défini ou non défini, et rien de plus — la commande qu’elle appelle refuse d’afficher un identifiant, quoi qu’on lui demande.

Connu et accepté

  • La limitation par adresse n’arrête pas un attaquant disposant de nombreuses adresses. C’est la longueur du jeton d’administration qui rend la devinette sans espoir — d’où le plancher de 16 caractères.
  • L’endpoint de réponse n’est pas limité en débit du tout, délibérément, pour la raison ci-dessus.
  • Binder l’API d’administration au-delà de la boucle locale est votre choix, et le serveur le dit dans le log quand vous le faites. 127.0.0.1 plus un tunnel SSH est le défaut sûr.

Capturer les requêtes

Capturer les requêtes

L’essentiel de ce que rescriptum sait des installateurs vient de leur documentation. Tant qu’un vrai installateur ne lui a pas parlé, c’est une affirmation plutôt qu’un fait — et quand un déploiement dérape, « qu’est-ce que node07 a réellement envoyé ? » est généralement la seule question qui vaille.

$ export RESCRIPTUM_CAPTURE_DIR=/var/log/rescriptum-captures

Désactivé sauf si défini.

Ce qu’il écrit

Deux fichiers par requête :

20260824T084337Z-10.0.0.42-0000.body     le corps, verbatim
20260824T084337Z-10.0.0.42-0000.meta     qui a demandé, et ce qu'il a reçu
time: 2026-08-24T08:43:37Z
peer: 10.0.0.42:51234
request: POST /proxmox/answer
body-bytes: 1876
outcome: 200 format=toml machine=98fa9b50d810 group=rack-a

Le .body est octet pour octet ce qui est arrivé, donc il se rejoue sans modification. Le nom de fichier porte l’horodatage, l’adresse du pair (assainie — les deux-points d’un pair IPv6 n’ont rien à faire dans un nom de fichier) et un numéro de séquence, pour que deux requêtes dans la même seconde n’entrent pas en collision.

En rejouer une

$ rescriptum render --body /var/log/rescriptum-captures/20260824T084337Z-10.0.0.42-0000.body

Cela résout exactement comme le serveur l’a fait, hors ligne, sans aucune machine — ce qui rend une mauvaise réponse débogable à votre bureau plutôt que devant une baie.

C’est aussi la meilleure façon de construire des sélecteurs contre un format de corps que vous n’avez jamais vu : capturez une vraie requête, puis itérez avec render --body jusqu’à ce qu’elle se résolve comme vous le vouliez.

Les limites, et pourquoi

  • Plafonné à 1000 captures. Un serveur de provisioning qui remplit son propre disque est pire qu’un qui ne capture rien. En atteignant le plafond, il le signale une fois et arrête d’écrire. Le compte porte sur les captures, pas sur les fichiers, et il survit à un redémarrage : le serveur compte ce qui se trouve déjà dans le répertoire avant d’écrire.
  • Rien n’est jamais supprimé. Faire tourner ou vider le répertoire vous incombe ; le serveur compte ce qui s’y trouve déjà au démarrage pour qu’un redémarrage ne dépasse pas le plafond.
  • Un échec de capture ne fait jamais échouer une requête. Il est journalisé, et l’installation continue. Perdre un diagnostic ne vaut pas de perdre une installation.

Avant d’en joindre une à un rapport de bug

Un corps capturé est un inventaire matériel : adresses MAC, numéros de série de disques, DMI. Le .meta dit quelle réponse a été reçue. Ni l’un ni l’autre ne contient vos hachages de mot de passe — mais la réponse, si. Nettoyez donc tout ce que vous collez à côté.

Voir aussi

  • Dépannage — lire le log, et les causes habituelles.
  • Validationrender sous ses autres formes.

Le store SQLite

Le store SQLite

Un répertoire de fichiers est le défaut, et c’est la bonne réponse pour une poignée de machines : greppable, diffable, dans git si vous voulez, sans base à faire tourner, sauvegarder ou migrer.

Pour un parc administré par outillage plutôt qu’à la main, les mêmes réponses peuvent vivre dans une base SQLite. Elle est compilée dans le binaire, donc il n’y a toujours rien à installer.

$ export RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db
$ rescriptum import /srv/answers      # faire passer les fichiers
$ rescriptum check                    # les mêmes vérifications, sur la base
$ rescriptum                          # servir depuis elle

Pourquoi vous le feriez

  • L’API d’administration en a besoin. Gérer les réponses en HTTP exige la base ; au-dessus de fichiers il y aurait deux façons de changer la même configuration — à la main et par le réseau — en concurrence l’une avec l’autre.
  • Les écritures concurrentes sont sûres. Mode WAL, donc une écriture administrative ne bloque jamais une installation en cours.
  • Un seul fichier à sauvegarder, et il se déplace atomiquement.

Pourquoi peut-être pas

  • Un répertoire est lisible. git log answers/ répond à « qui a changé cette baie et pourquoi » ; une base non, à moins que votre outillage ne l’enregistre.
  • Un fichier s’édite avec n’importe quoi. vi, scp, un Makefile.
  • C’est 1,2 Mo de binaire — 2,4 Mo avec SQLite contre 1,3 Mo sans, sur ARMv7. Construisez avec cargo build --no-default-features si cela compte et que vous n’en avez pas besoin.

Le comportement est identique

Correspondance, groupes, extends, fusion, templating, render, check — tout cela vit au-dessus du store, qui est délibérément mince : il rend le texte brut des documents et un jeton de version bon marché, et ne décide de rien.

Ce n’est pas affirmé mais imposé. tests/stores.rs fait tourner chaque cas de comportement deux fois, une fois par store, et exige le résultat identique. Un nouveau comportement a sa place dans cette suite, pas dans un test propre à un store.

Passer de l’un à l’autre

$ rescriptum import /srv/answers      # répertoire → store configuré
$ rescriptum export /tmp/backup       # store configuré → répertoire
$ RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db rescriptum import examples
copying files:examples -> sqlite:/srv/answers.db
  10 group(s), 8 machine(s)
  ok — now run `check` against the target

L’aller-retour est identique octet pour octet. Importez un répertoire, réexportez-le, et diff -r ne signale rien — commentaires, mise en forme et tout le reste. C’est ce qui rend la base sûre à adopter et sûre à quitter, et cela vaut la peine de rester vrai.

Les deux sens lancent check sur votre initiative plutôt qu’automatiquement ; la sortie ci-dessus vous le dit.

Versions de schéma

La base porte une version de schéma (user_version). Il n’y en a qu’une pour l’instant, et rien n’a été publié sous une plus ancienne : il n’y a donc rien à migrer.

Ce à quoi sert la version, c’est l’autre sens : un binaire plus ancien refuse d’ouvrir une base écrite par un plus récent plutôt que de deviner ce qui a changé.

database schema is version 2, this binary understands 1

Un retour arrière au-delà d’un futur changement de schéma a donc besoin de l’export d’avant la mise à jour, ou d’un binaire assez récent pour lire la base. Gardez un export sous la main quand vous en franchissez un.

Notes d’exploitation

  • version() est un atomique en mémoire, pas une requête, parce qu’il est appelé à chaque requête HTTP. Un changement fait par un autre processus est rattrapé par le filet de rechargement d’une seconde.
  • Le fichier de base et ses compagnons -wal/-shm doivent tous être accessibles en écriture, et appartiennent tous à la même sauvegarde.
  • RESCRIPTUM_DB_PATH vaut par défaut /srv/answers.db, voisin du répertoire de réponses par défaut. La base contient le même contenu curé, pas de l’état d’exécution : elle a sa place dans le même arbre.
  • Le répertoire parent est créé s’il n’existe pas.

Voir aussi

L'API d'administration

L’API d’administration

Avec RESCRIPTUM_STORE=sqlite, les réponses peuvent être gérées en HTTP plutôt qu’en éditant des fichiers. Elle est désactivée sauf si vous la configurez, et elle tourne sur son propre listener.

$ export RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db
$ export RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001
$ export RESCRIPTUM_ADMIN_TOKEN=$(openssl rand -hex 24)
$ rescriptum
2026-08-24T08:52:30Z - admin API listening on 127.0.0.1:8001
2026-08-24T08:52:30Z - rescriptum 0.1.0 listening on 0.0.0.0:8000 — store=sqlite:/srv/answers.db …

Trois propriétés porteuses

1. Son propre listener. L’endpoint de réponse n’est pas authentifié par nécessité — l’installateur n’a aucun identifiant à offrir. Cette API décide du mot de passe root et des clés SSH de chaque machine installée ensuite. Elle ne partage jamais ce port.

2. SQLite uniquement. Au-dessus d’un répertoire de fichiers il y aurait deux façons de changer la même configuration, à la main et par le réseau, en concurrence.

3. Une écriture ne peut jamais laisser le jeu de réponses cassé. Chaque écriture prend un instantané des problèmes courants, s’applique, puis compare. Tout ce qui est nouvellement cassé est annulé et répondu 409.

Le serveur refuse de démarrer — en erreur, pas en avertissement — si vous pointez l’API d’administration sur le store fichiers, omettez le jeton, ou définissez un jeton de moins de 16 caractères.

Endpoints

RequêteRôle
GET /machines, GET /groupslister les identifiants
GET /machines/{id}, GET /groups/{name}, GET /defaultle document stocké, tel qu’écrit — commentaires et mise en forme intacts
PUT /machines/{id}, PUT /groups/{name}, PUT /defaultstocker un document (le corps est le document)
DELETE /machines/{id}, DELETE /groups/{name}, DELETE /defaulten supprimer un
GET /resolve/{id}la réponse fusionnée que cette machine recevrait
GET /checkles problèmes actuels, le même jeu que la sous-commande check
GET /healthvivacité — le seul endpoint sans jeton, et jamais bloqué

Chaque endpoint nommant un document prend ?format= — l’extension du document. Il vaut toml par défaut, ce que ce serveur servait à ses débuts :

$ curl -H "$AUTH" -X PUT --data-binary @base.preseed \
    'http://127.0.0.1:8001/groups/base?format=preseed'

Comme la clé d’un document est (identifiant, format), un identifiant apparaît dans GET /machines une fois par format dans lequel il existe — une machine qui est à la fois un nœud Proxmox et un nœud Debian est listée deux fois.

Exemples

$ AUTH="Authorization: Bearer $RESCRIPTUM_ADMIN_TOKEN"

$ curl -s -H "$AUTH" http://127.0.0.1:8001/groups
{"group":["base","example-rack","rhel-compute","ubuntu-web"]}

$ curl -s -H "$AUTH" -X PUT --data-binary @rack-a.toml \
    http://127.0.0.1:8001/groups/rack-a
{"status":"stored","problems":[]}

$ curl -s -H "$AUTH" http://127.0.0.1:8001/resolve/98:fa:9b:50:d8:10
[global]
country = "fr"
keyboard = "fr"

GET /resolve renvoie aussi l’en-tête de réponse X-Answer-Source, portant la même description que la ligne de log :

x-answer-source: format=toml machine=98fa9b50d810 group=example-rack

Répéter une vraie requête

GET /resolve accepte les mêmes étiquettes que porterait une vraie requête, ce qui permet de répéter une URL particulière — la différence entre /user-data et /meta-data, par exemple :

$ curl -s -H "$AUTH" 'http://127.0.0.1:8001/resolve?path=/rhel/ks&serial=7ABC123'

Quand une query string est présente, l’identifiant du chemin est ignoré — les faits viennent de la query seule. Donc GET /resolve/98:fa:9b:50:d8:10?format=toml ne résout rien, parce que format=toml n’est pas une identité. Utilisez la forme sans query, ou mettez l’identité dans la query : ?mac=98:fa:9b:50:d8:10.

Elle ne vous laissera pas casser le parc

Chaque écriture est vérifiée après application. Si elle a introduit un problème — un cycle entre groupes, un document référençant un groupe qui n’existe plus — l’écriture est annulée et vous recevez un 409 disant ce que vous avez cassé :

$ curl -s -H "$AUTH" -X DELETE 'http://127.0.0.1:8001/groups/base?format=preseed'
{"error":"refused: this would break the answer set (rolled back)",
 "problems":["machine \"98fa9b50d810\": extends unknown group \"base\""]}

Deux choses découlent de ce fonctionnement :

  • Une écriture réussie signale quand même les problèmes préexistants, dans le tableau problems. Une réponse propre n’implique jamais que tout le jeu est sain — seulement que vous ne l’avez pas aggravé.
  • C’est pourquoi un extends de machine pointant sur un groupe manquant est détecté au chargement plutôt qu’au moment où cette machine demande. Le garde-fou ne peut attraper que ce que le rapport de problèmes connaît.

Les documents malformés sont également refusés à l’écriture, plutôt que de devenir un 500 la prochaine fois qu’une machine les demande :

$ curl -s -H "$AUTH" -X PUT --data-binary 'x = = 1' http://127.0.0.1:8001/machines/aa-bb-cc-dd-ee-01
{"error":"document: invalid TOML: TOML parse error at line 1, column 5 …"}

Identifiants

Lettres, chiffres et - _ . : uniquement. Ils deviennent des noms de répertoires sous export et dans le store fichiers, donc tout ce qui pourrait traverser un répertoire est rejeté — à la frontière de l’API et dans les deux stores. groups et default sont réservés comme identifiants de machine pour la même raison : ce sont les répertoires que l’agencement garde pour lui, et une base qui en accepterait un exporterait vers un répertoire incapable de le contenir.

Codes de statut

CodeSignifie
200fait
400document malformé, identifiant invalide, ou corps non-UTF-8
401jeton manquant ou faux
404document ou endpoint inexistant ; rien ne se résout pour cet identifiant
409l’écriture aurait cassé le jeu de réponses (annulée), ou un resolve qui n’a pas pu rendre
413document de plus de 256 Ko
429cette adresse est bloquée après des échecs d’authentification répétés
500le store n’a pas pu être lu ou écrit

Veiller sur le jeton

Le jeton constitue toute l’authentification, et ce qu’il protège mérite d’être dit franchement : les documents de réponse portent root-password-hashed et root-ssh-keys, donc quiconque peut écrire sur cette API décide des identifiants root de chaque machine que vous installerez ensuite.

Générez-en un vrai — pas un mot auquel vous avez pensé :

$ openssl rand -hex 24        # ou : head -c 24 /dev/urandom | base64

Ne le mettez pas sur une ligne de commande. Tout ce qui se trouve dans les arguments d’un processus est visible par tous les autres utilisateurs via ps, ce qui inclut le mettre directement dans une tâche planifiée DSM. Gardez-le dans un fichier réservé à root et sourcez-le — voir Sécurité.

Ce que le serveur fait de son côté :

  • Compare le jeton en temps constant, pour qu’il ne puisse pas être récupéré octet par octet par qui chronomètre les réponses.
  • Exclut une adresse qui insiste. Cinq échecs en une minute valent un blocage, doublant à chaque récidive jusqu’à un maximum de quinze minutes, et chaque tentative est journalisée. Le blocage s’applique aussi à un jeton correct venant de cette adresse — sinon deviner jusqu’à tomber juste ne coûterait rien.
  • Borne sa propre comptabilité à 4096 adresses suivies, pour que le garde-fou ne puisse pas lui-même être transformé en fuite de mémoire.
  • Laisse GET /health non authentifié et non bloqué, pour que la supervision ne s’éteigne pas pendant une attaque.
2026-08-24T08:52:32Z - admin: 10.0.0.9 failed authentication 5 times — blocked for 60s

Deux limites à prévoir

  • Elle parle HTTP en clair, donc le jeton traverse le réseau en clair. Sur la boucle locale c’est sans objet. Ailleurs, mettez un reverse proxy terminant TLS devant.
  • Le blocage par adresse n’arrête pas un attaquant disposant de nombreuses adresses. C’est la longueur du jeton qui rend la devinette sans espoir — d’où le plancher de 16 caractères au démarrage.

Binder au-delà de la boucle locale est votre choix, et le serveur le dit dans le log quand vous le faites :

2026-08-24T08:52:30Z - warning: the admin API is not bound to loopback — it rewrites what gets installed on every machine, so restrict it to a management network

127.0.0.1 plus un tunnel SSH est le défaut sûr.

Voir aussi

Dépannage

Dépannage

Quand une installation PXE ne démarre pas, le log est le seul diagnostic dont dispose qui que ce soit — il est donc délibérément ennuyeux et greppable : une ligne par requête, sur stderr. Les deux moitiés se règlent : RESCRIPTUM_LOG jette les requêtes qui ont abouti, et RESCRIPTUM_LOG_FILE envoie les lignes dans un fichier.

2026-08-24T08:43:36Z - rescriptum 0.1.0 listening on 127.0.0.1:8999 — store=files:answers workers=10 max_conn=2048 timeout=10s
2026-08-24T08:43:37Z 127.0.0.1:61720 GET /health 200
2026-08-24T08:43:37Z 127.0.0.1:61721 POST /answer body=102 200 format=toml machine=98fa9b50d810 group=example-rack bytes=431
2026-08-24T08:43:37Z 127.0.0.1:61722 GET /rhel/ks?serial=7ABC123 body=0 200 format=text group=rhel-compute bytes=747
2026-08-24T08:43:37Z 127.0.0.1:61723 POST /answer body=27 404 no answer file applies

Lire une ligne

2026-08-24T08:43:37Z 127.0.0.1:61721 POST /answer body=102 200 format=toml machine=98fa9b50d810 group=example-rack bytes=431
└─ horodatage UTC    └─ pair         └─ requête      └─ corps  └─ statut
                                                                  └─ comment la réponse a été composée  └─ octets envoyés

Les lignes portant - à la place d’une adresse de pair sont au niveau serveur : démarrage, échecs d’accept, délestage, problèmes de chargement du jeu de réponses.

format=… nomme la famille (toml, yaml, json, xml, text) plutôt que l’extension — ks et preseed remontent tous deux comme text.

Échecs courants

SymptômeCause probable
404 no answer file appliesRien n’a revendiqué la requête et il n’y a pas de default pour le format demandé. Capturez le corps et vérifiez que la MAC y est vraiment
404 sur une URL qui marchaitL’URL nomme maintenant un alias de format qui exclut votre document — /ubuntu/answer ne servira pas un .toml
500 … extends unknown groupUn document référence un groupe inexistant. Délibéré : servir une configuration dont la base manque installerait la machine à moitié configurée
500 sur une seule machineLe document de cette machine, ou son groupe, ne parse pas. La raison est sur la même ligne de log. rescriptum check le trouve sans attendre que la machine demande
500 template needs {{ … }}Un placeholder que la requête n’a pas pu remplir. Jamais servi comme chaîne vide, volontairement
401 bad or missing tokenRESCRIPTUM_ANSWER_TOKEN est défini mais l’ISO n’a pas été préparée avec le même --answer-auth-token
413Un corps de plus de 1 Mo, ou un Content-Length qui en annonce un. Refusé depuis l’en-tête, avant toute lecture
503Plus de connexions simultanées que RESCRIPTUM_MAX_CONNECTIONS. Augmentez-le, ou trouvez qui se connecte
Réponse servie, installation quand même ratéeLe document est du TOML valide mais pas du Proxmox valide. Passez render dans validate-answer
L’installateur ne contacte jamais le serveurL’URL de l’ISO ou un pare-feu, pas ce serveur. curl http://SERVER:8000/health depuis le même réseau

Le serveur démarre mais tout part en 404

Regardez la ligne de démarrage. Les deux causes habituelles s’annoncent :

warning: /srv/answers does not exist yet — every request will 404 until it does
warning: /srv/answers cannot be read: Permission denied (os error 13) — every request
will 404 until that is fixed; check the directory's owner against the user this server
runs as

Le second est ce que vous obtenez quand le répertoire existe mais que le processus ne peut pas le lister — la cause habituelle est un répertoire créé en root et un serveur tournant sous quelqu’un d’autre. La question est posée au système de fichiers plutôt que déduite des bits de permission, donc elle tient compte du propriétaire, du groupe, des ACL et du montage.

… store=files:/srv/answers …

— cette seconde est le répertoire de réponses par défaut. Si vous en vouliez un autre, RESCRIPTUM_ANSWERS_DIR n’a pas atteint le processus. Une valeur vide ou composée d’espaces est traitée comme non définie, et un nombre nul ou impossible à parser retombe sur sa valeur par défaut.

Le reproduire hors ligne

C’est le chemin le plus rapide entre « une machine a reçu la mauvaise chose » et un correctif :

$ export RESCRIPTUM_CAPTURE_DIR=/var/log/rescriptum-captures   # puis laissez-le échouer une fois de plus
$ rescriptum render --body /var/log/rescriptum-captures/2026…-0000.body

render résout exactement comme le serveur, donc ce qu’il affiche est ce que cette machine aurait reçu. Pas besoin de baie. Voir capturer les requêtes.

Sans capture, répétez depuis l’identité et l’URL :

$ rescriptum render --query "path=/rhel/ks&mac=98:fa:9b:50:d8:10&serial=7ABC123"

Ajoutez path= — sans lui, la résolution n’est pas contrainte par le format et peut choisir un document que la vraie URL aurait exclu, ce qui est exactement le bug que vous pourriez être en train de chasser.

Vérifier l’ensemble

$ rescriptum check

Problèmes de chargement, chaque machine et chaque membre de groupe rendus, et le validateur de l’installateur lancé là où il est dans le PATH. Détails dans validation.

Signaler quelque chose

Pour une mauvaise réponse, le rapport utile est ce que la machine a envoyé et ce qu’elle a reçu — le .body et le .meta d’une capture, plus la ligne de log.

Nettoyez les hachages de mot de passe et les clés SSH avant de joindre quoi que ce soit : la réponse n’est dans le outcome de la capture que par son nom, mais si vous collez aussi le document rendu, il porte de vrais identifiants.

Ouvrez un ticket avec cela et la version tirée de la ligne de démarrage.

Servir les médias de démarrage

Servir les médias de démarrage

Une réponse dit à une machine comment s’installer. Elle ne dit rien de l’endroit d’où vient l’installeur — et jusqu’ici c’était le serveur web de quelqu’un d’autre, hébergeant des images que personne ne confrontait aux réponses écrites pour elles.

Avec un répertoire de médias, le même serveur fait les deux. La MAC d’une machine choisit sa réponse et l’image pour laquelle cette réponse a été écrite, et les deux ne peuvent plus diverger puisqu’un seul composant décide des deux.

$ export RESCRIPTUM_MEDIA_DIR=/srv/media

Non défini, tout est éteint. Rien ne change pour un déploiement existant tant que vous ne la définissez pas.

Où vivent les images de base

Aucune image d’installation n’est dans ce projet, ni dans une version publiée. Une ISO est l’artefact de quelqu’un d’autre, elle pèse un à quatre gigaoctets, et elle change à son propre rythme — trois raisons distinctes pour qu’elle vive sur votre disque plutôt que dans le nôtre. RESCRIPTUM_MEDIA_DIR est l’endroit où vous les gardez, et ce répertoire est l’archive : ce que l’éditeur a publié, sur disque, jamais modifié ensuite.

Ce dernier point est une propriété, pas une promesse. Rien ici ne réécrit une image — en préparer une produit un fichier compagnon et une injection appliquée au fil de l’eau (voir Préparer une image Proxmox), de sorte que les octets sur disque restent exactement ce que l’éditeur a publié et que leur empreinte reste vérifiable contre le SHA256SUMS de l’éditeur. media list dit quelles entrées sont l’archive et lesquelles en dérivent.

Faire entrer une image

Trois façons, et la première est celle à privilégier.

Choisir dans un catalogue

$ rescriptum media sources
SOURCE       NAME              WHAT IT INSTALLS
proxmox-ve   Proxmox VE        the founding case — answers come from a file injected into the image
debian       Debian            netinst images; the answer is a preseed on the kernel command line
ubuntu       Ubuntu LTS        autoinstall, via a cloud-init datasource on the kernel command line
almalinux    AlmaLinux 9       kickstart, named on the kernel command line
rocky        Rocky Linux 9     kickstart, named on the kernel command line

$ rescriptum media sources proxmox-ve
reading https://enterprise.proxmox.com/iso/SHA256SUMS …
Proxmox VE — the founding case — answers come from a file injected into the image
  proxmox-ve_9.2-1.iso
  proxmox-ve_9.2-1-arm64.iso
  proxmox-ve_9.1-1.iso

$ rescriptum media add --from proxmox-ve proxmox-ve_9.2-1.iso

Rien concernant une image précise n’est stocké dans ce serveur. Chaque catalogue nomme l’index de sommes que l’éditeur publie déjà à côté de ses propres images, et les noms comme les empreintes en sont lus au moment où vous demandez — la liste est donc celle que cet éditeur a aujourd’hui, et l’empreinte est la sienne. Une table d’URL figée dans une version publiée proposerait les images du trimestre dernier, dont certaines supprimées depuis.

Ce que cela vaut, dit franchement. Prendre l’empreinte sur le serveur qui sert l’image n’est pas une vérification de signature. En HTTPS cela authentifie le domaine de l’éditeur et cela attrape un téléchargement tronqué, un miroir corrompu et un fichier qui a changé en dessous — l’essentiel de ce qui arrive vraiment — et rien de plus. Si vous voulez davantage, utilisez la section suivante avec une empreinte que vous avez obtenue vous-même.

Laisser le serveur la récupérer

$ rescriptum media add https://enterprise.proxmox.com/iso/proxmox-ve_8.4-1.iso \
    --sha256 9f86d081884c7d65…
fetching https://enterprise.proxmox.com/iso/proxmox-ve_8.4-1.iso
  with curl, into /srv/media/proxmox-ve_8.4-1.iso.part
######################################################################## 100.0%
verifying 1.5G …
fetched 1.5G via curl, digest verified

Elle atterrit sous un nom en .part et n’est renommée qu’une fois l’empreinte vérifiée : un téléchargement partiel ne devient jamais une entrée du catalogue — le catalogue analyse ce qu’il trouve, et une ISO tronquée s’analyse comme une image inconnue qu’une machine essaierait ensuite de démarrer. Une récupération interrompue laisse le .part en place, et relancer la commande la reprend.

--sha256 est obligatoire ici, parce que rien d’autre ne vérifierait ce qui est arrivé. Les éditeurs publient un SHA256SUMS à côté de l’image. Si vous voulez vraiment vous en passer, dites --unverified — l’important est que sauter cette vérification soit un acte délibéré et non le défaut, puisque cela décide ce que chaque machine du réseau installe.

--as NOM.iso choisit le nom de fichier quand l’URL n’en implique pas d’utilisable.

::: tip Il n’y a pas de TLS dans ce binaire rustls et un magasin de racines, c’est une quarantaine de crates et plus d’un mégaoctet sur ARMv7, pour un travail dont chaque hôte a déjà l’outil. Donc ceci lance curl, ou wget si c’est lui qui est installé, et le dit franchement s’il n’en trouve aucun — auquel cas la réponse est celle ci-dessous. :::

Ou la poser vous-même

En SMB, en scp, depuis là où l’ISO se trouve déjà — le geste naturel sur un NAS — puis l’enregistrer :

$ rescriptum media add /srv/media/pve-8.4.iso --sha256 9f86d081884c7d65…
hashing /srv/media/pve-8.4.iso …
  10% (152.0M of 1.5G)

pve-8.4  9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
  proxmox Proxmox Virtual Environment 8.4-1
  kernel /boot/linux26
  initrd /boot/initrd.img
  wrote /srv/media/pve-8.4.media

--sha256 est facultatif et mérite d’être fourni : une empreinte qui ne correspond pas, c’est soit un téléchargement tronqué soit le mauvais fichier, et les deux installeraient la mauvaise chose sur chaque machine qui demande. Rien n’est enregistré en cas d’écart.

Rien n’est copié et l’image n’est jamais modifiée. Ce que media add écrit, c’est le fichier compagnon .media posé à côté, qui retient l’empreinte et ce que la détection a trouvé. C’est tout l’intérêt : hacher 1,5 Go prend près d’une minute, et le serveur ne doit jamais passer une minute dans une requête.

Une image sans compagnon apparaît quand même et est servie quand même — elle n’a simplement pas d’empreinte à revérifier, et elle est analysée à la volée.

Ce qu’il sait dire d’une image

$ rescriptum media list
ID                   FAMILY   ARCH       VERSION                          SIZE  PINNED
pve-8.4              proxmox  x86_64     Proxmox Virtual Environment…     1.5G  9f86d0818
ubuntu-24.04         ubuntu   x86_64     Ubuntu-Server 24.04.1 LTS        2.1G  —
gparted-1.6          unknown  —          GPARTED-LIVE                   420.0M  —

Six familles sont reconnues — Proxmox, Debian, Ubuntu, RHEL et ses dérivés, SUSE et Fedora CoreOS — à partir d’une table de marqueurs situés dans l’image. Là où un éditeur a laissé une chaîne de version, elle est reprise ; l’identifiant de volume sert de repli.

Une image que rien ne reconnaît est quand même listée et quand même servie. Ne pas savoir la décrire n’est pas la même chose que ne pas savoir s’en servir : elle peut être sanbootée, écrite sur une clé, ou récupérée entière par le firmware. Ce qu’elle ne peut pas faire, c’est produire une strophe de démarrage, et le serveur le dit plutôt que de deviner.

Les points d’entrée

Le listener média a sa propre socket, sur 0.0.0.0:8001 par défaut.

RouteCe qui revient
GET /le catalogue en texte, ou en JSON avec Accept: application/json
GET /<id>/isol’image
GET /<id>/kernelle noyau, diffusé depuis l’intérieur de l’image
GET /<id>/initrdl’initrd, de même
GET /<id>/initrd+isol’initrd avec l’image ajoutée, pour les vieux chargeurs
GET /<id>/file/<chemin>n’importe quel fichier dans l’image
GET /health200 OK

Rien n’est extrait et rien n’est décompressé. Un fichier dans une image ISO9660 est une plage d’octets contiguë : servir /pve-8.4/kernel est donc un positionnement et une longueur — le même travail de quelques kilo-octets que l’image fasse 400 Mo ou 4 Go.

Les plages (Range), ETag, If-Range et HEAD sont tous traités, parce que les vrais clients en ont besoin : casper d’Ubuntu et anaconda de Red Hat récupèrent tous deux par plages, et le démarrage HTTP UEFI envoie un HEAD avant de récupérer quoi que ce soit.

Pourquoi c’est un second listener

Ce n’est pas une préférence — trois raisons distinctes, dont une seule suffirait :

  • Le point de réponse répond sur n’importe quel chemin, puisque l’URL est gravée dans une ISO. Un préfixe /media/… découperait un espace réservé dans un espace délibérément ouvert.
  • RESCRIPTUM_TIMEOUT_SECS est une échéance de connexion entière de dix secondes. Un transfert de 1,5 Go dure quinze secondes en gigabit et deux minutes en 100 Mbit : tous les téléchargements seraient tués en vol — et cela ressemblerait à un réseau instable, pas à un réglage.
  • Un téléchargement retient un jeton de connexion pendant des minutes. Partager ce budget avec les réponses, c’est un déploiement qui affame ses propres installations.

Les deux ont des budgets séparés, et un test le prouve au lieu de l’espérer : les réponses continuent d’aboutir avec quatre transferts en cours.

Démarrer une machine depuis tout ça

media ipxe écrit la strophe de démarrage d’une image :

$ rescriptum media ipxe pve-8.4
#!ipxe
# Proxmox Virtual Environment 8.4-1 — generated by `rescriptum media ipxe pve-8.4`.
# An ordinary answer document: selection, layering and templating all apply.
kernel http://192.0.2.10:8001/pve-8.4/kernel ramdisk_size=16777216 rw quiet initrd=initrd.img \
    splash=silent proxmox-start-auto-installer
initrd http://192.0.2.10:8001/pve-8.4/initrd initrd.img
initrd http://192.0.2.10:8001/pve-8.4/iso proxmox.iso
boot

Il imprime un script, il n’en installe pas. Enregistrez-le dans le répertoire des réponses et c’est un document de réponse ordinaire — sélectionné, superposé et gabarisé comme n’importe quel autre :

$ rescriptum media ipxe pve-8.4 > /srv/answers/groups/rack-a/boot.ipxe

C’est bien le point. Le serveur ne devient pas malin sur le démarrage ; il gagne un générateur, et le moteur de composition que vous avez déjà fait le reste. Un {{ mac }} dans l’URL de réponse générée est rempli à chaque requête depuis les faits de la machine.

Chaque famille reçoit ce dont elle a réellement besoin, et elles ne se ressemblent pas :

FamilleComment la réponse lui parvient
Proxmox VEdans l’image, via auto-installer-mode.toml — et proxmox-start-auto-installer sur la ligne de commande pour choisir la voie automatisée
Debianpreseed/url=…
Ubuntuds=nocloud-net;s=…/, d’où cloud-init récupère user-data et meta-data
Famille RHELinst.ks=…
SUSEautoyast=…
Fedora CoreOSignition.config.url=…

Proxmox est le cas à part, et il vaut la peine de savoir pourquoi : c’est le seul qui porte l’emplacement de la réponse à l’intérieur de l’image plutôt que sur la ligne de commande du noyau. C’est aussi pour cela que c’est le seul à devoir passer une fois par prepare-iso — voir Préparer les médias d’installation.

::: tip Vous avez déjà lancé prepare-iso --pxe ? Cela laisse un répertoire contenant vmlinuz, initrd.img et une ISO allégée. Pointez RESCRIPTUM_MEDIA_DIR dessus et cela fonctionne tel quel : l’image allégée est toujours reconnue comme Proxmox, et le noyau et l’initrd posés à côté sont trouvés et servis. :::

Préparer une image Proxmox

Proxmox est la seule famille à porter l’emplacement de la réponse à l’intérieur de l’image, dans /auto-installer-mode.toml. Cela imposait jusqu’ici de lancer proxmox-auto-install-assistant prepare-iso ailleurs d’abord.

$ rescriptum media prepare pve-8.4
pve-8.4-http  prepared from pve-8.4
  answer   http://192.0.2.10:8000/proxmox
  injects  /auto-installer-mode.toml (198 bytes)
  image    1610612736 bytes (source 1610610688 + 2048 appended)
  wrote    /srv/media/pve-8.4-http.media

Nothing was copied. Serve it as /pve-8.4-http/iso, or write it to a stick with
  rescriptum media export pve-8.4-http /tmp/pve-8.4-http.iso

Ce qui vient d’être écrit est un fichier compagnon : environ deux cents octets qui tiennent lieu de 1,5 Go. La source n’est jamais modifiée, jamais copiée, et son empreinte publiée reste vérifiable. Le fichier est injecté au fil de l’eau, donc changer plus tard l’URL de réponse réécrit ces deux cents octets plutôt qu’un gigaoctet — et les deux entrées apparaissent dans media list, adossées à une seule image sur disque.

--as NOM choisit le nom de l’entrée dérivée, et --url, --cert-fingerprint et --token disent ce qui va dans le fichier.

Pour une clé USB

$ rescriptum media export pve-8.4-http /tmp/pve-auto.iso

Matérialise exactement ce que le listener aurait servi, par le même chemin de code. Une clé écrite autrement serait une seconde implémentation à maintenir honnête, et l’écart ne se verrait que sur le bureau de quelqu’un.

Quand il refuse

Refuser est ici une réponse complète, parce que le repli tient en une commande sur n’importe quelle Debian et que ce serveur sert très bien son résultat :

$ proxmox-auto-install-assistant prepare-iso pve.iso --fetch-from http --url …

Il refuse quand l’image n’a ni Rock Ridge ni Joliet — le fichier ne pourrait alors exister que sous un nom 8.3 tronqué comme AUTO_INS.TOM;1, et l’installeur ne le trouverait jamais. Il refuse une image UDF, parce qu’une ISO Windows ne garde ses gros fichiers que dans l’arbre UDF et que patcher l’arbre ISO9660 produirait quelque chose qui a l’air juste et ne l’est pas. Et il refuse quand le répertoire racine n’a pas de mou dans aucun de ses secteurs : déplacer l’extent entraînerait les tables de chemins, ce qui n’est délibérément pas fait.

Il refuse aussi de préparer une image non-Proxmox, en nommant l’alternative : toutes les autres familles prennent l’URL sur la ligne de commande du noyau, là où media ipxe la met déjà.

Si la source change en dessous

Les décalages d’injection sont calculés contre une image donnée. Une source qui aurait changé serait patchée au mauvais endroit, produisant une image qui se monte et qui est fausse — le fichier compagnon retient donc la taille de la source, et le catalogue refuse quand elle ne correspond plus :

  problem: pve-8.4-http.media: pve-8.4 was 1610610688 bytes when this was prepared and
  is 1610612736 now. The injection offsets no longer apply — re-run `media prepare`.

Dire au serveur son propre nom

Dès qu’il écrit des URL dans les scripts qu’il sert, le serveur a besoin d’un nom pour lui-même qu’une machine puisse réellement atteindre. 0.0.0.0:8001 n’en est pas un.

$ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10

Un hôte, jamais une URL. Pas de schéma, pas de port, pas de chemin — le serveur écrit des URL pour deux listeners, et une valeur portant un port épinglerait chaque script généré sur l’un d’eux. Chaque URL ajoute le port de son propre listener. Une valeur portant l’un des trois est refusée au démarrage, en nommant lequel.

Laissée vide, elle demande à la table de routage laquelle des adresses de cet hôte fait face à l’extérieur — et sur un segment sans route par défaut, se rabat sur la liste des interfaces, ce qui sur un hôte à une seule adresse n’est pas une déduction du tout. Dans les deux cas elle dit au démarrage ce qu’elle a retenu, et s’il y avait quelque chose à trancher :

RESCRIPTUM_PUBLIC_HOST is not set — using 192.0.2.10, the only address this host has.
Every generated URL will name it.
warning: RESCRIPTUM_PUBLIC_HOST is not set — derived 192.0.2.10, which is what every
generated URL will name. This host also has 10.8.0.4. If the machines reach it on one of
those instead, set it explicitly.

C’est la seconde qu’il faut prendre au sérieux : une mauvaise déduction produit une machine qui démarre, enchaîne, et se bloque sur une adresse qui n’existe pas. Nommer les autres adresses est ce qui rend la question tranchable depuis le journal lui-même, plutôt qu’en allant regarder l’hôte. Le NAT est le cas qu’aucune des deux lignes ne peut attraper : l’adresse est bien celle de cet hôte, et bien celle que les machines n’atteignent pas.

Le garder honnête

$ rescriptum media check
checking media in /srv/media
  2 image(s), 1 verified against a recorded digest
  note: ubuntu-24.04 has no recorded digest — `media add` records one
  ok — everything recorded still matches

Son code de sortie est un contrat, comme celui de check : zéro quand tout ce qui a été enregistré correspond toujours, un quand quelque chose a dérivé. deploy.sh s’y fie.

Une image qui a changé sous une empreinte enregistrée est la seule panne qui installe silencieusement quelque chose que personne n’a relu, donc elle est bruyante :

  FAIL pve-8.4: the image no longer matches what was recorded
       recorded 9f86d081884c7d65…
       found    7d793037a0760186…

Ce que cela prouve, c’est l’intégrité, pas l’authenticité : ce qui est servi est ce qui a été enregistré. Savoir si ce qui a été enregistré est bien ce que l’éditeur a publié relève de ses propres signatures, et --sha256 au moment du media add est l’endroit où cette vérification se place.

Qui a le droit de récupérer

Le trafic de démarrage n’est pas authentifié, et forcément : une ROM PXE n’a aucun identifiant — la même nécessité qui gouverne déjà le point de réponse. Les contrôles sont donc structurels : lecture seule, borné au catalogue, et aucun chemin de système de fichiers n’est jamais construit à partir d’une requête. Plus un qui peut dire pas vous :

$ export RESCRIPTUM_BOOT_ALLOW=10.0.0.0/8,192.168.0.0/16

Non définie, n’importe qui pouvant atteindre le port, ce qui sur un VLAN de provisionnement est la configuration honnête. Un VLAN de démarrage est la recommandation qui fonctionne vraiment ; voir Sécurité.

Réglages

VariableDéfautÀ quoi elle sert
RESCRIPTUM_MEDIA_ADDR0.0.0.0:8001Le listener
RESCRIPTUM_MEDIA_TIMEOUT_SECS600Échéance du transfert entier
RESCRIPTUM_MEDIA_MAX_CONNECTIONS16Transferts simultanés

Seize, c’est bas volontairement. Chaque transfert retient son jeton pendant des minutes, et le petit bout de ce sur quoi cela doit tourner est un NAS avec un disque mécanique : seize transferts à 64 Kio par morceau font environ deux méga-octets de tampons, une arithmétique qui doit tenir dans 512 Mo de RAM.

Sur une machine de datacenter, montez-la. Le point de réponse a son propre budget et n’est touché dans aucun des deux cas.

Démarrer une machine par le réseau

Démarrer une machine par le réseau

Une machine s’allume. Quatre maillons plus tard, elle s’installe comme quelqu’un l’a décidé — ou, si personne n’a encore rien décidé à son sujet, elle attend dans un menu où un humain peut le faire.

 mise sous tension

(1) ├── le DHCP dit d'où démarrer ............ À EUX. Deux options, et nous
    │   générons l'extrait qui les pose.

(2) ├── TFTP livre un chargeur ............... À NOUS
    │   un iPXE adapté à l'architecture, qui enchaîne via ${next-server}

(3) ├── iPXE demande quoi faire .............. À NOUS
    │   machine connue   → sa propre réponse sans surveillance
    │   machine inconnue → le menu

(4) └── les octets arrivent .................. À NOUS
        noyau, initrd, l'image elle-même — HTTP avec plages

Le maillon 1 appartient à quelqu’un d’autre et cela ne changera pas. rescriptum ne parle pas DHCP du tout — ni serveur, ni proxy, ni derrière un drapeau. Les sites qui déploient ceci en ont déjà un, et le faire pointer vers un serveur de démarrage est un problème résolu depuis trente ans.

Mise en route

$ export RESCRIPTUM_MEDIA_DIR=/srv/media     # les images
$ export RESCRIPTUM_BOOT_DIR=/srv/boot       # les chargeurs
$ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10   # ce que nommeront les scripts générés

RESCRIPTUM_BOOT_DIR dit où sont les chargeurs : non définie, il n’y a aucun listener TFTP et rien sur /boot/…. La nommer démarre TFTP sur 0.0.0.0:69 sauf si vous dites le contraire.

Le port 69 est privilégié, et c’est le seul port privilégié que ce serveur demandera jamais — sans répondeur DHCP, il n’y a rien après 67 ni 4011. Quatre façons de traiter la question, toutes portables :

$ export RESCRIPTUM_USER=rescriptum          # démarrer en root, lier, puis abandonner
$ setcap cap_net_bind_service=+ep rescriptum # ou n'accorder que cette capacité
$ export RESCRIPTUM_TFTP_ADDR=0.0.0.0:6969   # ou le déplacer, si leur DHCP sait le dire
$ export RESCRIPTUM_TFTP_ADDR=off            # ou n'avoir aucun listener du tout

off est une valeur, pas une absence — c’est ainsi qu’on dit qu’un autre service de cette machine livre le chargeur pendant que rescriptum sert le reste de la chaîne. Les chargeurs restent servis en HTTP sur /boot/… et restent vérifiés par boot check ; seul le listener disparaît. C’est un contournement de déploiement pour qui le veut, jamais la façon dont quoi que ce soit est livré ici : c’est rescriptum le serveur TFTP, et une version qui le couperait par défaut aurait cédé la chose même qu’elle est. Le paquet Synology ouvre le port 69 avec un setcap.

Un port TFTP qu’on ne peut pas lier n’arrête pas le serveur, et c’est le seul endroit où la règle « un listener qui ne peut pas se lier est fatal » s’inverse dans ce projet. Le port 69 est le seul port privilégié de la conception, donc le seul bind qui puisse échouer pour quelque chose que personne n’a configuré — une capacité qu’une mise à jour a discrètement perdue, le plus souvent. Les réponses sont le produit ; mourir ici ferait échouer toutes les installations en cours pour signaler qu’un second port n’a pas pu être ouvert. Donc il avertit, continue de servir, et boot check sort en non-zéro :

$ rescriptum boot check
  BROKEN nothing answers on 0.0.0.0:69 and it cannot be bound either: Permission denied.
  Port 69 is privileged: run as root and set RESCRIPTUM_USER to drop afterwards, or grant
  the binary cap_net_bind_service with setcap — the server still answers and still serves
  media, but a machine sent here by DHCP asks for a loader and gets nothing

Il demande un vrai chargeur au port plutôt que d’essayer de le lier, car lier prouve le contraire de ce qu’on croit : un bind qui réussit signifie que personne n’écoute, et un bind qui échoue ne distingue pas ce serveur d’un autre service qui squatterait le port.

On lie d’abord, on abandonne ensuite, toujours. L’ordre inverse fonctionne en test sous root et échoue au déploiement, à un redémarrage — le seul moment où personne ne regarde.

Les deux lignes de leur serveur DHCP

$ rescriptum boot dhcp-snippet --format dnsmasq
# rescriptum 0.2.0 - boot handoff for 192.0.2.10
# Architecture values are IANA option 93 codes; see docs/guide/boot/dhcp.
# Generated from the same table the TFTP server serves from.
dhcp-match=set:bios,option:client-arch,0
dhcp-match=set:efi64,option:client-arch,7
dhcp-match=set:efi64,option:client-arch,9
dhcp-match=set:efiarm64,option:client-arch,11

--format couvre dnsmasq, isc, kea, powershell, pfsense et mikrotik ; --one-loader produit la forme d’une seule ligne pour un parc d’une seule architecture.

L’extrait et le serveur TFTP sont générés depuis une même table, de sorte que ce que vous collez et ce que le serveur distribue ne peuvent pas diverger. Ce qu’ils peuvent faire, c’est nommer un chargeur que personne n’a encore téléchargé, et cela échoue silencieusement au niveau de la ROM : la machine demande, ne reçoit rien, et s’arrête sans un message sur aucune console. Une commande l’attrape :

$ rescriptum boot check
checking boot assets in /srv/boot
  ok   ipxe-arm64.efi (1.0M)
  MISSING ipxe-undionly.kpxe — every machine the snippet sends here will ask for it,
  get nothing, and stop

Son code de sortie est un contrat, comme celui de check. Placez-le au même endroit.

Quatre détails que l’extrait généré traite correctement

Chacun est une façon d’échouer sans bruit sur le réseau de quelqu’un d’autre, et aucun n’est évident :

  • Le champ BOOTP file et l’option 67. Certaines ROM ne lisent que l’un des deux, et lequel n’est pas prévisible d’après le fournisseur.
  • Un défaut sans étiquette à la fin. Chaque ligne d’architecture est étiquetée : une ROM qui n’envoie pas d’option 93 ne correspondrait à rien et n’obtiendrait aucun fichier de démarrage.
  • HTTPClient renvoyé dans l’option 60 pour les clients UEFI HTTP Boot. Le firmware filtre les offres dessus : une réponse ne portant que l’URL est écartée, en silence, ce qui est indiscernable d’une absence de serveur DHCP.
  • Un next-server pour ces clients aussi, bien qu’ils récupèrent en HTTP. Sans lui, le script embarqué du chargeur lit un ${next-server} vide et enchaîne vers nulle part.

::: tip Windows Server Une stratégie DHCP ne peut pas se conditionner sur l’option 93 — les types de condition sont la classe fournisseur, la classe utilisateur, la MAC, l’identifiant client, le FQDN et les informations de relais. L’architecture n’atteint une stratégie qu’à l’intérieur de la chaîne de l’option 60, donc le PowerShell généré définit des classes fournisseur sur PXEClient:Arch:00007* et y accroche les stratégies. Même résultat, mécanisme différent, et c’est exactement le genre de chose qu’on retient à moitié. :::

Le chargeur

TFTP livre un fichier, et la règle est écrite dans le code :

TFTP livre le chargeur. Tout ce qui suit passe en HTTP.

À 1468 octets par aller-retour, TFTP déplace environ 1,4 Mo/s sur une milliseconde de latence. Le chargeur fait un mégaoctet : deux secondes. Une image de 1,5 Go prendrait près de vingt minutes, contre quinze secondes en HTTP sur le même câble.

Quel chargeur dépend de ce que le firmware a annoncé :

Option 93ClientServi
0x0000BIOS PXEipxe-undionly.kpxe
0x0007, 0x0009UEFI x86-64ipxe-x86_64.efi, plus -snp / -snponly
0x000bUEFI ARM64ipxe-arm64.efi
0x0010, 0x0013UEFI HTTP Bootles mêmes fichiers, en HTTP, sans TFTP du tout
tout le resteUEFI 32 bits, EBC, U-Bootrefusé, avec la raison

0x0009 mérite un mot. La RFC 4578 le définissait comme « EFI x86-64 » ; le registre IANA, réécrit par la RFC 5970, le liste comme « EBC ». Les vrais firmwares x64 envoient l’un ou l’autre, donc les deux pointent vers x64 — une table produite depuis le seul registre ne donnerait rien à la moitié d’un parc.

snponly existe parce que la construction UEFI ordinaire ne voit pas toujours la carte réseau. Toutes les variantes sont servies et la table choisit ; c’est précisément le savoir qu’un exploitant ne devrait pas avoir à acquérir.

Se les procurer

Chaque version publiée attache rescriptum-boot-assets-<version>.tar.gz. Décompressez-le là où le serveur peut le lire, nommez le répertoire, et vérifiez-le :

$ tar -xzf rescriptum-boot-assets-0.2.0.tar.gz -C /srv
$ export RESCRIPTUM_BOOT_DIR=/srv/rescriptum-boot-assets-0.2.0
$ rescriptum boot check

Il contient les huit chargeurs, un SHA256SUMS, un ipxe.iso et un ipxe.usb démarrables pour une machine sans ROM PXE utilisable, et un NOTICE — c’est iPXE, en GPLv2, construit depuis un commit amont épinglé. C’est un téléchargement séparé, et il ne fait partie d’aucune archive binaire ni d’aucun .spk, délibérément : des fichiers séparés servis à côté relèvent de la simple agrégation, et packaging/ipxe/ est l’offre écrite qui les accompagne.

Pour les construire vous-même à la place — le même script que la release exécute, depuis le même épinglage :

$ packaging/ipxe/build.sh --out /srv/boot

Un chargeur venu d’ailleurs convient aussi, à condition qu’il enchaîne vers ce serveur plutôt que vers Internet — voir ci-dessous pourquoi un chargeur d’origine ne le fait pas.

Ce qui se passe au deuxième démarrage

La première question que tout le monde se pose après une installation réussie, et elle a une vraie réponse.

Une machine qui vient d’être installée redémarre, et si le démarrage réseau est encore premier dans son BIOS, elle revient ici. Ce qui suit est décidé par un seul réglage :

RESCRIPTUM_BOOT_UNCLAIMEDUne machine qu’aucune réponse ne revendique
menu (défaut)reçoit le menu, dont la première entrée est le disque local et dont le délai y retombe — quinze secondes, puis le disque
localest rendue directement à son firmware, qui passe au périphérique suivant

Ce sont deux lectures opposées de ce que signifie un fichier de réponse, et le choix appartient au déploiement.

Avec le menu, un fichier qui revendique une machine est la façon de dire laisse celle-ci tranquille — car sans lui elle atterrit dans un menu que quelqu’un pourrait cliquer. C’est juste pendant qu’on provisionne, et c’est la thèse du projet : une machine dont personne n’a rien décidé doit finir là où un humain peut décider.

Avec local, un fichier de réponse veut dire installe celle-ci, et son absence est l’état sûr. Il n’arrive rien à une machine pour laquelle vous n’avez pas écrit de fichier — elle démarre sur son disque, à chaque fois, sans menu à cliquer par accident. C’est la lecture dont un parc en production a besoin, et c’est celle qui passe à l’échelle : le nombre de machines qu’on veut réinstaller est toujours plus petit que celui des autres.

Le bénéfice, c’est que le démarrage réseau peut rester premier dans le BIOS pour toujours. Réinstaller une machine devient ajouter un fichier, redémarrer — sans console, sans menu de démarrage, sans toucher au matériel. Retirer le fichier est ce qui empêche que cela se reproduise.

$ rescriptum config set RESCRIPTUM_BOOT_UNCLAIMED=local

Dans les deux cas l’identité de la machine part d’abord. Le réglage décide seulement de ce qui arrive quand rien ne l’a revendiquée — pas s’il faut demander.

Installer une machine une fois, et une seule

Une machine revendiquée par une réponse .ipxe s’installe, redémarre, est revendiquée de nouveau, et se réinstalle — en effaçant son disque à chaque tour. Tous les systèmes de provisionnement répondent pareil : une machine est armée pour l’installation, et quelque chose la désarme ensuite.

C’est la machine qui sait. Proxmox appelle un webhook après une installation réussie et avant le redémarrage, avec ses interfaces réseau dans le corps :

[post-installation-webhook]
url = "http://192.0.2.10:8000/installed"
auth-token = "nas:s3cr3t"
$ rescriptum config set RESCRIPTUM_INSTALLED_TOKEN=nas:s3cr3t

C’est tout. La machine termine, elle le dit, et son .ipxe passe de 98fa9b50d810/ à installed-98fa9b50d810/ — un nom de répertoire qui ne lui correspond plus, le préfixe faisant partie du nom comparé. Elle démarre sur son disque désormais, et la réarmer consiste à remettre le document en place.

Le document désarmé va dans un répertoire frère plutôt que de rester dans celui de la machine, pour que 98fa9b50d810/ continue de vouloir dire « la configuration de cette machine » et que rien de ce qu’il contient n’ait à se lire comme désactivé.

Pas de jeton, pas d’endpoint — absent plutôt qu’ouvert. Sans lui, /installed est une demande de réponse ordinaire comme n’importe quel chemin, ce qui permet à une URL de rester gravable dans une ISO.

Trois choses qu’il ne fait pas, et chacune est délibérée :

  • Il ne touche jamais un groupe. Un groupe revendique un rack entier, et une machine qui finit son installation ne doit pas désarmer ses voisines. La recherche ne consulte pas les groupes du tout, plutôt que de les écarter après coup.
  • Il ne touche rien d’autre que le .ipxe. Le .toml de la machine, à côté dans le même répertoire, est ce que l’installateur a lu pour la construire, et il reste comme trace de la manière.
  • Il déplace, il ne supprime pas. C’est le seul chemin où quelque chose venu du réseau modifie le jeu de réponses : rien de ce qu’il fait n’est irréversible.

Arriver deux fois n’est pas une erreur — un webhook peut être réessayé, et une machine installée depuis le menu n’a jamais été revendiquée. Un désarmement qui échoue est journalisé en still armed, parce que sa conséquence est autrement silencieuse : la machine se réinstalle au démarrage suivant et rien d’autre ne le dirait.

Toutes les autres familles rapportent aussi

Proxmox est le seul à avoir son propre webhook. La revendication n’est pas propre à Proxmox — c’est un document .ipxe, qui concerne le chargeur et non le système d’exploitation — donc toutes les familles ont besoin du même désarmement, et toutes ont un endroit où lancer une ligne à la fin de leur installation :

curl -fsS -X POST -H "Authorization: Bearer nas:s3cr3t" \
  "http://192.0.2.10:8000/installed?mac=$(cat /sys/class/net/*/address | head -1)"

Pas de corps, pas de JSON : la query dit quelle machine, l’en-tête dit qu’elle en a le droit. Où mettre cette ligne :

Famille
Proxmox[post-installation-webhook] — natif, rien à écrire
Debiand-i preseed/late_command string in-target sh -c '…'
Ubuntulate-commands: dans le document autoinstall
RHEL, AlmaLinux, Rockyla section %post du kickstart
SUSE<scripts><chroot-scripts> du profil AutoYaST

Prenez la MAC de l’interface qui a démarré, pas la première par ordre alphabétique. L’exemple ci-dessus prend la première entrée de /sys/class/net, ce qui va sur une machine à une carte et se trompe sur une machine à quatre — et une mauvaise MAC désarme la mauvaise machine, ou personne. Sur une machine à plusieurs cartes, nommez l’interface.

L’endpoint accepte les deux formes du justificatif : l’auth-token de Proxmox arrive dans le corps JSON parce que c’est ce que Proxmox envoie, et un en-tête bearer parce que c’est ce qu’envoie un script shell. Même secret, même comparaison en temps constant.

Quand tout est juste et que la machine refuse quand même de s’installer

La chaîne peut être parfaite et échouer à la dernière marche, côté machine et non côté serveur. Deux cas réellement rencontrés, tous deux sur un Lenovo vPro :

Intel AMT en adresse statique, sur une carte partagée avec le système. Le dhclient de l’installateur envoie deux requêtes à onze secondes d’intervalle puis abandonne ; si le Management Engine tient l’interface avec une configuration statique pendant que l’hôte demande du DHCP, ces onze secondes passent sans offre et l’installation s’arrête sur Fetching answer file via HTTP failed: Network is unreachable. Mettez l’AMT en DHCP aussi. Lancer dhclient -v eno1 à la main depuis le shell de l’installateur réussit ensuite immédiatement, et c’est ce qui rend le diagnostic déroutant : le réseau va bien, c’est la temporisation qui ne va pas.

Un port de commutateur qui ne transmet pas tout de suite, pour la même raison et avec le même symptôme — RSTP en convergence, ou un lien encore en négociation après que le noyau a repris la carte des mains d’iPXE. Ce serveur n’y peut rien dans les deux cas : les onze secondes sont la fenêtre de l’installateur, pas la nôtre.

L’installateur ouvre un shell root quand il abandonne, et ce shell est le diagnostic le plus rapide qui soit :

# ip link                 # l'interface est-elle seulement montée ?
# dhclient -v eno1        # une offre revient-elle quand on la demande à la main ?
# ip addr show eno1

Une adresse qui apparaît là et pas pendant l’installation veut dire que le réseau fonctionne et que la machine a simplement demandé trop tôt.

Comment iPXE finit par parler à nous

La question qu’on ne s’attend pas à devoir trancher. Quel que soit le livreur du chargeur :

  • Un undionly.kpxe ordinaire venu d’ipxe.org fait du DHCP, se fait dire de charger iPXE, et se recharge lui-même indéfiniment — la boucle d’enchaînement documentée par iPXE.
  • Un binaire netboot.xyz d’origine embarque un script qui va droit au menu public boot.netboot.xyz. Pas de boucle, mais votre menu et vos réponses ne sont jamais consultés.

Les chargeurs que rescriptum distribue portent un script de trois lignes qui enchaîne via ${next-server} — la valeur que l’option 66 a déjà posée, puisque c’est ainsi que le chargeur est arrivé. Une seule construction générique fonctionne donc dans tous les déploiements, sans seconde condition dans un fichier de configuration qui appartient à quelqu’un d’autre.

Le script enchaîne vers le port 8001, et c’est un contrat plutôt qu’une préférence : il est gravé dans le chargeur avant qu’aucun déploiement n’existe et ne peut lire aucune configuration. Déplacer RESCRIPTUM_MEDIA_ADDR est permis, et boot check le signale.

Ce qu’une machine voit

L’étape deux met l’identité de la machine dans la chaîne de requête, la seule chose que DHCP ne peut pas faire — une option DHCP ne peut pas porter ${net0/mac} :

$ rescriptum boot bootstrap
#!ipxe
chain http://192.0.2.10:8000/ipxe/boot?mac=${netX/mac}&uuid=${uuid}\
&serial=${serial:uristring}&asset=${asset:uristring}\

|| chain http://192.0.2.10:8001/ipxe/menu

Deux détails y sont porteurs. netX, pas net0net0 n’est que la première interface, donc un serveur démarrant par son second port s’identifierait par le premier, inutilisé. Et :uristring sur chaque chaîne SMBIOS, parce que ${manufacturer} s’étend en Dell Inc. avec l’espace et qu’iPXE n’encode rien de lui-même.

Ce || final, c’est tout « un menu est la réponse par défaut » : une machine que quelque chose réclame reçoit sa propre réponse sans surveillance, et une machine que rien ne réclame retombe sur le menu. C’est la description de poste de default/, mot pour mot, appliquée à un autre format.

Le menu

$ rescriptum boot menu

Rendu depuis le catalogue au moment de la requête, et non maintenu comme un fichier : posez une ISO dans le répertoire de médias et elle est dans le menu à la requête suivante.

  • « Boot from the local disk » est en premier, et le délai y retombe. Une machine qui démarre en PXE par accident, et que rien ne réclame, finit sur son propre disque au bout de quinze secondes. Elle n’attend jamais un humain qui ne vient pas, et elle n’installe jamais rien. Avec la règle qui veut qu’une machine non réclamée reçoive un menu plutôt qu’une installation, le pire cas d’une erreur sur le périmètre des machines qui atteignent ce serveur est quelques secondes ajoutées à un démarrage.
  • Les entrées sont filtrées sur l’architecture du client : une image ARM64 n’est pas proposée à une machine x86 — ce serait une entrée qui démarre le mauvais noyau.
  • Une image qu’aucune détection n’a su placer est quand même proposée, comme un CD.
  • Les entrées de diagnostic — un shell, netinfo, et une qui démarre un autre rescriptum — sont ce dont tout serveur de démarrage finit par avoir besoin. La dernière sert à tester un serveur candidat sur site, depuis celui qui tourne, sans toucher au DHCP ni aux chargeurs.

RESCRIPTUM_BOOT_TIMEOUT_SECS (15 par défaut) règle l’attente, et RESCRIPTUM_BOOT_TITLE la barre de titre. Le logo est récupéré par console --picture … ||, qui tolère son propre échec : une console série via IPMI n’a pas de framebuffer, et c’est ainsi que la moitié des installations en datacenter sont suivies.

Ce qui casse quand ce serveur est arrêté

Cela mérite d’être dit franchement, parce que « serveur de démarrage » sonne critique et ne l’est pas :

rescriptum arrêté
Adressage DHCP, DNS, routageinchangés — il ne parle aucun de ces protocoles
Machines déjà installées et en serviceinchangées
Machines qui redémarrentinchangées — elles démarrent sur disque
Une machine qui démarre en PXE par accidentpasse au périphérique suivant, comme elle l’aurait fait
Démarrer une nouvelle installations’arrête

Rien de ce que rescriptum installe ne dépend de rescriptum ensuite. Le point de réponse est consulté pendant une installation et plus jamais.

Sécurité

Le trafic de démarrage n’est pas authentifié, et forcément — une ROM PXE n’a aucun identifiant, la même nécessité qui gouverne déjà le point de réponse. Les contrôles sont donc structurels, et l’un d’eux peut dire pas vous :

$ export RESCRIPTUM_BOOT_ALLOW=10.0.0.0/8    # partagée par TFTP et les médias

UDP est falsifiable et TFTP est de l’UDP, donc le serveur ne répond jamais à une destination de diffusion ou de multidiffusion — de l’hygiène anti-amplification plutôt que de la politesse —, plafonne les transferts au total et par pair, et journalise chacun d’eux. Il est en lecture seule : une requête d’écriture est refusée comme violation d’accès, car écrire un chargeur en UDP non authentifié serait un moyen de changer ce que démarre chaque machine du segment.

Un VLAN de démarrage est la recommandation honnête et celle qui fonctionne vraiment. Voir Sécurité.

::: tip Secure Boot Nos chargeurs ne sont pas signés, et shim ne charge que ce que la clé de son distributeur a signé — servir un shim à côté d’un iPXE non signé n’est donc pas un support de Secure Boot, c’est un démarrage qui s’arrête sur une erreur de signature. Ce qui fonctionne : désactiver Secure Boot, enrôler une MOK, ou laisser le firmware démarrer en PXE le shim et le GRUB signés de la distribution cible, servis par le listener média comme n’importe quel fichier. Nous ne signons rien, ne retirons rien, et rien ici n’affaiblit une machine dont Secure Boot est actif. :::

Quand leur DHCP est vraiment intouchable

Rien de tout cela ne coûte une ligne de code, et les trois fonctionnent :

  • UEFI HTTP Boot avec une URL saisie dans le firmware. Les firmwares serveur récents permettent d’entrer directement une URL de démarrage. La chaîne commence alors sur le listener média, sans aucune option DHCP.
  • iPXE depuis un média virtuel IPMI, une clé USB ou la ROM de la carte réseau, portant l’adresse de ce serveur. Une image d’un mégaoctet, montée une fois par machine.
  • dnsmasq en mode proxy-DHCP, pour un site qui a vraiment un serveur DHCP qu’il ne peut pas modifier. Il existe, il est mature, il tient en trois lignes de configuration, et ce n’est pas à nous de le réécrire. Le nommer est la réponse honnête.

Référence

Référence

La contrepartie exhaustive du reste du guide. Tables et contrats, une page par surface :

Vous cherchez le récit plutôt ? Commencez par ce qu’est rescriptum.

Configuration

Configuration

Des variables d’environnement — et, en option, un fichier d’où les lire, dans l’une de deux formes. Il n’y a pas de ligne de commande à se tromper, et les variables sont toute la configuration : les deux formats de fichier règlent exactement les mêmes choses sous exactement les mêmes règles, donc rien de ce qu’on écrit dans un fichier ne peut signifier quelque chose que l’environnement ne dirait pas.

Les variables

VariableDéfautSignification
RESCRIPTUM_CONFIGnon définiLire les valeurs par défaut depuis ce fichier TOML — voir plus bas
RESCRIPTUM_ENV_FILEnon définiLire les valeurs par défaut depuis ce fichier CLÉ=valeur — voir plus bas
RESCRIPTUM_STOREfilesfiles (un répertoire) ou sqlite (une base)
RESCRIPTUM_ANSWERS_DIR/srv/answersRépertoire des documents de réponse
RESCRIPTUM_DB_PATH/srv/answers.dbChemin de la base, quand RESCRIPTUM_STORE=sqlite
RESCRIPTUM_LISTEN_ADDR0.0.0.0:8000Adresse d’écoute. :0 choisit un port libre, et celui qui est bindé est affiché
RESCRIPTUM_WORKERSnombre de CPUThreads du runtime asynchrone. Pas une limite de concurrence
RESCRIPTUM_MAX_CONNECTIONS2048Connexions en vol avant délestage en 503
RESCRIPTUM_TIMEOUT_SECS10Délai de lecture des en-têtes et échéance de la connexion entière
RESCRIPTUM_ANSWER_TOKENnon définiJeton exigé par l’endpoint de réponse. Non défini = ouvert
RESCRIPTUM_ADMIN_ADDRnon définiListener de l’API d’administration. Non défini = API désactivée
RESCRIPTUM_ADMIN_TOKENnon définiJeton d’administration, 16+ caractères. Obligatoire avec RESCRIPTUM_ADMIN_ADDR
RESCRIPTUM_CAPTURE_DIRnon définiEnregistre les corps de requête ici. Non défini = pas de capture
RESCRIPTUM_LOGallall, problems ou off — voir plus bas
RESCRIPTUM_LOG_FILEnon définiUn fichier où ajouter, ou stdout / stderr. Non défini = stderr
RESCRIPTUM_MEDIA_DIRnon définiImages d’installation. Non défini = pas de média et pas de listener média
RESCRIPTUM_MEDIA_ADDR0.0.0.0:8001Le listener média, quand un répertoire de médias existe
RESCRIPTUM_MEDIA_TIMEOUT_SECS600Échéance du transfert entier. Volontairement pas les 10 s du point de réponse
RESCRIPTUM_MEDIA_MAX_CONNECTIONS16Transferts simultanés. Bas exprès : chacun retient son jeton des minutes durant
RESCRIPTUM_PUBLIC_HOSTdéduitL’hôte que nomment les URL générées. Un hôte, jamais une URL
RESCRIPTUM_BOOT_ALLOWnon définiCIDR clients autorisés à récupérer les médias. Non défini = quiconque atteint le port
RESCRIPTUM_BOOT_DIRnon définiChargeurs et menus, distribués en TFTP. Non défini = pas de TFTP du tout
RESCRIPTUM_TFTP_ADDR0.0.0.0:69Le listener TFTP, ou off pour aucun. Le port 69 est privilégié ; voir RESCRIPTUM_USER
RESCRIPTUM_TFTP_PORT_RANGEnon définiLes ports depuis lesquels un transfert répond, en premier-dernier. Un transfert TFTP quitte le port 69 aussitôt — le serveur répond depuis un port neuf et le client acquitte vers celui-là — donc un pare-feu n’autorisant que 69 jette l’acquittement, et la machine semble s’être désintéressée. Épinglez la plage pour pouvoir l’ouvrir. Non définie, le noyau choisit
RESCRIPTUM_TFTP_BLKSIZE1468Le plus grand bloc TFTP accepté. 1468 remplit exactement un chemin de 1500 octets — 1468 de charge, 4 TFTP, 8 UDP, 20 IP — donc un tag VLAN ou un tunnel rend la trame trop grande et une ROM PXE s’arrête en général sans rien dire. À baisser (1400, ou 512) quand un démarrage cale au premier bloc
RESCRIPTUM_BOOT_TIMEOUT_SECS15Secondes avant que le menu ne retombe sur le disque local
RESCRIPTUM_BOOT_UNCLAIMEDmenuCe que reçoit une machine qu’aucune réponse ne revendique. local la rend à son firmware, ce qui inverse le sens d’un fichier de réponse : présent veut dire installe celle-ci plutôt que laisse celle-ci tranquille
RESCRIPTUM_INSTALLED_TOKENnon définiLe jeton du [post-installation-webhook] de Proxmox. Défini, POST /installed existe et retire la revendication d’installation d’une machine quand elle signale sa réussite. Non défini, il n’y a pas d’endpoint
RESCRIPTUM_BOOT_LOGOintégréUn PNG à afficher derrière le menu
RESCRIPTUM_BOOT_TITLEintégréLa barre de titre du menu
RESCRIPTUM_USER / _GROUPnon définiBasculer dessus après avoir lié. L’ordre inverse échoue au déploiement

/srv est l’endroit où la norme de hiérarchie des fichiers range les données servies par le système, ce qu’est précisément un répertoire de réponses. Les deux valeurs par défaut y vivent, pour qu’un rescriptum lancé sans rien fasse quelque chose de plausible sur n’importe quel hôte Linux. Rien ne crée le répertoire pour vous, et le serveur le signale au démarrage s’il manque.

Journalisation

Une ligne par événement, sur stderr par défaut. Deux réglages, parce que les deux questions sont différentes.

QuoiRESCRIPTUM_LOG :

ValeurGarde
all (défaut)chaque requête, plus le démarrage, les avertissements et les erreurs
problemsdémarrage, avertissements, erreurs, et seulement les requêtes qui n’ont pas abouti
off / nonerien du tout

Une réponse réussie fait une ligne, et à treize mille requêtes par seconde c’est la seule chose ici qui ait du volume. problems est ce que vous voulez quand un déploiement devient routinier et que le disque, lui, ne l’est pas. Tout le reste est peu volumineux et diagnostique, donc conservé dans les deux cas.

Une requête qui n’a jamais atteint de statut — une connexion expirée en plein corps — compte comme un problème. Une valeur non reconnue retombe sur all avec un avertissement : une faute de frappe ne doit pas être la raison pour laquelle personne ne voit pourquoi un déploiement a échoué. Le niveau est nommé dans la ligne de démarrage (log=problems), donc un log vide s’explique lui-même.

RESCRIPTUM_LOG_FILE :

ValeurVa vers
non définie, ou stderrstderr, ce que lit un superviseur
stdoutstdout
toute autre valeurce fichier, en ajout ; les répertoires parents sont créés

Un fichier impossible à ouvrir est une erreur de démarrage, pas un repli sur stderr — ce serait une surprise silencieuse découverte bien plus tard. Une écriture qui échoue une fois le serveur lancé est abandonnée : un serveur de provisioning qui mourrait parce que son disque de logs est plein ferait échouer toutes les installations en cours pour signaler qu’il ne peut pas signaler quelque chose.

La rotation vous incombe. Sous systemd il n’y a rien à faire, le log part dans le journal ; avec un fichier, pointez logrotate dessus avec copytruncate.

Le fichier TOML

RESCRIPTUM_CONFIG nomme un fichier en TOML qui règle les mêmes variables, dans une forme faite pour être lue. C’est celui vers lequel se tourner quand une personne édite le fichier à la main — sur un NAS, dans File Station ou via SMB — c’est-à-dire exactement là où RESCRIPTUM_ANSWERS_DIR=… sur chaque ligne se lit mal, et où le mot « environnement » envoie chercher un shell qui n’existe pas.

# /etc/rescriptum.toml   (chmod 600, appartenant à root)
answers_dir = "/srv/answers"
listen_addr = "0.0.0.0:8000"
log         = "problems"          # all | problems | off

[store]
kind    = "sqlite"
db_path = "/srv/answers.db"

[server]
workers         = 2
max_connections = 2048
timeout_secs    = 10

[admin]
addr  = "127.0.0.1:8001"
token = "…"

[answer]
token       = "…"
capture_dir = "/var/lib/rescriptum/captures"
$ RESCRIPTUM_CONFIG=/etc/rescriptum.toml rescriptum
2026-08-29T12:42:02Z - reading configuration defaults from /etc/rescriptum.toml (8 set)

Toutes les règles du fichier d’environnement valent ici aussi : jamais découvert, seulement nommé (il n’y a pas de ./rescriptum.toml) ; l’environnement réel gagne ; et un fichier demandé et illisible est une erreur de démarrage, jamais un avertissement.

Placez-le hors du répertoire de réponses. Tout .toml servable à la racine de ce répertoire est un document réponse, et ce format partage l’extension — un fichier de configuration déposé là est signalé par check comme une réponse mal placée, et migrate propose de le déplacer. /etc est le foyer évident ; sur une installation empaquetée, le paquet en choisit un.

Les noms

Le préfixe disparaît et les tables font le regroupement. Rien d’autre ne change : chaque ligne ci-dessous est la variable du même nom, et rescriptum config affiche les deux orthographes.

Dans le fichierVariable
answers_dirRESCRIPTUM_ANSWERS_DIR
listen_addrRESCRIPTUM_LISTEN_ADDR
log, log_fileRESCRIPTUM_LOG, RESCRIPTUM_LOG_FILE
public_hostRESCRIPTUM_PUBLIC_HOST
user, groupRESCRIPTUM_USER, RESCRIPTUM_GROUP
store.kind, store.db_pathRESCRIPTUM_STORE, RESCRIPTUM_DB_PATH
server.workers, server.max_connections, server.timeout_secsRESCRIPTUM_WORKERS, RESCRIPTUM_MAX_CONNECTIONS, RESCRIPTUM_TIMEOUT_SECS
admin.addr, admin.tokenRESCRIPTUM_ADMIN_ADDR, RESCRIPTUM_ADMIN_TOKEN
answer.token, answer.capture_dirRESCRIPTUM_ANSWER_TOKEN, RESCRIPTUM_CAPTURE_DIR
media.dir, media.addr, media.timeout_secs, media.max_connectionsles quatre RESCRIPTUM_MEDIA_*
boot.dir, boot.allow, boot.unclaimed, boot.timeout_secs, boot.logo, boot.titleles six RESCRIPTUM_BOOT_*
tftp.addr, tftp.port_range, tftp.blksizeles trois RESCRIPTUM_TFTP_*
installed.tokenRESCRIPTUM_INSTALLED_TOKEN

Le format

N’importe quel scalaire TOMLun nombre peut s’écrire en nombre (workers = 2) ou en chaîne ; les deux arrivent au serveur comme le même réglage
Un commentaire #n’importe où, y compris en fin de ligne — contrairement au fichier d’environnement, qui n’a pas d’échappements et ne peut donc pas en avoir
Une valeur avec un #, un guillemet ou une espacesans problème, échappée comme TOML échappe, et relue à l’identique
""non défini — la même règle qu’une variable exportée mais vide, et c’est ce qui permet à config unset de vider une ligne au lieu de supprimer le paragraphe qui la documente
La même clé deux foisrefusée par TOML lui-même, donc le fichier ne se charge pas
Une clé que ce programme ne lit pasun avertissement la nommant : admin.tokenn est attrapé au lieu d’être ignoré
Une liste ou une table là où une valeur est attendueune erreur de démarrage : contrairement à une faute de frappe, elle visait un réglage réel, et servir la valeur par défaut alors que le fichier dit le contraire serait silencieux
Un fichier lisible par d’autresun avertissement avec son mode, parce qu’il peut contenir admin.token

Les avertissements nomment les clés et les chemins, jamais les valeurs.

Les deux fichiers à la fois

Nommer les deux est une transition plutôt qu’un état stable : rien n’est refusé et l’ordre est annoncé au démarrage — l’environnement bat le fichier TOML, qui bat le fichier d’environnement. rescriptum config montre lequel des trois a mis chaque valeur en vigueur, et config set écrit dans le fichier TOML — celui que le serveur lit en premier, pour qu’une écriture ne puisse pas être une modification qui ne change rien en silence.

Le fichier d’environnement

RESCRIPTUM_ENV_FILE nomme un fichier contenant les mêmes variables. Il existe pour les déploiements qui n’ont nulle part où mettre un jeton — au premier chef Synology DSM 7, qui n’a pas de systemd. Sous systemd, EnvironmentFile= fait déjà cela et vous n’en avez pas besoin.

# /etc/rescriptum.env   (chmod 600, appartenant à root)
RESCRIPTUM_STORE=sqlite
RESCRIPTUM_DB_PATH=/srv/answers.db
RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001
RESCRIPTUM_ADMIN_TOKEN=
$ RESCRIPTUM_ENV_FILE=/etc/rescriptum.env rescriptum
2026-08-24T12:42:02Z - reading configuration defaults from /etc/rescriptum.env (4 set)

Il n’est jamais découvert, seulement nommé. Il n’y a pas de ./.env. Ce binaire tourne en root : s’il ramassait un fichier dans le répertoire d’où il a été lancé, quiconque peut y écrire posséderait RESCRIPTUM_ADMIN_TOKEN — et avec lui le mot de passe root de chaque machine installée ensuite.

L’environnement réel gagne. Le fichier fournit des valeurs par défaut, donc ce qui est exporté délibérément au lancement n’est jamais écrasé en douce. Une variable exportée mais vide compte comme non définie, donc le fichier s’applique quand même.

Un fichier demandé et illisible est une erreur de démarrage, pas un avertissement. C’est tout l’intérêt : l’échec qu’il remplace est un serveur qui démarre sur ses valeurs par défaut — mauvais répertoire de réponses, pas de jeton admin — sans un mot dans le log.

Le format

CLÉ=valeur, une par ligneun export en tête est accepté, pour que le même fichier puisse aussi être sourceé
# en début de ligneun commentaire
# ailleursfait partie de la valeur. Pas de commentaires en fin de ligne : tronquer un jeton sur un # qu’il contient légitimement serait silencieux, alors qu’un commentaire atterrissant dans une valeur est bruyant
"guillemets" ou 'guillemets'les guillemets sont retirés et les espaces internes conservés ; une valeur sans guillemets est trimée
$HOME, ${x}non développés. Ce n’est pas un shell — pas de substitution, pas de lignes de continuation
la même clé deux foisune erreur de démarrage, plutôt qu’une supposition sur celle qui était voulue
une clé que ce programme ne lit pasun avertissement nommant la clé — RESCRIPTUM_ADMIN_TOKENN est donc attrapé au lieu d’être ignoré
un fichier lisible par d’autresun avertissement avec son mode, parce qu’il peut contenir le jeton admin

Les avertissements nomment les clés et les chemins, jamais les valeurs.

Le lire et le modifier

rescriptum config affiche chaque variable, sa valeur, et qui des fichiers ou de l’environnement l’y a mise — la distinction qui compte, puisque les fichiers fournissent des valeurs par défaut et que l’environnement réel l’emporte. config set modifie le fichier comme on voudrait qu’il le soit, dans l’un ou l’autre format : commentaires conservés, réglage commenté décommenté sur place plutôt que dupliqué (fichier d’environnement) ou valeur remplacée là où elle est (TOML), et refus avant toute écriture d’une modification qui laisserait un serveur incapable de démarrer. C’est documenté dans la référence de la ligne de commande, et c’est ce que l’application DSM pilote dessous.

Valeurs invalides

CasCe qui se passe
Exportée mais vide (RESCRIPTUM_LISTEN_ADDR=)traitée comme non définie — une valeur vide est une erreur, pas une instruction
Uniquement des espacespareil, et les valeurs sont trimées
Un nombre nul ou impossible à parserretombe sur la valeur par défaut, plutôt que de démarrer un serveur qui accepte des connexions sans jamais répondre
RESCRIPTUM_STORE avec toute autre valeurun avertissement, et files est utilisé
RESCRIPTUM_ENV_FILE ou RESCRIPTUM_CONFIG nommant un fichier absent, illisible ou malforméune erreur de démarrage
Un réglage TOML recevant une liste ou une tableune erreur de démarrage, contrairement à une clé mal orthographiée, qui avertit
RESCRIPTUM_STORE=sqlite sur un binaire construit sans la featureune erreur au démarrage

Erreurs de démarrage

Celles-ci arrêtent le serveur au lieu d’avertir, parce que démarrer quand même serait pire :

ConditionPourquoi c’est fatal
RESCRIPTUM_ADMIN_ADDR défini avec RESCRIPTUM_STORE autre que sqlitedeux façons de changer la même configuration, en concurrence
RESCRIPTUM_ADMIN_ADDR défini sans RESCRIPTUM_ADMIN_TOKENune API ouverte qui réécrit les identifiants root
RESCRIPTUM_ADMIN_TOKEN de moins de 16 caractèresassez court pour être deviné
L’adresse d’écoute ne peut pas être bindéerien à faire
Le store ne peut pas être ouvertrien à servir
RESCRIPTUM_MEDIA_ADDR défini sans RESCRIPTUM_MEDIA_DIRun listener sans rien à servir
RESCRIPTUM_MEDIA_ADDR égal à l’adresse de réponse ou d’administrationle second bind perd, et lequel dépend de l’ordre de démarrage
RESCRIPTUM_PUBLIC_HOST portant un schéma, un port ou un cheminil est écrit dans les URL de deux listeners ; un port dans la valeur épingle chaque script généré sur l’un d’eux
RESCRIPTUM_TFTP_ADDR défini sans RESCRIPTUM_BOOT_DIRun listener sans chargeur à distribuer
Le répertoire de démarrage ne peut pas être résoluchaque contrôle de chemin s’y compare
RESCRIPTUM_USER nomme un compte inexistantrien à devenir

Avertissements de démarrage

Ceux-ci sont affichés et le serveur continue :

ConditionLigne
Répertoire de réponses absentwarning: … does not exist yet — every request will 404 until it does
Le chemin existe mais n’est pas un répertoirewarning: … is not a directory — every request will 404 until it is
Répertoire de réponses présent mais illisiblewarning: … cannot be read: … — every request will 404 until that is fixed. La cause la plus probable est un serveur tournant sous un utilisateur qui n’est pas le propriétaire du répertoire
API d’administration hors boucle localewarning: the admin API is not bound to loopback — …
RESCRIPTUM_ANSWER_TOKEN de moins de 16 caractèresun avertissement, pas une erreur — refuser de démarrer laisserait un parc incapable de s’installer
Tout problème dans le jeu de réponsesune ligne warning: chacun, le même jeu que signale check
RESCRIPTUM_PUBLIC_HOST non définiLa réponse de la table de routage, ou l’unique adresse d’interface s’il n’y a pas de route par défaut. Journalisé dans les deux cas, en avertissement nommant les autres adresses s’il y en a. Un hôte derrière du NAT se trompe toujours en silence
TFTP ne peut pas se lierwarning: cannot bind TFTP on … le seul listener dont l’échec de liaison n’est pas fatal. Le port 69 est le seul port privilégié de la conception, donc le seul bind qui puisse échouer pour quelque chose que personne n’a configuré ; les réponses sont le produit, et mourir ferait échouer toutes les installations en cours pour signaler qu’un second port n’a pas pu être ouvert. boot check sort en non-zéro et le message nomme les façons d’obtenir le port
Répertoire de médias absent ou illisibleune ligne warning: media: … — un parc ne doit jamais être incapable de s’installer parce qu’une image est bizarre

Options de compilation

FeatureDéfautEffet
sqliteactivéeLe store SQLite et l’API d’administration
bootactivéeLe catalogue de médias, le lecteur ISO et le listener média

Mesuré sur ARMv7 (gnueabihf, plancher glibc 2.17), les quatre d’affilée le 2026-08-29. Remesurez plutôt que de citer ces chiffres : ils ont bougé d’environ 375 Ko quand cette cible est passée de musl à glibc, et le jeu qu’ils remplacent ici avait dérivé d’environ 200 Ko.

BuildOctets
les deux (défaut)2 813 712
sqlite seule2 557 592
boot seule1 649 048
aucune1 392 544

Limites fixes

Non configurables, et délibérément :

LimiteValeur
Corps de requête1 Moendpoint de réponse — un Content-Length invraisemblable est refusé depuis l’en-tête
Taille d’un document256 KoPUT de l’API d’administration
Requêtes capturées1000 capturescomptées depuis le répertoire au démarrage, donc un redémarrage ne repart pas de zéro
Échecs d’administration avant blocage5 en 60 sle blocage double jusqu’à un maximum de 900 s
Adresses suivies par le garde-fou4096pour qu’il ne puisse pas être transformé en fuite mémoire
Filet de rechargement du listing1 sforce une relecture même quand le mtime du répertoire semble inchangé

Surface HTTP

Surface HTTP

Deux listeners, et ils ne partagent jamais un port. L’endpoint de réponse est ce à quoi parlent les installateurs ; l’API d’administration est désactivée sauf configuration.

L’endpoint de réponse

RequêteRéponse
POST n’importe quel cheminla réponse, typée par son format
GET n’importe quel cheminla même chose
GET /health200 OK, corps OK\n — pas de jeton nécessaire, jamais limité en débit
toute autre méthode405

N’importe quel chemin, parce que l’URL est gravée dans une ISO et que ce serveur n’a pas son mot à dire. Le chemin n’est pas ignoré pour autant : les segments nommant un alias de format restreignent les documents pouvant répondre, et le chemin fournit aussi les faits path, file et segment.

Codes de statut

CodeQuand
200une réponse s’est appliquée
400le corps n’a pas pu être lu
401RESCRIPTUM_ANSWER_TOKEN est défini et la requête ne l’a pas présenté
404rien n’a revendiqué la requête et il n’y a pas de default pour le format demandé
405une méthode autre que GET ou POST
413corps de plus de 1 Mo, ou Content-Length en annonçant un
500un document ne parse pas, un groupe manque, un template n’a pas pu être rempli, ou la recherche a paniqué
503à RESCRIPTUM_MAX_CONNECTIONS — écrit immédiatement, puis la connexion se ferme

En-têtes de réponse

En-têteValeur
Content-Typeselon le format de la réponse — voir la table ci-dessous
Content-Lengthtoujours défini
Connectionclose
WWW-AuthenticateBearer, sur un 401
FormatContent-Type
toml, et tous les formats texte (ks, preseed, cfg, seed, ipxe)text/plain; charset=utf-8
yaml, ymltext/yaml; charset=utf-8
json, ignapplication/json
xml, autoyast, unattendapplication/xml; charset=utf-8

Le TOML est servi en text/plain plutôt qu’en application/toml parce que c’est ce qu’attend l’installateur Proxmox.

Limites de traitement des requêtes

Plafond du corps1 Mo. Un Content-Length invraisemblable est refusé depuis l’en-tête, avant toute lecture — plutôt que d’allouer pour lui et de faire sauter une limite plus tard
Délai de lecture des en-têtesRESCRIPTUM_TIMEOUT_SECS, 10 s par défaut
Échéance de la connexion entièrela même valeur. Les deux sont nécessaires : le délai d’en-têtes s’arrête à la fin des en-têtes, donc un client qui promet un corps sans l’envoyer garerait sinon une connexion indéfiniment
ConcurrenceRESCRIPTUM_MAX_CONNECTIONS en vol ; au-delà, un 503 et fermeture plutôt qu’une mise en file
Authentificationseulement quand RESCRIPTUM_ANSWER_TOKEN est défini. Comparée en temps constant. Les échecs sont journalisés et jamais limités en débit

Authentification

Authorization: Bearer <RESCRIPTUM_ANSWER_TOKEN>

Proxmox l’envoie quand son ISO a été préparée avec --answer-auth-token. Rien d’autre ne le peut, ce qui est pourquoi c’est désactivé par défaut. Voir Sécurité.

L’API d’administration

Un listener séparé, RESCRIPTUM_ADMIN_ADDR, en SQLite uniquement. Détails complets sur sa propre page.

RequêteRôle
GET /machines, GET /groupslister les identifiants
GET /machines/{id}, GET /groups/{name}, GET /defaultle document stocké, tel qu’écrit
PUT /machines/{id}, PUT /groups/{name}, PUT /defaultstocker un document
DELETE /machines/{id}, DELETE /groups/{name}, DELETE /defaulten supprimer un
GET /resolve/{id}la réponse fusionnée que cette machine recevrait
GET /checkles problèmes actuels
GET /healthvivacité — pas de jeton, jamais bloqué

Tous les endpoints de document prennent ?format=<ext>, toml par défaut.

CodeQuand
200fait
400document malformé, identifiant invalide, ou corps non-UTF-8
401jeton manquant ou faux
404document ou endpoint inexistant ; rien ne se résout pour cet identifiant
409l’écriture aurait cassé le jeu de réponses (annulée), ou un resolve qui n’a pas pu rendre
413document de plus de 256 Ko
429cette adresse est bloquée ; Retry-After dit pour combien de temps
500le store n’a pas pu être lu ou écrit

Chaque réponse d’administration définit Connection: close. Un GET /resolve réussi définit aussi X-Answer-Source, portant la même description que la ligne de log.

Journalisation

Une ligne par requête, sur stderr par défaut. RESCRIPTUM_LOG choisit ce qui est gardé et RESCRIPTUM_LOG_FILE où cela va — voir journalisation.

2026-08-24T08:43:37Z 127.0.0.1:61721 POST /answer body=102 200 format=toml machine=98fa9b50d810 group=example-rack bytes=431

Les lignes de niveau serveur portent - là où serait l’adresse du pair. Voir dépannage.

Formats et alias d'endpoint

Formats et alias d’endpoint

Trois tables. La version narrative est dans un document par système d’exploitation.

Extensions de document

La liste blanche des extensions que rescriptum ramassera dans un store. Tout le reste est ignoré — txt n’y est délibérément pas, pour qu’un fichier de notes égaré à côté de vos réponses ne devienne jamais un candidat.

ExtensionFamilleSuperpositionContent-Type
tomlTOMLfusion structurelletext/plain; charset=utf-8
yaml, ymlYAMLfusion structurelletext/yaml; charset=utf-8
json, ignJSONfusion structurelleapplication/json
xml, autoyast, unattendXMLfusion structurelle, par élémentapplication/xml; charset=utf-8
ks, cfg, preseed, seed, ipxetexteconcaténation dans l’ordre des couchestext/plain; charset=utf-8

La famille est ce que remonte le champ format= de la ligne de log, donc ks et preseed apparaissent tous deux en format=text. L’extension est ce sur quoi un endpoint filtre, et ce dont check a besoin pour choisir le bon validateur.

Alias d’endpoint

Un segment de chemin nommant l’un de ceux-ci restreint la réponse aux documents portant les extensions listées. N’importe quel segment du chemin peut le nommer, donc /rhel/ks, /ks et /provision/rhel/node.cfg restreignent tous au kickstart.

SegmentSertUsage typique
proxmox, pve, toml.tomlProxmox VE
debian, preseed.preseed, .seedpreseed Debian
rhel, centos, fedora, alma, rocky, kickstart, ks.kskickstart
ubuntu, autoinstall, cloudinit, nocloud, yaml, yml.yaml, .ymlautoinstall Ubuntu, cloud-init
flatcar, coreos, ignition, ign.ign, .jsonIgnition
suse, opensuse, autoyast.autoyast, .xmlAutoYaST
windows, unattend.unattend, .xmlunattend.xml Windows
json.json, .ign
xml.xml
cfg.cfg
ipxe.ipxe

Un segment n’en nommant aucun ne contraint rien, ce qui est pourquoi /answer continue de fonctionner exactement comme avant.

Deux pièges dans cette table

  • Le filtrage porte sur l’extension, pas sur la famille. .ks et .preseed sont tous deux des documents texte ; filtrer par famille laisserait un preseed répondre à /rhel/ks.
  • seed n’est délibérément pas un alias. s=http://server/seed/ est une URL de seed NoCloud parfaitement ordinaire, et elle sert du YAML. Un alias doit être assez spécifique pour que personne ne l’atteigne par accident. (L’extension .seed existe toujours, et /debian/ la sert.)

Clés de contrôle, par format

Retirées avant que la réponse ne soit envoyée.

FormatÉcriture
TOMLextends = "base", members = […], table [match], au premier niveau
YAMLextends:, members:, match: au premier niveau
JSON"extends", "members", "match" au premier niveau
XML<answer-meta extends="base"><member>…</member><match k="v" /></answer-meta>
Texte# answer: extends <nom> · # answer: member a, b · # answer: match k=v k2=v2

Les directives texte acceptent aussi // comme marqueur de commentaire. match prend des paires clé=motif séparées par des espaces ; member une liste séparée par des virgules. Les commentaires ordinaires d’un document texte sont servis — seules les lignes # answer: sont retirées.

Sémantique de fusion

Formats structurésFormats texte
Maps / objets / élémentsfusionnent récursivement
Scalairesla couche supérieure remplace
Tableaux / listesremplacent, jamais de concaténation
Document entierconcaténé dans l’ordre des couches

XML apparie les frères par nom d’élément plus un attribut discriminant — name, id, key, alias, pass — et respecte config:type="list". Déclarations, doctypes, espaces de noms et attributs survivent à une fusion ; l’indentation d’origine et le placement des commentaires, non.

Validateurs que check peut appeler

FormatOutilInvoqué comme
tomlproxmox-auto-install-assistantvalidate-answer <fichier>
xml, autoyast, unattendxmllint--noout <fichier>
ksksvalidator<fichier>
tout le resteaucun n’existe

Un outil absent du PATH est signalé une fois comme note, jamais comme un échec.

Ligne de commande

Ligne de commande

Sans argument, rescriptum lance le serveur. Tout le reste est une sous-commande.

CommandeRôle
rescriptumlancer le serveur
rescriptum render <id>afficher la réponse que cet identifiant recevrait
rescriptum render --body FICHIER…pour un corps de requête capturé
rescriptum render --query Q…pour des étiquettes, p. ex. "mac=aa:bb&serial=7ABC1"
rescriptum checkrendre tout le store configuré et signaler ce qui casse
rescriptum import <dir>copier un répertoire de documents dans le store configuré
rescriptum export <dir>écrire le store configuré comme un répertoire de documents
rescriptum migrate [<dir>]montrer ce que deviendrait un répertoire de réponses plat
rescriptum migrate --applydéplacer ces documents dans un répertoire chacun
rescriptum configafficher la configuration, et d’où vient chaque valeur
rescriptum config --jsonla même chose, pour un panneau de réglages
rescriptum config --value CLÉune valeur, pour un script — jamais un identifiant
rescriptum config set C=V …éditer le fichier que RESCRIPTUM_CONFIG ou RESCRIPTUM_ENV_FILE nomme
rescriptum config unset CLÉ …y retirer un réglage
rescriptum --helpusage et variables d’environnement

Toutes lisent les mêmes variables d’environnement, dont RESCRIPTUM_CONFIG et RESCRIPTUM_ENV_FILE — résolus en premier, donc un fichier illisible arrête toute commande ayant besoin de la configuration. --help et --version répondent avant sa lecture, parce que ce sont les commandes qu’on lance quand quelque chose ne va pas. Il n’y a pas d’options globales.

render

$ rescriptum render 98:fa:9b:50:d8:10
$ rescriptum render --query "serial=7ABC123&mac=98:fa:9b:50:d8:10"
$ rescriptum render --query "path=/rhel/ks&serial=7ABC123"
$ rescriptum render --body /var/log/rescriptum-captures/2026…-0000.body
FormeFaits fournis
<id>l’identifiant comme botte de foin, et rien d’autre — assez pour correspondre par nom, pas assez pour un sélecteur sur serial
--query "k=v&k2=v2"ces étiquettes, décodées. path= fournit aussi file et segment, et contraint le format comme le ferait une vraie URL
--body FICHIERle fichier verbatim : botte de foin, plus le JSON aplati s’il parse comme du JSON
  • Le document part sur stdout ; la ligne # format=… machine=… group=… expliquant comment il a été obtenu part sur stderr. Donc render … > answer.toml ne donne que le document.
  • Les problèmes de chargement sont d’abord affichés comme lignes warning:.
  • Sortie 0 quand quelque chose s’est résolu, 1 quand rien ne s’appliquait (le serveur aurait renvoyé un 404) ou que le rendu a échoué.

check

$ rescriptum check

Signale les problèmes de chargement, rend chaque document machine et chaque membre de groupe, nomme les groupes qui sélectionnent sur un bloc match (qu’il ne peut pas essayer sans vraie requête), et appelle le validateur de l’installateur là où il est dans le PATH.

Sortie 0 quand tout se rend, 1 dès que quelque chose a échoué — il tombe donc tel quel dans une CI. Voir validation.

import / export

$ RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db rescriptum import /srv/answers
$ RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db rescriptum export /tmp/backup

import lit un répertoire et écrit dans le store configuré ; export fait l’inverse. L’aller-retour est identique octet pour octet, chemins compris. Aucun des deux ne lance check pour vous — la sortie vous le dit.

migrate

Les réponses étaient des fichiers à la racine du répertoire de réponses — 98fa9b50d810.toml à côté de 98fa9b50d810.preseed. Elles ont désormais un répertoire chacune, et un document resté à plat est signalé et non servi. Cette commande les déplace :

$ rescriptum migrate
migrating /srv/answers
  98fa9b50d810.toml -> 98fa9b50d810/proxmox.toml
  98fa9b50d810.ipxe -> 98fa9b50d810/boot.ipxe
  groups/rack-a.toml -> groups/rack-a/proxmox.toml
  default.toml -> default/proxmox.toml
  4 document(s) to move — nothing has been changed. Re-run with --apply.

Elle montre par défaut et ne déplace que si on le lui demande. Le répertoire de réponses est ce à partir de quoi une baie s’installe ; taper la commande pour savoir ce qu’elle ferait ne doit pas le réorganiser.

--apply effectue les déplacements, chacun un rename dans le même répertoire, si bien qu’aucun document n’est jamais réécrit. Si une destination est déjà prise, rien ne bouge du tout — y compris les documents qui auraient pu — et les collisions sont nommées : un répertoire à moitié migré est l’état sur lequel personne ne peut raisonner. Elle prend un répertoire en argument, RESCRIPTUM_ANSWERS_DIR par défaut, et sur un répertoire déjà migré elle dit qu’il n’y a rien à déplacer.

config

La configuration, ce sont des variables d’environnement, et sur un déploiement qui les lit depuis un fichier — une installation par paquet, surtout — voici comment les voir et les changer sans ouvrir d’éditeur. C’est aussi ce que l’application DSM exécute dessous.

$ rescriptum config
env file: /var/packages/rescriptum/etc/rescriptum.env

  RESCRIPTUM_STORE            files                             default
  RESCRIPTUM_ANSWERS_DIR      /volume1/netboot/answers          file
  RESCRIPTUM_LISTEN_ADDR      0.0.0.0:9000                      environment
  RESCRIPTUM_ADMIN_TOKEN      (set)                             file

La troisième colonne est l’essentiel. Les fichiers fournissent des valeurs par défaut et l’environnement réel l’emporte : une valeur marquée environment ne peut donc pas être changée en éditant un fichier — et config set le dit, plutôt que de vous laisser écrire quelque chose que le serveur en cours continuera d’ignorer. Avec un fichier TOML la colonne affiche toml file, et nommer les deux fichiers affiche les deux chemins ainsi que l’ordre dans lequel ils l’emportent.

config set écrit dans le fichier TOML quand les deux sont nommés, parce que c’est celui que le serveur lit en premier : écrire l’autre serait une modification qui ne change rien en silence.

Un identifiant n’est jamais affiché, sous aucune forme de cette commande. Un jeton apparaît comme (set) ou (not set) ; --value refuse tout net.

$ rescriptum config set RESCRIPTUM_LOG=problems RESCRIPTUM_CAPTURE_DIR=/srv/captures
wrote /var/packages/rescriptum/etc/rescriptum.env

L’écriture laisse le fichier tel qu’il est par ailleurs : les commentaires restent, un réglage est remplacé là où il se trouve, et un réglage commenté est décommenté sur place plutôt qu’ajouté en dessous — ce qui compte quand le commentaire au-dessus est la seule documentation qu’a le fichier. Dans un fichier TOML, le même soin s’applique au document : la valeur est remplacée là où elle est, son commentaire de fin de ligne survit, et config unset vide la valeur au lieu de supprimer la ligne, pour que le paragraphe qui explique le réglage reste en place.

Deux refus sont délibérés :

  • Une modification qui laisserait un serveur incapable de démarrer est refusée, en bloc, avant toute écriture. Activer l’API d’administration sans jeton, ou avec un jeton de moins de 16 caractères, vous vaut la raison plutôt qu’un prochain démarrage cassé.
  • Une variable mal orthographiée est refusée. Écrite, elle serait relue comme une inconnue et signalée au démarrage suivant, quand plus personne ne fait le lien.

Contrairement à toutes les autres sous-commandes, celle-ci fonctionne quand la configuration est trop cassée pour démarrer un serveur — un fichier qui ne parse pas, un jeton d’un caractère trop court. C’est l’état dont on se sert d’elle pour sortir.

media

Les médias de démarrage : les images d’installation que ce serveur détient. Chacune de ces commandes exige RESCRIPTUM_MEDIA_DIR ; sans elle, elles le disent et sortent en 1. Voir Servir les médias de démarrage.

$ rescriptum media list                    # ce qui est détenu : famille, architecture, version, empreinte
$ rescriptum media add FILE [--sha256 D]   # enregistrer une image déjà dans le répertoire
$ rescriptum media add URL --sha256 D      # la récupérer dedans, puis l'enregistrer
$ rescriptum media check                   # revérifier chaque empreinte enregistrée
$ rescriptum media ipxe ID                 # imprimer la réponse .ipxe qui démarre une image
$ rescriptum media prepare ID [--url URL]  # une image Proxmox avec son URL de réponse dedans
$ rescriptum media export ID FICHIER       # matérialiser une entrée préparée, pour une clé

media add prend un fichier déjà dans le répertoire de médias — rien n’est téléchargé et rien n’est copié. Il le hache avec une progression, l’analyse, et écrit un fichier compagnon .media à côté. --sha256 est vérifié avant tout enregistrement : un écart n’écrit rien et sort en 1.

Le code de sortie de media check est un contrat, comme celui de check. deploy.sh s’y fie.

media ipxe imprime sur stdout et met tout le reste sur stderr, de sorte que rescriptum media ipxe pve-8.4 > groups/rack-a/boot.ipxe produit un document de réponse utilisable — ce qu’il est, rien de plus. Il imprime un script, il n’en installe pas.

boot

La moitié démarrage réseau : les chargeurs de TFTP, la configuration DHCP générée, et les deux scripts qu’une machine exécute. Voir Démarrer une machine par le réseau.

$ rescriptum boot dhcp-snippet [--format F] [--one-loader]
$ rescriptum boot check        # les chargeurs qu'un extrait nomme sont-ils sur le disque ?
$ rescriptum boot bootstrap    # imprimer le script de l'étape deux
$ rescriptum boot menu         # imprimer le menu intégré

--format vaut dnsmasq (par défaut), isc, kea, powershell, pfsense ou mikrotik. L’extrait part sur stdout et les avertissements sur stderr, de sorte que boot dhcp-snippet > dhcpd.conf produit un fichier incluable tel quel.

Le code de sortie de boot check est un contrat, comme celui de check. Ce qu’il attrape est la panne la moins diagnosticable de la chaîne : un extrait nommant un chargeur absent du disque échoue silencieusement au niveau de la ROM, sans rien sur aucune console. Il signale aussi que le listener média a quitté le port que les chargeurs distribués ont gravé.

boot bootstrap et boot menu impriment ce qu’une machine exécutera, pour la même raison que render imprime une réponse : tout ce qu’une baie exécute devrait d’abord être lisible par un humain.

Codes de sortie

CodeSignifie
0succès
1la commande a échoué — rien ne s’est résolu, un document ne parse pas, le store n’a pas pu être ouvert

config est la seule à avoir un second sens : 0 dit que la configuration en est une sur laquelle le serveur démarrerait, 1 qu’elle ne l’est pas — ou qu’une écriture a été refusée. Cela la rend utilisable depuis un script, comme check.

Le serveur lui-même sort en 0 sur SIGTERM ou Ctrl-C, et en 1 s’il ne peut pas binder ou ouvrir le store.

Développement

Travailler sur rescriptum

Travailler sur rescriptum

rescriptum est une chose petite et ciblée : il détermine quelle configuration d’installation revient à chaque machine, la compose à partir de couches, et la sert. Environ 4 000 lignes de Rust, 308 tests, et une courte liste de contraintes qui ne sont pas révisables à la légère.

Cet espace est le pourquoi. Le Guide est le quoi.

Le mettre en route

git clone https://github.com/z29k/rescriptum && cd rescriptum
cargo test                      # 308 tests
cargo run -- --help

Essayez un changement contre les exemples travaillés plutôt que contre les seuls tests — ils sont le seul endroit où tous les formats sont montrés en train de se composer ensemble :

RESCRIPTUM_ANSWERS_DIR=examples cargo run -- check
RESCRIPTUM_ANSWERS_DIR=examples cargo run -- render --query "path=/rhel/ks&serial=7ABC123"

Avant d’ouvrir une PR :

cargo fmt --all
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo build --release --no-default-features   # le plus petit build doit continuer de marcher

Ces quatre-là sont exactement ce que lance la CI.

Le dépôt

CheminContient
src/main.rsmise en place du runtime, boucle d’accept, service des connexions, routage, et la moitié bloquante d’une requête
src/lib.rsla crate. main.rs est un binaire mince par-dessus, pour que le comportement soit testable directement
src/select.rsnormalisation, correspondance, superposition — le comportement qui compte
src/facts.rsce qu’une requête dit de la machine
src/format/une interface par format de document ; xml.rs porte l’arbre XML
src/merge.rsla fusion TOML, utilisée par format
src/store/d’où viennent les documents : file.rs, sqlite.rs, derrière un trait mince
src/admin.rsl’API d’écriture, et la garantie qu’une écriture ne peut pas casser le parc
src/config.rsconfiguration par l’environnement
src/envfile.rsle fichier de valeurs par défaut que nomme RESCRIPTUM_ENV_FILE — jamais découvert, seulement nommé
src/capture.rsenregistrement de ce que les machines envoient réellement
src/cli.rsles sous-commandes render, check, import et export
src/log.rsune ligne par événement, des horodatages UTC sans crate de date, et les deux réglages au-dessus
tests/le vrai binaire sur une socket (integration, admin, guards), sa ligne de commande (cli), et la suite de conformité des deux stores (stores)
examples/un exemple travaillé de chaque format supporté
docs/ce site

Ne redéclarez jamais un module dans main.rs. Cela compile une seconde copie, fait tourner chaque test unitaire deux fois, et laisse les deux copies diverger.

Par où commencer à lire

  • Les contraintes — d’abord. Elles expliquent l’essentiel de la forme du code, et plusieurs ressemblent à des choses qu’on aurait envie d’« améliorer » tant qu’on ne sait pas pourquoi elles sont là.
  • Architecture — la carte des modules et ce qui circule entre eux.
  • Le cycle de vie d’une requête — une requête de l’accept à la réponse.
  • Sélection — la partie avec le plus de comportement par ligne.
  • Pièges déjà rencontrés — une liste de choses qui ont coûté du temps une fois. La lire coûte moins cher que les redécouvrir.

Conventions

  • Anglais pour le code, les commentaires, la documentation et les messages de commit. Cette documentation existe en français en plus, pas à la place.
  • Un comportement a sa place dans tests/stores.rs, qui fait tourner chaque cas contre les deux stores et exige le résultat identique. Un test couvrant un seul store prouve la moitié de ce qu’il prétend. Voir tests.
  • Les tableaux remplacent, ils ne concatènent pas, dans tous les formats.
  • Échouer bruyamment. Un groupe manquant, un template impossible à remplir, un document qui ne parse pas — tous sont des erreurs avec une raison. Servir une réponse à moitié construite installe une machine de travers, et personne ne s’en aperçoit avant qu’elle ne tourne.
  • Ajouter une dépendance exige une raison dans le message de commit. Ce binaire tourne en root sur le matériel d’autres gens, et le job audit de la CI est l’autre moitié de cette règle : une raison de l’ajouter n’est pas une raison de la garder.
  • Commits conventionnels avec un scopefeat(http): …, fix(select): ….

À lire aussi

CLAUDE.md à la racine du dépôt est le document d’architecture écrit pour les agents de code. Il recoupe largement cet espace et c’est le fichier à mettre à jour quand une contrainte change.

Architecture

Architecture

Un processus, une crate, aucun framework. main.rs est un binaire mince par-dessus lib.rs, pour que chaque comportement soit testable directement plutôt qu’à travers une socket.

La forme de l’ensemble

flowchart TB
  subgraph net["Réseau"]
    I["Installateur<br/>POST /answer · GET /rhel/ks"]
    A["Client d'administration"]
  end

  I --> M["main.rs<br/>accept · timeouts · routage"]
  A --> AD["admin.rs<br/>listener propre · auth · écritures gardées"]

  M --> F["facts.rs<br/>query · feuilles JSON · botte de foin"]
  F --> S["select.rs<br/>match · superposer · remplir"]
  AD --> S

  S --> FM["format/<br/>parser · fusionner · rendre"]
  FM --> MG["merge.rs<br/>fusion profonde TOML"]
  FM --> X["format/xml.rs<br/>arbre XML"]

  S --> ST["store/ (trait)"]
  AD --> ST
  ST --> FS["file.rs<br/>un répertoire"]
  ST --> SQ["sqlite.rs<br/>une base"]

  CLI["cli.rs<br/>render · check · import · export"] --> S

Ce que possède chaque pièce

ModulePossède
main.rsle runtime tokio, la boucle d’accept, le sémaphore de connexions, les deux timeouts, le routage, la vérification du jeton de réponse, et l’appel spawn_blocking qui fait la recherche
facts.rstransformer une requête en valeurs étiquetées — paramètres de query, corps JSON aplati, segments de chemin, et la botte de foin normalisée
select.rsle comportement qui compte : normalisation, scoring, chaîne de groupes, ordre de fusion, remplissage de templates, et le listing mis en cache
format/une interface par format de document. Doc parse, fusionne, rend, et signale ses clés de contrôle
merge.rsla fusion profonde TOML, utilisée par format
store/d’où viennent les documents, derrière un trait de lecture à deux méthodes
admin.rsson propre listener, l’auth bearer, le garde-fou d’échecs, et l’annulation qui empêche une écriture de casser le jeu de réponses
config.rsl’environnement, et la validation qui transforme une configuration dangereuse en erreur de démarrage
envfile.rsle fichier que nomme RESCRIPTUM_ENV_FILE : parsé, jamais découvert, et fatal s’il est illisible
cli.rsrender, check, import, export
capture.rsl’enregistrement des corps de requête
log.rsune ligne par événement, des horodatages UTC calculés sans crate de date, et les deux réglages au-dessus : ce qui est gardé, et où cela va

La seule frontière qui mérite d’être défendue

Le store est délibérément mince. Il rend le texte brut des documents et un jeton de version bon marché, et ne décide de rien :

pub trait Store: Send + Sync {
    fn version(&self) -> Version;               // assez peu cher pour être appelé par requête
    fn snapshot(&self) -> io::Result<Snapshot>; // seulement quand la version a bougé
    fn describe(&self) -> String;
}

Chaque décision — correspondance, chaînes extends, fusion, rendu, check — vit au-dessus, dans select.rs et merge.rs, et est partagée par les deux backends. Dès qu’un backend se met à décider du comportement, les deux divergent.

tests/stores.rs est ce qui fait de cela une garantie plutôt qu’une intention : chaque cas de comportement tourne deux fois, une par store, et affirme le résultat identique.

La moitié écriture est un trait séparé, parce que servir des réponses n’en a jamais besoin :

pub trait StoreWrite: Store {
    fn put_machine(&self, id: &str, format: &str, body: &str) -> io::Result<()>;
    fn delete_machine(&self, id: &str, format: &str) -> io::Result<bool>;
    // …groupes, défaut
}

Notez que chaque opération nomme un format. Un document est indexé par ce à quoi il sert — une machine et un système d’exploitation — pas par identifiant seul.

La couche de cache

Answers enveloppe un store et garde un Listing parsé et fusionné derrière un mutex :

struct Cached { version: Version, loaded_at: Instant, listing: Arc<Listing> }

Une requête réutilise le cache seulement quand les trois conditions tiennent :

  1. store.version() est inchangée — pour les fichiers, le mtime du répertoire ; pour SQLite, un atomique en mémoire ;
  2. cette version est Some — une version illisible n’est jamais traitée comme « inchangée » ;
  3. moins de RELOAD_BACKSTOP (1 s) s’est écoulé.

Le filet n’est pas redondant avec la vérification de version. Éditer le contenu d’un fichier de groupe ne bouge aucun mtime de répertoire, et un changement fait par un autre processus ne bouge aucun atomique en mémoire. Sans le filet, l’une ou l’autre édition serait invisible jusqu’à ce qu’autre chose arrive au répertoire.

Un mutex empoisonné — une autre requête a paniqué en plein rafraîchissement — est récupéré plutôt que propagé. Les données en cache sont encore structurellement saines, et faire échouer une installation à cause du panic d’une autre requête serait le mauvais compromis.

Pourquoi il n’y a pas de framework

Le routage ici est un seul if sur la méthode et le chemin. Un framework n’apporte rien pour ça, et axum en particulier ne donne aucun moyen de définir un délai de lecture des en-têtes — précisément le garde-fou anti-slowloris qui a motivé le passage à l’asynchrone. Donc : hyper directement.

Dépendances

64 crates, 2,4 Mo statique sur ARMv7 (1,3 Mo sans SQLite). Directes :

CratePour
tokiole runtime, les timers, les signaux
hyper + hyper-util + http-body-utilHTTP/1, avec un délai de lecture des en-têtes
toml_editTOML, en préservant la mise en forme
serde_jsondocuments JSON, et aplatissement d’un corps de requête
serde_yaml_ngdocuments YAML
quick-xmldocuments XML
rusqlite (optionnelle, bundled)le store SQLite

Aucun serde derive nulle part. La règle d’origine était « ne jamais parser le corps de requête comme du JSON » ; elle a été assouplie délibérément, et l’énoncé honnête de l’état actuel est : le corps est parsé en une serde_json::Value non typée quand il se trouve être du JSON, uniquement pour en récolter des faits. Rien n’est désérialisé dans une struct, donc aucune hypothèse sur le schéma de Proxmox n’est gravée dans un type. Un corps qui n’est pas du JSON n’est pas une erreur — il apporte la botte de foin et rien de plus. Voir sélection.

Ajouter une dépendance exige une raison dans le message de commit. Ce binaire tourne en root sur le matériel d’autres gens.

Les contraintes

Les contraintes

Ce sont des décisions, pas des oublis. Plusieurs ressemblent, vues de l’extérieur, à des améliorations évidentes. N’en changez aucune sans demander — et si vous en changez une, changez cette page et CLAUDE.md avec.

Asynchrone, sur tokio et hyper

La spécification d’origine demandait zéro dépendance et un thread par connexion. Les deux ont été écartées délibérément, une fois que l’exigence est devenue « absorber une rafale de provisioning professionnelle ». Un déploiement de 2 000 machines, ce sont 2 000 connexions quasi simultanées, et un thread chacune fait 2 000 piles sur une machine à 512 Mo.

Ce qui a survécu de la spec : aucun serde derive, aucun framework, et une liste de dépendances directes très courte. Voir architecture.

hyper directement, pas axum

axum ne donne aucun moyen de définir un délai de lecture des en-têtes, précisément le garde-fou anti-slowloris qui a motivé le passage à l’asynchrone. Le routage ici est un seul if sur la méthode et le chemin, donc un framework n’apporte rien et coûte la seule chose qui comptait.

Concurrence bornée, même si les tâches sont bon marché

Une connexion coûte des kilo-octets plutôt qu’un thread — c’est tout l’intérêt de la réécriture asynchrone. Mais bon marché n’est pas gratuit, et un accept non borné transforme quand même une rafale en épuisement mémoire.

Un Semaphore de RESCRIPTUM_MAX_CONNECTIONS plafonne les connexions en vol. Au-delà, le serveur écrit un 503 immédiat et ferme plutôt que de mettre en file : un client à qui on dit de réessayer s’en sort mieux qu’un client garé dans une file qui ne se videra pas.

Le travail sur le système de fichiers passe par spawn_blocking

read_dir et read sont des appels bloquants, et bloquer un thread worker asynchrone bloque toutes les autres connexions que ce thread pilote. Sur un NAS dont le disque dort, ce n’est pas théorique — un réveil de disque se compte en secondes, pas en millisecondes.

resolve() porte à la fois le parsing et l’E/S, et n’est jamais appelé qu’à l’intérieur d’un spawn_blocking. Un panic là renvoie un 500 ; il ne peut pas emporter le serveur.

Ne jamais paniquer sur une entrée malformée

Tout échec de parsing devient une réponse d’erreur plus une ligne de log. Écrivez le code comme s’il n’y avait pas de filet.

Il y en a un, délibérément : le profil release ne définit pas panic = "abort". Avec le déroulement, un panic est confiné à la connexion qui l’a causé au lieu de tuer un serveur en pleine installation. Coût mesuré sur ARMv7 : +2416 octets, +0,8 %. Ne le « ré-optimisez » pas.

Si la conception passe un jour à un pool de threads, ajoutez un catch_unwind à la frontière du worker — un thread de pool qui meurt en silence est pire que l’un ou l’autre.

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
# panic = "abort" est délibérément ABSENT

Le store ne décide de rien

Il rend le texte brut des documents et un jeton de version bon marché. Correspondance, chaînes extends, fusion, rendu et check vivent tous au-dessus et sont partagés.

Gardez-le ainsi. Dès qu’un backend se met à décider du comportement, les deux divergent — et tests/stores.rs cesse de pouvoir prouver le contraire.

L’organisation du stockage n’est pas l’URL

Répertoires et lignes de base sont un espace de recherche et doivent rester libres d’être réorganisés. Une URL est un contrat public gravé dans une ISO et ne doit pas bouger parce que quelqu’un a renommé un dossier. Une conception antérieure faisait du nom de répertoire le segment d’URL et a été écartée pour exactement cette raison.

La conséquence est que la clé d’un document est (identifiant, format), ce autour de quoi le schéma SQLite est construit.

Ne jamais construire un chemin de fichier à partir de données de requête

C’est le garde-fou contre la traversée de chemin, et il est structurel plutôt qu’une vérification : seules les entrées directes du répertoire de réponses sont lues. Les identifiants arrivant à l’API d’administration sont validés séparément, à la frontière de l’API et dans les deux stores, parce qu’export les retransforme en noms de fichiers.

Les réponses doivent être des documents valides

Avant la fusion, un fichier de réponse était servi comme des octets opaques, donc un fichier malformé atteignait l’installateur. Maintenant c’est un 500 avec l’erreur de parsing dans le log.

C’est le meilleur échec — un installateur qui reçoit du TOML à moitié valide échoue d’une façon bien plus déroutante — mais c’est un changement de comportement, et des fixtures écrites en pseudo-YAML ont cessé de fonctionner à ce moment-là.

Échouer bruyamment

Un groupe manquant, un template impossible à remplir, un document qui ne parse pas : tous sont des erreurs avec une raison, jamais une réponse au mieux.

Le raisonnement est toujours le même. Une réponse à moitié construite installe une machine de travers, et personne ne s’en aperçoit avant qu’elle ne tourne. Une installation ratée se remarque en quelques minutes.

Asymétries délibérées

Deux endroits où la symétrie évidente est fausse exprès :

Le jeton de réponse n’est jamais limité en débit ; celui d’administration siune baie peut se trouver derrière une seule adresse, donc l’exclure transforme un mauvais jeton en déploiement raté. Aucun installateur ne parle à l’API d’administration
Un jeton de réponse court avertit ; un jeton d’administration court empêche le démarragerefuser de démarrer laisserait un parc incapable de s’installer. Refuser de démarrer l’API d’administration ne coûte d’installation à personne

Ce que la spec demandait et n’a pas eu

plans/rescriptum-spec.md (dans le .gitignore, donc un contributeur ne l’aura pas) est le compte rendu de ce qui a été demandé au départ, pas une description de ce qui existe. Le projet l’a dépassée dans toutes les directions : multi-OS, sélecteurs, templating, une API d’administration, un store en base.

Trois écarts précis, tous listés ci-dessus : asynchrone plutôt qu’un thread par connexion, panic = "abort" omis, et le corps de requête parsé en JSON non typé pour en récolter des faits. Là où la spec et cette page divergent, c’est cette page qui a raison.

Le cycle de vie d'une requête

Le cycle de vie d’une requête

sequenceDiagram
  participant C as Installateur
  participant L as boucle d'accept
  participant T as tâche tokio
  participant B as pool bloquant

  C->>L: connexion TCP
  L->>L: try_acquire_owned()
  alt pas de permis
    L-->>C: 503, fermeture
  else
    L->>T: spawn(connection)
    Note over T: échéance de connexion démarre
    C->>T: en-têtes de requête
    Note over T: header_read_timeout
    T->>T: /health ? jeton ? méthode ? Content-Length ?
    C->>T: corps (plafonné à 1 Mo)
    T->>B: spawn_blocking(Facts + resolve)
    B->>B: version() → listing en cache ou snapshot()
    B->>B: match · superposer · fusionner · remplir · nettoyer
    B-->>T: Resolution | None | Err
    T-->>C: 200 + document · 404 · 500
    T->>T: une ligne de log, capture si activée
  end

1. Accept

serve() boucle sur listener.accept() dans un tokio::select! avec le signal d’arrêt (SIGTERM, ce qu’envoie le planificateur de tâches DSM, ou Ctrl-C).

Un échec d’accept — épuisement de descripteurs de fichiers, par exemple — journalise et continue. Terminer la boucle transformerait un problème de ressources passager en panne.

Un permis est pris au sémaphore avant le spawn. Sans permis, shed() écrit un 503 et ferme — répondre honnêtement plutôt que jeter en silence, pour que le client sache qu’il doit réessayer au lieu de deviner.

2. La connexion

Deux timeouts, et aucun n’est redondant :

Garde-fouCouvre
http1::Builder::header_read_timeoutun client qui ouvre une connexion et distille ses en-têtes
tokio::time::timeout autour de la connexion entièretout ce qui vient après les en-têtes

hyper n’a pas de délai de lecture de corps. Sans le second garde-fou, un client qui promet un corps dans son Content-Length puis n’envoie rien garerait une connexion indéfiniment — à l’intérieur d’un permis, donc en coûtant une place autant que de la mémoire.

hyper panique si un timeout est défini sans timer. header_read_timeout exige .timer(TokioTimer::new()). Omettez-le et chaque connexion panique à l’exécution — cela ne casse pas la compilation. Voir pièges.

3. Routage

Un seul if sur la méthode et le chemin, dans cet ordre :

  1. GET /health200 OK. Avant l’authentification, avant tout, pour que la supervision ne s’éteigne jamais.
  2. Le jeton de réponse, quand RESCRIPTUM_ANSWER_TOKEN est défini. Comparé sans retour anticipé, pour qu’un mauvais jeton ne puisse pas être récupéré octet par octet par qui chronomètre les réponses. Journalisé, jamais limité en débit.
  3. La méthode — tout ce qui n’est ni GET ni POST donne 405.
  4. Content-Length — une taille annoncée aberrante est refusée depuis l’en-tête, plutôt qu’en laissant Limited sauter après avoir tamponné un mégaoctet.
  5. Le corps, via Limited::new(…, MAX_BODY). Une erreur de limite de longueur devient 413, toute autre 400.

Il n’y a pas de routage de chemin au-delà : POST et GET sont traités sur n’importe quel chemin, parce que l’URL est gravée dans une ISO. Le chemin n’est pas ignoré — il devient des faits — il ne décide simplement pas s’il faut répondre.

4. Résolution, hors du worker asynchrone

let picked = tokio::task::spawn_blocking(move || {
    let facts = Facts::from_request(Some(&request_path), query.as_deref(), &body);
    answers.resolve(&facts)
}).await;

Les deux moitiés ont leur place hors du worker asynchrone : construire les faits est du travail CPU sur une charge de taille arbitraire, et la recherche est de l’E/S bloquante. Faire l’un ou l’autre sur un thread du runtime bloque toutes les autres connexions que ce thread pilote.

À l’intérieur, resolve() :

  1. demande au store sa version() — un stat pour les fichiers, une lecture atomique pour SQLite ;
  2. réutilise le Listing en cache, ou prend un nouveau snapshot() et le reconstruit ;
  3. choisit le meilleur document machine et le meilleur groupe (scoring) ;
  4. résout extends, à l’intérieur d’un format ;
  5. fusionne chaîne de groupes → document machine ;
  6. remplit les {{ placeholders }} ;
  7. retire les clés de contrôle ;
  8. rend.

5. Réponse

RésultatRéponse
Ok(Ok(Some(resolution)))200, le document, Content-Type selon son format, Connection: close
Ok(Ok(None))404 no answer file applies
Ok(Err(e))500, avec la raison sur la ligne de log
Err(join_error)500 answer lookup panicked — cela ne peut pas emporter le serveur, mais ne doit pas non plus passer en silence

Puis exactement une ligne de log, et une capture s’il y en a une de configurée. Le corps a été cloné avant que la résolution ne le prenne, et seulement quand la capture est active.

Le listener d’administration

Un TcpListener séparé, une tâche serve() séparée, lancée seulement quand RESCRIPTUM_ADMIN_ADDR est défini — et seulement après que Config::validate a confirmé que le store est SQLite et que le jeton est assez long. Son propre pipeline est dans les internes de l’API d’administration.

Arrêt

SIGTERM ou Ctrl-C termine la boucle d’accept et fait retourner serve(). Les connexions en vol ne sont pas drainées : il n’y a pas d’état à perdre, le client réessaie, et un serveur de provisioning qui refuse de s’arrêter est pire qu’un qui perd une requête.

Internes de la sélection

Internes de la sélection

src/select.rs et src/facts.rs portent le comportement qui compte. Les deux sont de la logique pure sur des données qu’on leur passe, et les deux sont abondamment testés — 27 et 22 tests respectivement.

Normalisation

pub fn normalize(input: &[u8]) -> String   // alphanumériques ASCII minuscules, le reste jeté

Elle prend des octets, pas un &str, volontairement : un corps de requête est constitué d’octets arbitraires et n’a pas besoin d’être de l’UTF-8 valide. Filtrer sur les alphanumériques ASCII contourne complètement la question — pas de validation, pas de conversion avec perte, pas de mode d’échec.

C’est ce qui rend la correspondance indifférente au style de séparateur et à la façon dont Proxmox structure son JSON cette version-ci. C’est un test de sous-chaîne sur des octets, pas un schéma.

normalize_pattern est l’autre. La normalisation ordinaire retire * et ? avec le reste de la ponctuation, ce qui transforme chaque glob en littéral — en silence. Les motifs de sélecteur doivent passer par normalize_pattern, qui les conserve.

Faits

Facts est une map étiquette → valeurs, plus la botte de foin. Trois sources, étagées du plus structuré au moins structuré :

Les paramètres de query — parsing fait main avec décodage pourcent, plutôt que de tirer une crate d’URL pour vingt lignes de travail. Les valeurs vont aussi dans la botte de foin, pour qu’un document nommé d’après une MAC résolve que la MAC soit arrivée dans un corps POST ou dans une query string. Sans cela, un GET — qui n’a aucun corps — ne pourrait jamais correspondre par nom.

Le chemin apporte trois étiquettes synthétisées :

ÉtiquetteDepuis
pathle chemin entier, débarrassé de ses slashes
fileson dernier segment
segmentchaque segment, comme valeurs séparées

file n’est pas de la décoration : la source de données NoCloud de cloud-init récupère user-data et meta-data depuis une seule URL et ignore complètement la source si l’un des deux manque, donc le même serveur doit leur répondre différemment. Les segments de chemin alimentent aussi la botte de foin, parce que NoCloud peut développer __dmi.chassis-serial-number__ dans l’URL.

Le corps JSON, aplati par flatten() en à la fois ses chemins pointés complets et ses noms de feuilles nus. Les indices de tableau font partie du chemin mais pas du nom de feuille, donc network_interfaces.0.mac est aussi atteignable par mac tout court.

L’écart avec la règle d’origine

La règle initiale était que le corps de requête n’est jamais parsé comme du JSON. Elle a été assouplie, délibérément et étroitement :

if let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) {
    flatten(&value, &mut String::new(), &mut facts);
}

Non typé, opportuniste, et non fatal — un corps qui n’est pas du JSON apporte simplement la botte de foin et rien de plus. Aucune struct n’est dérivée, donc aucune hypothèse sur le schéma de Proxmox n’est gravée dans un type.

C’est la forme « nom de feuille » qui justifie tout cela. La documentation de Proxmox avertit elle-même que le contenu de dmi « peut varier énormément selon le système ». Un numéro de série ne peut être atteint d’aucune autre façon, parce que l’URL gravée dans une ISO est la même pour toutes les machines. Un sélecteur disant « un champ nommé serial, où qu’il se trouve » survit à une réorganisation qu’un chemin figé ne supporterait pas.

Scoring

const IDENTITY_SCORE: u32 = 1_000;

fn score(control: &Control, identity: &[String], facts: &Facts) -> Option<u32> {
    if identity.iter().any(|n| !n.is_empty() && facts.haystack().contains(n)) {
        return Some(IDENTITY_SCORE);      // nommer une machine est le plus spécifique possible
    }
    if control.matchers.is_empty() { return None; }
    control.matchers.iter()
        .all(|(k, p)| facts.matches(k, p))
        .then_some(control.matchers.len() as u32)
}
  • identity est le radical normalisé pour un document machine, et les members normalisés pour un groupe.
  • Tous les critères doivent tenir ; le score est leur nombre.
  • IDENTITY_SCORE vaut 1000 plutôt que u32::MAX pour que « une correspondance d’identité bat n’importe quel sélecteur » reste lisible, et qu’un sélecteur à mille critères reste un problème théorique plutôt qu’un problème subtil.

Les égalités se départagent sur le nom trié, le premier par ordre alphabétique :

.max_by(|(a, ca), (b, cb)| a.cmp(b).then_with(|| cb.id.cmp(&ca.id)))

La comparaison interne inversée est ce qui fait préférer à max_by le plus petit nom. matchbox, l’antécédent le plus proche, documente que sa propre résolution entre groupes concurrents « ne sera pas déterministe ». Celle-ci l’est, et un test l’épingle.

Filtrage par format

fn wanted(facts: &Facts) -> Option<&'static [&'static str]>   // depuis les faits `segment`
fn acceptable(wanted: Option<&…>, format: &str) -> bool       // None ⇒ tout peut répondre

Le filtrage porte sur l’extension, jamais sur la famille. .ks et .preseed sont tous deux Kind::Text ; filtrer par famille laisserait un preseed répondre à /rhel/ks.

None — une URL ne nommant aucun alias — ne contraint rien, ce qui garde /answer fonctionnel pour un déploiement qui ne sert jamais qu’un format.

Le cache du listing

struct Cached { version: Version, loaded_at: Instant, listing: Arc<Listing> }

Réutilisé seulement quand la version() du store est inchangée, vaut Some, et que moins de RELOAD_BACKSTOP (1 s) s’est écoulé.

La lecture littérale de la spécification — relire le répertoire à chaque requête — c’est un readdir plus un tri plus une passe de normalisation par requête. Avec un document de réponse par machine, le débit s’effondre :

DocumentsRelecture littéraleCache par mtime
1011 954 req/s12 922 req/s
2003 198 req/s12 890 req/s
2 000311 req/s12 520 req/s
10 0006 924 req/s

Un stat remplace tout le parcours, et une nouvelle machine est quand même prise en compte sans redémarrage — ce qui est la garantie que la spécification voulait réellement. Les identités normalisées sont calculées une fois par lecture du store, pas une fois par requête.

Le filet n’est pas redondant, et il travaille plus qu’avant. Éditer le contenu d’un document ne bouge aucun mtime de répertoire ; en ajouter un à l’intérieur du répertoire d’une machine non plus, puisque c’est un niveau sous le mtime surveillé ; et un changement fait par un autre processus ne bouge aucun atomique en mémoire. Avec un répertoire par identité, le filet est donc ce qui rattrape tout sauf l’apparition ou la disparition d’une identité. Des tests couvrent chaque cas.

Les chiffres ci-dessus ont été mesurés contre l’agencement plat. La lecture elle-même est désormais un readdir par identité en plus du fichier qu’elle ouvrait déjà — de 28 ms à 63 ms à 2 000 machines — que le cache amortit sur une seconde de requêtes, et qui n’a pas déplacé le débit de façon mesurable. Cela reste la raison pour laquelle un groupe vaut mieux qu’un répertoire par machine.

Le coût restant à 10 000 documents est un balayage linéaire d’aiguilles précalculées — du CPU pur, aucun appel système. Regrouper les aiguilles par longueur et faire glisser une fenêtre sur le corps supprimerait ce coût, mais un déploiement de 10 000 machines se termine déjà en moins de deux secondes. Mesurez avant d’ajouter cela.

Construire un Listing

build(snapshot) fait tout ce qui est coûteux, une fois :

  • parser chaque document, en gardant l’erreur plutôt qu’en faisant échouer le chargement ;
  • normaliser chaque radical et chaque entrée de members ;
  • résoudre les chaînes extends, en détectant cycles et parents manquants — le groupe cassé est écarté plutôt qu’appliqué à moitié, et le problème est enregistré ;
  • pré-fusionner la chaîne de chaque groupe, et la pré-rendre comme chaîne de caractères quand elle ne porte aucun placeholder.

Ce dernier point est pourquoi le groupement est le chemin rapide : le cas courant en datacenter ne parse rien par requête. Group::has_placeholders est le drapeau qui en décide.

problems est collecté ici, pas au moment de la requête, ce qui permet au garde-fou d’annulation de l’API d’administration d’attraper un extends cassé avant que quiconque ne le demande.

Résolution

resolve() est un match sur (machine, machine_doc, group) :

CasComportement
groupe seulservir la chaîne préparée, ou cloner-remplir-nettoyer-rendre si templatisé
machine seuleremplir, nettoyer, rendre
les deuxchaîne de groupes, fusionner la machine par-dessus, remplir, nettoyer, rendre
aucunretomber sur default pour le format demandé, qui peut lui-même extends un groupe

Les variables de template sont les faits de la requête plus machine et group, que les faits ne peuvent pas porter parce qu’ils ne sont connus qu’une fois la correspondance faite.

machine n’est lié que si un document machine a matché. Une machine revendiquée par les members d’un groupe sans document à elle se résout avec machine: None, donc {{ machine }} dans un groupe échoue pour exactement les membres qu’il devait servir. Le guide de templating dit d’utiliser un fait de requête à la place.

Formats et fusion

Formats et fusion

src/format/mod.rs donne une seule interface à chaque format de document, pour que select.rs n’ait jamais à savoir lequel il tient.

enum Inner {
    Toml(toml_edit::DocumentMut),
    Yaml(serde_yaml_ng::Value),
    Json(serde_json::Value),
    Xml(xml::Document),
    Text(String),
}

Doc l’enveloppe et offre parse, merge, render, control, strip_control, substitute et has_placeholders. Ajouter un format, c’est ajouter une variante et remplir ces sept-là — rien au-dessus de ce module ne change.

Kind

Kind::for_extension est une liste blanche délibérée. txt n’y est pas, pour qu’un fichier de notes égaré à côté des réponses ne devienne jamais un candidat.

Kind est la famille ; l’extension est gardée séparément, parce que ce ne sont pas la même chose :

  • Kind décide comment parser, comment fusionner, et le Content-Type.
  • L’extension décide si un endpoint peut être servi, et quel validateur check appelle. ks et preseed sont tous deux Kind::Text mais ne partagent pas de validateur — ce qui est pourquoi Resolution porte format_name à côté de format.

Filtrer sur la famille plutôt que sur l’extension laisserait un preseed répondre à /rhel/ks.

endpoint_formats

Une petite table d’alias associant un segment d’URL aux extensions qu’il accepte. Deux pièges y vivent, tous deux déjà payés :

  • Filtrer sur l’extension, pas sur le Kind — comme ci-dessus.
  • Un alias doit être assez spécifique pour que personne ne l’atteigne par accident. seed a été retiré : s=http://server/seed/ est une URL de seed NoCloud ordinaire, et elle sert du YAML.

Un segment ne nommant aucun alias ne contraint rien, donc /answer continue de fonctionner.

Règles de fusion

Maps / objetsfusionnent récursivement
Toute autre valeurremplacée intégralement par la couche supérieure
Tableauxremplacent, ils ne concatènent pas
Kind::Textconcaténation dans l’ordre des couches

Les tableaux remplacent parce que concaténer rendrait une liste impossible à raccourcir depuis une couche supérieure, et « ce nœud a deux disques, pas quatre » doit rester exprimable. La règle est la même dans tous les formats, pour qu’on n’ait jamais à se rappeler dans lequel on est.

merge.rs porte le cas TOML, et utilise as_table_like pour que [table] et { inline = "table" } fusionnent entre eux — un groupe peut employer un style et une machine l’autre sans surprise.

Le cas texte est honnête sur le fait d’être une concaténation plutôt que de prétendre le contraire : savoir si cela équivaut à une surcharge est l’affaire du format cible (la dernière réponse gagne en preseed ; pas toujours en kickstart).

L’arbre XML

format/xml.rs est un petit arbre construit à la main par-dessus quick-xml, parce qu’aucune crate généraliste ne préserve ce qu’un document de réponse a besoin de voir préservé.

Appariement. Les enfants sont appariés par nom d’élément plus un attribut discriminant :

const DISCRIMINATORS: [&str; 5] = ["name", "id", "key", "alias", "pass"];

C’est ce qui rend <component name="Microsoft-Windows-Shell-Setup"> et <settings pass="specialize"> fusionnables : surcharger une pass laisse les autres tranquilles.

Des frères répétés ne sont pas toujours une liste. Les traiter comme telle remplaçait chaque <component> d’un unattend.xml par celui que la surcouche mentionnait. S’ils portent un attribut discriminant, ce sont une collection indexée. Le config:type="list" d’AutoYaST est respecté pour le vrai cas des listes.

Fidélité. Déclarations, doctypes, espaces de noms et attributs survivent à une fusion. L’indentation d’origine et le placement des commentaires, non — la sortie est re-rendue, pas rustinée.

quick-xml émet les références d’entité comme leurs propres événements. Les ignorer soude les fragments de texte alentour : 1 &lt; 2 &amp; 3 revenait en 123. Les entités numériques sont résolues ; les inconnues sont refusées plutôt que silencieusement jetées.

Il ne comprend aucun schéma. check appelle xmllint là où il est installé, et c’est l’étendue de la garantie.

Clés de contrôle

pub const CONTROL_KEYS: [&str; 3] = ["extends", "members", "match"];
pub const XML_CONTROL_ELEMENT: &str = "answer-meta";
pub const TEXT_DIRECTIVE: &str = "answer:";

Elles voyagent dans ce que chaque format permet — clés natives de premier niveau dans les formats structurés, un élément <answer-meta> en XML, des directives # answer: (ou // answer:) en texte — et strip_control() les retire toutes avant l’envoi de la réponse.

Control est la forme parsée : extends: Option<String>, members: Vec<String>, matchers: BTreeMap<String, String>.

Templating

Deux règles, toutes deux porteuses :

La substitution se fait sur des valeurs de chaîne parsées, jamais sur le texte brut du document. La valeur entre dans le modèle de données du document et le sérialiseur du format l’écrit, donc c’est le sérialiseur qui échappe. Une valeur contenant un guillemet ne peut pas casser le TOML dans lequel elle atterrit ; une contenant < ne peut pas casser le XML. Un test fait passer a"b'c<d>e&f dans les quatre formats structurés et reparse la sortie.

Un fait manquant est une erreur, jamais une chaîne vide. Servir node-.example.com installe une machine avec un nom d’hôte cassé et personne ne le remarque avant plus tard. Les caractères de contrôle sont refusés pour la même classe de raison — un saut de ligne dans une valeur kickstart injecte une directive dans un fichier que l’installateur exécute.

Group::has_placeholders est pourquoi un groupe sans template ne coûte aucun parsing par requête : la chaîne préparée au chargement est servie telle quelle.

Les exemples travaillés font partie de la conception

examples/ porte un exemple commenté de chacune des treize extensions de la liste blanche, et

RESCRIPTUM_ANSWERS_DIR=examples cargo run -- check

les exerce tous. Gardez-le ainsi. Ils sont le seul endroit où les formats sont montrés en train de se composer ensemble, et deux d’entre eux — suse-node.autoyast et windows-node.unattend — sont ce qui a attrapé le doctype manquant et la pass non appariée.

Les stores

Les stores

Les réponses viennent soit d’un répertoire de documents (RESCRIPTUM_STORE=files, le défaut), soit d’une base SQLite (RESCRIPTUM_STORE=sqlite), au choix à l’exécution.

Le trait est délibérément mince

pub trait Store: Send + Sync {
    fn version(&self) -> Version;               // Option<String>, bon marché par requête
    fn snapshot(&self) -> io::Result<Snapshot>; // seulement quand la version a bougé
    fn describe(&self) -> String;
}

Un Snapshot, c’est du texte brut de documents et rien d’autre : RawMachine, RawGroup, RawDefault, portant chacun un identifiant, un format et un corps.

Chaque décision vit au-dessus de cela. Correspondance, chaînes extends, fusion, rendu, check — tout dans select.rs et merge.rs, partagé. Gardez-le ainsi : dès qu’un backend se met à décider du comportement, les deux divergent et la suite de conformité cesse de pouvoir prouver le contraire.

La moitié écriture est séparée, parce que servir des réponses n’en a jamais besoin :

pub trait StoreWrite: Store {
    fn put_machine(&self, id: &str, format: &str, body: &str) -> io::Result<()>;
    fn delete_machine(&self, id: &str, format: &str) -> io::Result<bool>;
    fn put_group(&self, name: &str, format: &str, body: &str) -> io::Result<()>;
    fn delete_group(&self, name: &str, format: &str) -> io::Result<bool>;
    fn put_default(&self, format: &str, body: &str) -> io::Result<()>;
    fn delete_default(&self, format: &str) -> io::Result<bool>;
}

Chaque opération nomme un format. Un document est indexé par ce à quoi il sert — une machine et un système d’exploitation.

Un put antérieur supprimait les autres formats d’un radical, pour éviter « deux réponses pour une machine ». C’était le mauvais modèle : ce sont les réponses de cette machine pour deux systèmes d’exploitation, et les deux sont censées exister. Voir pièges.

tests/stores.rs est la garantie

Chaque cas de comportement tourne deux fois, une par store, et affirme le résultat identique. 35 cas au dernier compte.

Un nouveau comportement a sa place là, pas dans un test propre à un store. Un test qui couvre un seul backend prouve la moitié de ce qu’il prétend — et la moitié qu’il ne couvre pas est exactement là où se cache une divergence.

Le store fichiers

Un répertoire par identité. Une machine est un répertoire nommé d’après elle, qui contient un document par format ; groups/ porte la même forme pour les groupes, et default/ les réponses de repli. Les deux noms sont réservés, donc une machine ne peut pas les revendiquer — valid_machine_id les refuse dans les deux stores, parce qu’une base qui en accepterait un exporterait vers un répertoire incapable de le contenir.

Dans un répertoire, l’extension est le format et le radical n’est rien du tout. C’est cette règle qui fait de deux documents d’un même format dans un même répertoire un problème signalé plutôt que tranché : il n’existe aucun départage qu’un administrateur aurait pu prévoir. L’ordre trié décide lequel des deux répond, pour que le choix ne dépende au moins pas de readdir — et le perdant est nommé dans problems().

Un document servable laissé à la racine du répertoire de réponses — l’agencement d’avant — est signalé et non servi, avec sa destination explicitée. Lire à moitié un ancien agencement signifierait une machine dont la réponse se déplace silencieusement entre deux fichiers. pending_moves() expose la même connaissance pour migrate, pour que la commande et le lecteur ne puissent pas diverger sur l’endroit où va un document.

version() est le mtime du répertoire :

fs::metadata(&self.dir).ok()
    .and_then(|m| m.modified().ok())
    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
    .map(|d| d.as_nanos().to_string())

Un stat remplace tout un parcours de répertoire — voir le cache du listing.

Le mtime du répertoire bouge quand une entrée y est ajoutée ou supprimée, pas quand l’une est éditée, ni quand quelque chose change un niveau plus bas. Le répertoire entier d’une machine qui apparaît ou disparaît est donc vu immédiatement, tandis qu’un document ajouté ou modifié à l’intérieur de l’un d’eux attend le filet de rechargement d’une seconde — celui qui couvrait déjà un fichier édité sur place. Un test unitaire épingle chacune des deux moitiés.

Ce que l’agencement coûte à la lecture. Un rechargement complet est désormais un readdir par identité en plus du fichier qu’il ouvrait déjà. Mesuré à 2 000 machines sur un M1 Pro : 28 ms à plat, 63 ms avec un répertoire chacune. C’est amorti sur une seconde de requêtes, et le débit de bout en bout n’a pas bougé de façon mesurable — mais c’est un vrai facteur 2,2 sur la seule opération dont le filet garantit qu’elle tournera chaque seconde, et c’est la raison de préférer un groupe à un répertoire par machine.

Les écritures passent par un fichier temporaire plus un rename, atomique dans un répertoire sur POSIX, pour qu’un lecteur ne rencontre jamais une réponse à moitié écrite. Le nom temporaire porte l’identifiant du processus, et il est supprimé si le rename échoue. Un test affirme qu’aucun fichier .tmp ne survit.

La lecture utilise DirEntry::file_type(), pas fs::metadata. Le type de fichier revient gratuitement avec le readdir sur Unix ; seul un lien symbolique a besoin du stat pour être résolu. Cela seul valait 65 % à 2 000 fichiers, avant que le cache ne soit ajouté.

Le store SQLite

rusqlite avec la feature bundled — SQLite est compilé depuis les sources dans le binaire, donc il n’y a rien à installer. Il se compile en croisé vers armv7-musl sous zigbuild ; la CI construit cette cible à chaque push précisément parce que c’est là qu’une dépendance C casse en premier.

Mode WAL, pour que l’API d’administration ne bloque jamais une installation en cours.

version() lit un atomique en mémoire, pas la base :

Some(self.revision.load(Ordering::Relaxed).to_string())

Elle est appelée à chaque requête, et une requête SQL par requête HTTP annulerait l’intérêt du cache. La conséquence est qu’un changement fait par un autre processus ne la bouge pas — c’est le filet de rechargement qui l’attrape.

Les versions de schéma vivent dans PRAGMA user_version. Il n’y en a qu’une, et rien n’a été publié sous une plus ancienne, donc migrate() n’a aucune étape : il refuse une base venue du futur, crée le schéma quand la version vaut 0, et l’estampille. Les formes par lesquelles ce schéma est passé pendant son écriture ne sont jamais sorties du dépôt, et porter des migrations depuis elles reviendrait à porter du code qui ne peut pas s’exécuter.

Ce à quoi sert la version, c’est le sens du retour arrière :

database schema is version 2, this binary understands 1

Refusé plutôt que deviné, parce qu’une base écrite par un binaire plus récent peut porter des colonnes que celui-ci ignorerait silencieusement — et ignorer silencieusement une partie d’un jeu de réponses, c’est ainsi qu’une machine s’installe de travers.

import / export

$ rescriptum import <dir>    # répertoire → store configuré
$ rescriptum export <dir>    # store configuré → répertoire

Les deux passent par Snapshot, donc ils partagent toutes les règles. L’aller-retour est identique octet pour octet — importez un répertoire, réexportez-le, diff -r ne signale rien, chemins compris. Un test compare les deux côtés au même chemin pour exactement cette raison : export écrivant un document là où import n’irait pas le chercher est ce qui rendrait la base dangereuse à quitter.

Les identifiants deviennent des noms de répertoires

pub fn valid_id(id: &str) -> bool           // lettres, chiffres, - _ . : et aucun séparateur
pub fn valid_machine_id(id: &str) -> bool   // …et ni `groups` ni `default`

Imposé à la frontière de l’API d’administration et dans les deux stores. Le store est la couche qui transforme un identifiant en chemin, donc c’est la couche qui ne doit pas être trompée — ne vérifier qu’à la frontière ferait dépendre le garde-fou du fait que tout appelant futur s’en souvienne.

valid_format est l’équivalent pour les extensions : un document dans un format que personne ne peut lire n’atteint jamais le store.

La feature cargo sqlite

Activée par défaut, et retirable :

BuildTaille ARMv7
par défaut2 103 456 octets
--no-default-features944 928 octets

La retirer retire aussi l’API d’administration, qui a besoin de la base. La CI construit --release --no-default-features à chaque push pour que le plus petit build ne pourrisse pas sans qu’on le remarque.

Internes de l'API d'administration

Internes de l’API d’administration

src/admin.rs, activée seulement par RESCRIPTUM_ADMIN_ADDR, et seulement au-dessus de SQLite. Trois propriétés sont porteuses — un changement qui en supprime discrètement une est une régression.

1. Son propre listener

L’endpoint de réponse n’est pas authentifié par nécessité : l’installateur n’a aucun identifiant à offrir. Cette API décide du mot de passe root et des clés SSH de chaque machine installée ensuite. Elle ne partage jamais ce port.

Config::validate refuse de démarrer — en erreur, pas en avertissement — sans jeton, avec un jeton de moins de 16 caractères, ou au-dessus du store fichiers. Ces vérifications tournent avant que le listener ne soit bindé, donc une mauvaise configuration n’est jamais brièvement en ligne.

2. SQLite uniquement

Au-dessus d’un répertoire de fichiers il y aurait deux façons de changer la même configuration, à la main et par le réseau, en concurrence l’une avec l’autre.

3. L’écriture qui ne peut pas casser le parc

fn guarded(admin, kind, id, format, body) -> Response<Body> {
    let before   = admin.answers.problems()?;   // instantané des dégâts
    let previous = admin.store.snapshot()?…;    // ce qui était là, pour pouvoir le restaurer
    let existed  = apply(…)?;                   // put ou delete
    let after    = admin.answers.problems();
    let introduced = after.filter(|p| !before.contains(p));
    if !introduced.is_empty() { restore(previous); return 409 }
    200 avec `problems: before`
}
  • Seuls les problèmes nouvellement introduits déclenchent l’annulation. Un store déjà cassé reste éditable — sinon un mauvais état serait impossible à réparer par l’API qui l’a causé.
  • Une écriture réussie signale quand même les problèmes préexistants, pour qu’une réponse propre n’implique jamais que tout le jeu est sain.
  • C’est pourquoi un extends de machine pointant sur un groupe manquant est détecté au chargement, dans select.rs, plutôt qu’au moment où cette machine demande. Le garde-fou ne peut attraper que ce que problems() signale. Ajouter une nouvelle classe de casse veut dire l’ajouter là, sinon le garde-fou cesse silencieusement de la couvrir.

Les documents malformés sont refusés à l’écriture (400) plutôt que de devenir un 500 la prochaine fois qu’une machine en demande un.

Authentification

Comparaison en temps constant. Un == ordinaire retourne dès le premier octet différent, ce qui fuit le jeton octet par octet à qui chronomètre les réponses — quelques milliers de requêtes plutôt qu’un nombre impossible. Comparer tous les octets quoi qu’il arrive supprime le signal. Sur un réseau le timing se perd généralement dans la gigue, donc c’est une précaution ; ça coûte cinq lignes.

AuthGuard exclut une adresse après des échecs répétés :

ConstanteValeur
MAX_FAILURES5
FAILURE_WINDOW60 s
BASE_BLOCK60 s, doublant à chaque récidive
MAX_BLOCK900 s
MAX_TRACKED4096 adresses

Trois détails qui ne sont pas des accidents :

  • Le blocage s’applique aussi à un jeton correct. Sinon deviner jusqu’à tomber juste ne coûterait rien.
  • MAX_TRACKED est borné, pour que le garde-fou ne puisse pas lui-même être transformé en fuite de mémoire par un attaquant qui fait tourner ses adresses sources.
  • GET /health est vérifié avant le garde-fou et avant l’auth, pour que la supervision ne s’éteigne pas pendant une attaque.

Un blocage répond 429 avec Retry-After.

Traitement des requêtes

let segments: Vec<&str> = path.trim_matches('/').split('/').collect();
match (&method, segments.as_slice()) {
    (&Method::GET,    ["machines"])      => list(…),
    (&Method::GET,    ["resolve", id])   => resolve(…),
    (&Method::PUT,    ["groups", id])    => put(…).await,

    _ => error(NOT_FOUND, "no such endpoint"),
}

?format= choisit l’extension du document, toml par défaut — ce que ce serveur servait à ses débuts.

Lire le corps de la requête avant de la rejeter. Répondre et fermer pendant que le client écrit encore lui vaut un ECONNRESET au lieu de la réponse. put() draine d’abord, puis valide l’identifiant.

Chaque réponse d’administration doit définir Connection: close. Sans cela, chaque client de test attendait l’expiration de la connexion — la suite prenait 30 s au lieu de 0,4 s — et la coupure finale arrivait parfois comme un reset plutôt qu’un EOF propre.

GET /resolve définit X-Answer-Source avec la même description que la ligne de log.

GET /resolve/{id} ignore l’identifiant du chemin quand une query string est présente — les faits viennent de la query seule, ce qui permet de répéter une vraie requête. Cela rend ?format=toml sur cet endpoint activement faux : il ne résout rien. Documenté dans le guide.

Identifiants

valid_id — lettres, chiffres, - _ . :, aucun séparateur de chemin — est imposé à la frontière de l’API et dans les deux stores. export retransforme les identifiants en noms de fichiers, donc tout ce qui pourrait traverser un répertoire doit être rejeté dans la couche qui construit le chemin, pas seulement dans celle qui l’a reçu.

Connu et accepté

  • La limitation par adresse n’arrête pas un attaquant disposant de nombreuses adresses. C’est la longueur du jeton qui rend la devinette sans espoir — d’où le plancher de 16 caractères au démarrage.
  • Elle parle HTTP en clair. Mettez du TLS devant si elle quitte la boucle locale.
  • Binder au-delà de la boucle locale journalise un avertissement plutôt que de refuser, parce qu’un réseau d’administration est un choix légitime.

Tests

tests/admin.rs (15 cas) couvre le routage, l’annulation, la validation des identifiants et les codes de statut. tests/guards.rs (5) couvre l’arithmétique du verrouillage et le fait que /health reste joignable à travers.

Tests

Tests

619 tests. cargo test les fait tous tourner en une vingtaine de secondes — dont l’essentiel dans tests/tftp.rs, qui attend de vrais délais UDP parce que c’est précisément ce qu’il teste.

cargo test ne lance pas les bancs qui comptent le plus : le banc de démarrage, les trois du paquet DSM, et la construction des chargeurs. Voir Le paquet aussi est testé et le banc de démarrage.

cargo test                                # tout
cargo test <nom>                          # un seul, par sous-chaîne
cargo test -- --nocapture                 # afficher stdout
cargo test --all-features                 # ce que lance la CI

Où un test a sa place

SuiteCasPour
tests/integration.rs52le vrai binaire sur une vraie socket
tests/cli.rs65render, check, import, export, config et le fichier d’environnement — contre le vrai binaire
tests/media.rs45les médias de démarrage contre le vrai binaire, les deux listeners debout
src/config.rs56l’environnement, ce qui refuse de démarrer, et qui l’emporte du fichier ou de l’environnement
tests/stores.rs48chaque comportement, contre les deux stores
tests/tftp.rs30le TFTP sur de l’UDP réel : les tours de parole, et ce qu’une liaison ratée ne doit pas coûter
src/select.rs29normalisation, scoring, superposition, remplissage de templates
src/format/mod.rs28parsing, fusion, clés de contrôle, alias d’endpoint
tests/admin.rs26l’API d’administration de bout en bout, formats compris
src/envfile.rs23le parseur et l’écrivain du fichier d’environnement, et ce que chacun refuse
src/facts.rs22parsing de query, aplatissement JSON, globbing
src/format/xml.rs18l’arbre XML — appariement, entités, fidélité
src/merge.rs11la fusion profonde TOML
tests/guards.rs7le jeton de réponse, et le verrouillage qui délibérément n’existe pas
src/installed.rs6une machine qui signale son installation, et ce qu’il ne faut jamais désarmer
src/log.rs15lecture des niveaux, et l’arithmétique d’horodatage
src/boot/*.rs128le lecteur ISO, le repérage, le catalogue, les sources d’images, les plans de patch, le menu, la table des chargeurs, les extraits DHCP, cpio et SHA-256
src/admin.rs, src/capture.rs, src/store/mod.rs21comportement unitaire

tests/common/mod.rs — les fixtures que toutes les suites partagent

Les réponses sont stockées à raison d’un répertoire par identité, donc une fixture ne peut plus être un simple nom de fichier. seed() prend le nom dans lequel un test pense — 98fa9b50d810.toml, groups/rack-a.toml, default.toml — et l’écrit via StoreWrite, si bien qu’elle atterrit exactement là où une écriture de l’API d’administration la mettrait et ne peut pas diverger de l’agencement. Un nom que le store refuserait (une extension que personne ne sert) est écrit littéralement, parce que ces fixtures existent justement pour prouver qu’un fichier égaré ne répond à rien.

Une seule copie, pas une par suite — le même raisonnement qui fait de loaders.rs une table unique lue par TFTP et par l’extrait DHCP. Quatre copies d’une correspondance sont quatre occasions qu’une fixture atterrisse là où le serveur ne regarde pas, et un test qui ne sème rien passe pour la mauvaise raison.

tests/stores.rs — la suite de conformité

Chaque cas de comportement tourne deux fois, une par store, et affirme le résultat identique. Cette suite est ce qui empêche deux backends de diverger.

Un nouveau comportement a sa place là, pas dans un test propre à un store. Un test qui couvre un seul backend prouve la moitié de ce qu’il prétend — et la moitié qu’il ne couvre pas est exactement là où se cache une divergence.

tests/cli.rs — les commandes qu’on dit aux gens de lancer

check est ce que deploy.sh lance avant d’expédier quoi que ce soit, et ce que la documentation dit de mettre en CI — donc son code de sortie est un contrat, pas une commodité. La séparation stdout/stderr de render en est un autre : le document part sur stdout pour que render … > answer.toml donne un fichier utilisable, et la ligne de provenance part sur stderr pour qu’elle ne s’y retrouve pas.

Également épinglé ici : l’aller-retour importexport est identique octet pour octet, commentaires et mise en forme compris. C’est ce qui rend la base sûre à adopter et sûre à quitter ; si cela cesse d’être exact, export n’est plus une porte de sortie.

tests/integration.rs — contre le vrai binaire

Il démarre le binaire réel sur un port éphémère et lui parle en HTTP. Le binaire affiche l’adresse qu’il a bindée, donc il n’y a ni course sur le port ni « on dort et on espère ».

Cette suite existe parce que certains échecs sont invisibles aux tests unitaires. L’exemple le plus clair : hyper panique à l’exécution si header_read_timeout est défini sans .timer(…). Ça compile. Seule une vraie connexion le trouve.

Explicitement couverts :

  • une requête tronquée, et une sans Content-Length ;
  • un Content-Length aberrant — et un corps chunked qui dépasse le plafond en cours de route, l’autre chemin d’entrée, où la limite saute en pleine lecture ;
  • une méthode inconnue, un corps vide, un corps de 1 Mo, un corps qui n’est pas de l’UTF-8 valide ;
  • le plafond de connexions : au-delà, un 503 immédiat plutôt qu’une file — et le permis qui revient ensuite ;
  • et, après chacun de ces cas, que le serveur répond encore. C’est cette dernière assertion qui compte — la maltraitance n’a d’intérêt que si le serveur y survit.

cargo test ne reconstruit pas target/debug/rescriptum. Une vérification manuelle contre un binaire périmé a un jour « reproduit » un bug déjà corrigé. Reconstruisez avant de triturer le binaire à la main.

tests/tftp.rs — un transfert est une conversation

Rien ici ne se prouve depuis l’intérieur d’une fonction. Les blocs, les acquittements, la retransmission, le paquet vide qui termine un transfert — chaque bug qui vaut d’être attrapé vit dans les tours de parole, et la première exécution en a trouvé deux, du genre « marche à la main, jamais après un redémarrage ». Un fichier dont la longueur est un multiple exact de la taille de bloc doit se terminer par un paquet de données vide ; sans lui le client attend éternellement un dernier bloc qui ne vient jamais.

Cette suite porte aussi le seul écouteur de ce serveur dont l’échec n’est pas fatal. Un port TFTP qu’on ne peut pas lier ne doit pas emporter les réponses et les médias avec lui — mesuré sur DSM, où la capacité est accordée hors du paquet et où une mise à jour la perd — donc le test squatte le port, puis vérifie trois choses d’un coup : le serveur est monté, il a averti en disant ce qui marche encore, et boot check sort toujours en non-zéro.

Cette dernière assertion est d’abord passée pour la mauvaise raison : trois chargeurs manquants faisaient déjà échouer la commande. Le montage écrit maintenant tous les chargeurs que la table nomme, et une exécution témoin avec le TFTP coupé prouve que le répertoire est propre par ailleurs.

tests/media.rs — les médias de démarrage contre le vrai binaire

Les deux listeners debout, et chaque cas d’abus se termine en prouvant que le serveur répond toujours. Un cas prouve la propriété pour laquelle la socket séparée existe : les réponses continuent d’aboutir pendant que quatre transferts d’image sont en cours.

Il n’y a délibérément aucune ISO binaire dans ce dépôt. boot::iso::build écrit des images en mémoire, derrière la fonctionnalité test-support, pour qu’elle n’atteigne jamais un binaire de release.

La chaîne de démarrage a sa place dans le banc

packaging/boot-rig/run.sh n’est pas du Rust et cargo test ne le lance pas. Il démarre une machine revendiquée et une non revendiquée dans QEMU sous TCG, sur un pont privé sans lien montant, et vérifie quatre marqueurs : la passe DHCP a répondu depuis notre propre extrait généré, un chargeur a été récupéré en TFTP, la machine non revendiquée est retombée sur son disque local, et la machine revendiquée a atteint sa propre réponse. La CI fait la même chose plus une casse délibérée.

Un invité QEMU ponté dans un conteneur a une MAC à lui, et le commutateur virtuel de Docker Desktop ne transmet pas les trames d’une MAC qu’il n’a pas attribuée — mesuré, d’où un banc principal en un seul conteneur plutôt qu’en quatre sur un réseau Docker.

Vérifier qu’un test peut échouer

Un test qui passe pour la mauvaise raison est pire que pas de test : il annonce une couverture qui n’existe pas. Avant de faire confiance à un nouveau test, cassez ce qu’il garde et regardez-le rougir.

Un test de cette suite n’a pas survécu à cette vérification. Il prétendait protéger la clause version.is_some() du cache du listing ; en la retirant, il restait vert — parce qu’avec l’un comme l’autre store, une version n’est illisible que lorsque le store est aussi vide, si bien que la clause ne peut pas se déclencher. Le test prouve quelque chose de réel — un répertoire qui apparaît après le démarrage est servi dès la requête suivante — et le dit maintenant.

Assertions à copier

  • Affirmez sur des valeurs parsées, pas sur la mise en forme. Remplacer une table par un scalaire laisse la décoration d’origine de la clé, donc la sortie peut se lire value= 3 — du TOML valide, un texte différent. Une comparaison de chaînes échoue là pour la mauvaise raison, ou passe pour une mauvaise raison.
  • Les tests d’invalidation de cache doivent partager une seule instance d’Answers. Un test qui en construit une nouvelle à chaque appel contourne complètement le cache et ne prouve silencieusement rien.
  • Config::from_lookup prend une closure, pour que les tests de configuration ne touchent jamais l’environnement du processus — et ne se courent donc jamais après sous un runner parallèle.
  • Vérifiez que l’ancien texte a bien été trouvé avant d’écrire. Deux patchs python/sed dans l’histoire de ce projet n’ont silencieusement rien matché et n’ont été attrapés qu’en vérifiant le nombre de tests ensuite.

Les exemples de réponses sont aussi un test

RESCRIPTUM_ANSWERS_DIR=examples cargo run -- check

examples/ contient un exemple travaillé de chaque format, et c’est le seul endroit où ils sont montrés en train de se composer ensemble. Deux d’entre eux ont attrapé de vrais bugs — un doctype manquant et un attribut pass non apparié. Gardez-les fonctionnels.

Le paquet aussi est testé, à trois endroits

cargo test ne touche pas au paquet DSM, parce que rien là-dedans n’est du Rust. Trois harnais s’en chargent, et chacun prouve ce que les autres ne peuvent pas.

ProuveCoût
packaging/dsm/check-spk.shl’archive est structurellement ce que DSM attend — tar externe non compressé, les six champs d’INFO, une version tout en segments numériques, os_min_ver au moins 7.1, icônes 64×64 et 256×256, scripts exécutables sans CRLF, le --version du binaire empaqueté, et l’application de bureau : un dsmappname nommant une classe que son ui/config déclare vraiment, un nom de fichier JavaScript qui porte la version, et un backend qui vérifie toujours la session DSM et administratorsdes secondes, à chaque push
packaging/dsm/lifecycle-test.shtout ce que les scripts du paquet décident, contre un faux arbre /var/packages : le fichier d’environnement écrit une fois et une seule, les valeurs de l’assistant et leur absence, le service qui survit à son propre script de démarrage et répond à /health, les codes de sortie que lit Package Center, une mise à jour qui ne doit pas toucher une configuration éditée à la main, une désinstallation qui ne doit pas toucher aux réponses — et le backend de l’application de bureau, piloté avec un authentificateur bouchonné : refuser l’absence de session, refuser un non-administrateur, refuser une écriture sans en-tête d’intention, refuser celle qui empêcherait le serveur de démarrer, et ne jamais livrer un jeton au navigateurdes secondes, à chaque push
packaging/dsm/vm/on-dsm.shla machinerie propre à DSM — le worker data-share et son ACL, le worker port-config, l’unité systemd générée, logrotate contre un descripteur vivant, si Package Center accepte l’archive — et qu’une machine qui demande sa configuration en reçoit une : un POST avec le matériel dans le corps, auquel répond le fichier de cette machine fusionné par-dessus le groupe qui la revendique. Elle porte aussi la seule route vers le port 69 et la capacité de ce NAS à atteindre l’index d’un éditeur : que 69/udp survive dans l’entrée de pare-feu acquise, que le paquet réponde encore sans la capacité, et que setcap cap_net_bind_service=+ep puis un redémarrage lient udp/69 sous le processus non privilégié du paquetdes minutes, sur une VM DSM 7 — puis sur le DS416j
packaging/dsm/lifecycle-test.sh                     # le premier .spk de dist/ qui tourne ici
docker compose -f packaging/dsm/vm/docker-compose.yml up -d   # une machine DSM 7.2
packaging/dsm/vm/on-dsm.sh admin@<hôte> -p 2222     # contre elle
packaging/dsm/vm/on-dsm.sh admin@nas                # le verdict

La VM, c’est vdsm/virtual-dsm, qui installe la Virtual DSM officielle de Synology — aucune image de loader à trouver. KVM la rend rapide, pas possible : sans /dev/kvm elle émule, dix fois plus lentement, et c’est à ça que sert docker-compose.emulated.yml. En revanche elle veut 14 Gio libres pour son stockage, en dur dans l’image.

Le dernier est destructeur exprès — il met à jour par-dessus un fichier d’environnement édité à la main et un canary dans le dossier partagé, puis désinstalle, puis vérifie que les deux ont survécu. Ces deux gardes sont les choses les plus coûteuses à rater dans ce paquet, et le premier .spk publié est celui dont les scripts de désinstallation tourneront pendant la première mise à jour de tout le monde. packaging/dsm/vm/README.md décrit le banc d’essai : ce dont il est une preuve, et ce dont il ne l’est pas.

La même règle que partout ailleurs vaut pour eux : cassez ce qu’ils gardent et regardez-les virer au rouge. Annuler la garde de postinst à la mise à jour, faire supprimer le partage par postuninst, renvoyer 1 pour un paquet arrêté et refuser prestart transforme 33 vérifications vertes en 25 vertes et 8 rouges — c’est ainsi qu’on sait que le harnais teste quelque chose. Aujourd’hui c’est 85 vérifications dans lifecycle-test.sh, 28 dans check-spk.sh et 52 sur la machine ; les trois dernières ajoutées ont chacune été vues rouges de la même façon — en remettant RESCRIPTUM_TFTP_ADDR=off, en supprimant le rapport du panneau sur l’état du TFTP, et en lui faisant prétendre qu’il livre alors que rien n’est lié.

CI

.github/workflows/ci.yml, à chaque push sur main et develop et à chaque pull request :

JobLance
gatescargo fmt --all --check, cargo clippy --all-targets --all-features -D warnings, cargo test --all-features, cargo build --release --no-default-features
docsconstruit le site public et lance notabene lint
auditcargo audit --deny warnings sur l’arbre de dépendances
crossun build ARMv7-musl complet, puis affirme que le binaire est bien statically linked, puis assemble les deux .spk, les contrôle structurellement et déroule le cycle de vie du paquet

Le job cross n’est pas redondant. SQLite est compilé depuis les sources, et armv7-musl est la cible la moins indulgente qui soit livrée — c’est là qu’une dépendance C casse en premier. L’attraper sur un push vaut mieux que l’attraper en train de faire une release.

Le job audit est l’autre moitié de la règle « ajouter une dépendance exige une raison » : une raison de l’ajouter n’est pas une raison de la garder. --deny warnings échoue aussi sur un crate non maintenu ou yanké, pas seulement sur une vulnérabilité. Quand quelque chose apparaît sans correctif, ajoutez --ignore RUSTSEC-… avec une ligne expliquant pourquoi, plutôt que de retirer le drapeau.

Chaque action utilisée est une action officielle actions/*, et Zig comme cargo-audit sont installés directement plutôt que via une action tierce. C’est délibéré : cette toolchain vérifie et lie un binaire que des gens font tourner en root.

Le site de documentation a son propre garde-fou — voir le site de documentation.

Construire

Construire

./build.sh                    # cette machine, et affiche la taille
./build.sh --all              # toutes les cibles qu'une release livre
./build.sh --no-sqlite        # le plus petit binaire
./build.sh armv7-unknown-linux-gnueabihf
./build.sh --help

build.sh ajoute une cible Rust manquante pour vous et avertit si un build musl est sorti lié dynamiquement — ce que DSM refuserait de lancer, au moment de l’exec sur le NAS plutôt qu’au build sur votre portable.

cargo build tout court fonctionne aussi ; build.sh existe pour le rapport de taille et cet avertissement.

Les cibles de release

CiblePourCroisé
armv7-unknown-linux-gnueabihfle DS416j, la raison d’être du projet — glibc, pas musl, voir plus baszigbuild, plancher 2.17
aarch64-unknown-linux-muslNAS ARM récents, Raspberry Pizigbuild
x86_64-unknown-linux-muslla plupart des autres hôtes Linuxzigbuild
aarch64-apple-darwindéveloppement localnatif
x86_64-apple-darwindéveloppement localnatif

Compilation croisée

cargo-zigbuild utilise Zig comme éditeur de liens, ce qui évite une toolchain croisée complète par cible :

cargo install cargo-zigbuild
cargo zigbuild --release --target armv7-unknown-linux-gnueabihf.2.17

Pourquoi armv7 est la seule cible qui ne soit pas musl

Toutes les autres cibles sont en musl statique. ARMv7 est en glibc, et ce n’est pas une préférence : c’est la seule façon dont la machine qui justifie ce projet exécute le binaire.

Les noyaux ARMv7 de Synology sont des 3.10, et ils répondent EINVAL aux appels système time64 là où on attendrait ENOSYS. musl 1.2 a fait passer time_t à 64 bits sur les architectures 32 bits et tente d’abord clock_gettime64 (ainsi que clock_nanosleep et le futex temporisé), avec un repli sur l’appel 32 bits conditionné à ENOSYS. Sur un noyau qui dit EINVAL, le repli n’arrive jamais et toute demande d’heure échoue. Mesuré sur un DS416j en DSM 7.1, noyau 3.10.108 :

$ ./probe
libc clock_gettime(CLOCK_REALTIME)  -> -1  errno=22 (Invalid argument)
syscall 263 (time32)                -> 0   ok
syscall 403 (time64)                -> -1  errno=22 (Invalid argument)

Le symptôme : un binaire qui répond à --version puis panique dès qu’il veut un horodatage — time.rs:131, Os { code: 22, kind: InvalidInput }. Ce n’est ni un problème d’ABI ni un noyau trop vieux pour les instructions, ce à quoi ça ressemble pourtant.

La glibc, en 32 bits, utilise les appels time32, et DSM fournit la sienne (2.20 sur armada38x). Le build armv7 vise donc un plancher glibc 2.17 — assez bas pour DSM, et comme la glibc est rétrocompatible, le même binaire tourne aussi sur un Linux ARMv7 récent.

Ce qu’il faut vérifier n’est donc plus qu’il est statique, mais qu’il ne réclame aucune glibc plus récente que le plancher. Plus récent échoue au moment de l’exec, sur le NAS, en nommant une version de symbole et rien d’autre :

$ readelf --dyn-syms target/armv7-unknown-linux-gnueabihf/release/rescriptum \
    | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1
GLIBC_2.17

La CI l’affirme à chaque push. Les cibles musl, elles, restent vérifiées comme statiques, parce que pour elles c’est la promesse.

Installer Zig sur la machine du mainteneur

Zig n’est pas une installation Homebrew ici : brew install avorte sur cette machine à cause de taps tiers non fiables sans rapport avec Zig. Il vit dans ~/.local/zig, avec un lien symbolique dans ~/.local/bin/zig. Pour le mettre à jour, remplacez ce répertoirebrew upgrade zig ne fait rien.

Toolchain vérifiée : Rust 1.93, cargo-zigbuild 0.23.0, Zig 0.16.0, avec les cibles aarch64-apple-darwin et armv7-unknown-linux-gnueabihf installées.

Le profil release

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true

panic = "abort" est délibérément absent — voir contraintes. Coût mesuré du maintien du déroulement sur ARMv7 : +2416 octets, +0,8 %.

Taille

BuildARMv7
par défaut2 103 456 octets
--no-default-features (ni SQLite, ni API d’administration)944 928 octets

L’essentiel de la différence est SQLite embarqué, compilé depuis les sources. La CI construit --release --no-default-features à chaque push pour que le petit build ne pourrisse pas sans qu’on le remarque.

Features

FeatureDéfautApporte
sqliteactivéele store SQLite et l’API d’administration
cargo build --no-default-features          # le plus petit
cargo test --all-features                  # ce que lance la CI

Le paquet Synology

Un .spk est un format de release, pas un build : le binaire est fini avant que l’empaquetage commence, il n’y a pas de build spécifique à DSM, et rien dans src/ ne sait que Synology existe.

./build.sh --spk x86_64-unknown-linux-musl   # compiler, puis emballer
packaging/dsm/make-spk.sh armv7              # emballer un build qui existe déjà
packaging/dsm/check-spk.sh                   # contrôle structurel sur dist/*.spk

Le paquet embarque les chargeurs : construisez-les d’abord, sinon il ne passe pas son propre contrôle. make-spk.sh les prend dans packaging/ipxe/out (remplaçable par RESCRIPTUM_LOADERS), et check-spk.sh refuse un paquet qui n’en a pas — un serveur TFTP sans rien à distribuer ne démarre personne. Construire iPXE demande une chaîne C Linux, ce qui sur un Mac veut dire un conteneur :

docker run --rm --platform linux/amd64 -v "$PWD:/w" -w /w debian:bookworm-slim sh -c '
  apt-get update -qq &&
  apt-get install -y --no-install-recommends build-essential liblzma-dev mtools \
    xorriso isolinux gcc-aarch64-linux-gnu git ca-certificates perl &&
  packaging/ipxe/build.sh --out /w/packaging/ipxe/out'

Une fois, pas par paquet : les chargeurs sont les mêmes octets dans le .spk de chaque ABI, puisqu’ils tournent sur les machines démarrées, pas sur le NAS. packaging/ipxe/out est gitignoré — jamais de binaires dans git.

ABIarch dans INFODepuis
x86_64x86_64 — le nom de famille, donc toutes les plateformes Intelx86_64-unknown-linux-musl
armv7armada38x — le raccourci de famille n’atteint pas les plateformes Marvellarmv7-unknown-linux-gnueabihf
aarch64armv8aarch64-unknown-linux-musl, une fois le binaire lancé sur l’une d’elles

La règle pour élargir : revendiquer un ABI une fois le binaire lancé sur son membre au noyau le plus ancien, jamais parce qu’une plateforme est plausible.

make-spk.sh est déterministe — mtimes fixes, propriété 0:0, ustar, gzip -n, liste de fichiers pré-triée — donc les mêmes entrées donnent un .spk identique octet pour octet, ce qui est ce qui donne du sens à la somme publiée.

check-spk.sh tourne dans la CI à chaque push. Il vérifie que l’archive externe est un tar non compressé, que INFO a ses six champs obligatoires et une version tout en segments numériques, que les icônes font exactement 64×64 et 256×256, que les scripts de cycle de vie s’analysent et sont exécutables, et que le --version du binaire empaqueté correspond à INFO — le build x86_64 tourne sur le runner, donc cette dernière assertion est réelle et non une relecture de la même chaîne.

lifecycle-test.sh déroule ensuite les scripts du paquet contre un faux arbre /var/packages — installation, démarrage, /health, les codes de sortie, une mise à jour par-dessus une configuration éditée à la main, une désinstallation par-dessus un canary dans le partage — et tourne lui aussi à chaque push.

packaging/dsm/lifecycle-test.sh

Ce que rien de tout cela ne peut prouver, c’est que DSM acceptera le paquet ; seule une installation le peut. C’est le banc d’essai de packaging/dsm/vm/ : un lanceur QEMU, et un script qui joue les vérifications sur la machine — la VM pendant qu’on itère, le DS416j pour le verdict. Voir tests.

Déployer un build

./deploy.sh admin@nas
./deploy.sh admin@nas /volume1/netboot

Construit, vérifie les réponses et refuse d’expédier si elles ne reviennent pas propres, copie sous un nom temporaire, redémarre, et confirme /health. Voir déploiement.

EnvironnementDéfaut
TARGETarmv7-unknown-linux-gnueabihf
ANSWERS<répertoire-distant>/answers
PORT8000

Branches et releases

Branches et releases

Le modèle reprend délibérément celui du projet frère notabene — même mainteneur, mêmes attentes.

Branches

BrancheRègle
mainstable. Seuls les commits de release et les tags vX.Y.Z y atterrissent. Ne poussez jamais de travail de fonctionnalité directement
developintégration. Maintenue à la prochaine version en cours
feature/<nom>, fix/<nom>partent de develop, PR de retour vers develop
main ──●────────────────────────●─(tag vX.Y.Z)──▶  releases
        \                      /
develop  ●───●───●───●───●────●  ────────────────▶  garde-fous CI seulement, ne publie rien
          \     /   \       /
    feature/…  ●   fix/… ●          (PR vers develop)

develop ne publie rien. Elle lance les garde-fous — build, tests, clippy, fmt — et s’arrête là. Pas de préversions, pas d’artefacts. Les binaires sont produits uniquement par un tag vX.Y.Z sur main.

C’est la seule chose qui ne se reporte pas depuis notabene, qui est un paquet npm et publie des préversions sur un dist-tag @dev. Ce projet livre un binaire compilé, donc l’artefact de release est une GitHub Release avec des binaires compilés en croisé attachés, construits par une matrice CI.

Commits

Commits conventionnels avec un scope :

feat(http): answer GET as well as POST
fix(select): normalize member strings before comparing
chore: release v0.2.0

Gardez les PR ciblées. Ajouter une dépendance exige une raison dans le message de commit — ce binaire tourne en root sur le matériel d’autres gens.

Faire une release

# sur develop, tout étant vert
$EDITOR Cargo.toml          # monter la version
cargo build                 # rafraîchir Cargo.lock
git commit -am "chore: release vX.Y.Z"

git checkout main && git merge --no-ff develop
git tag -a vX.Y.Z -m "rescriptum vX.Y.Z"
git push origin main --follow-tags

.github/workflows/release.yml ensuite :

  1. Refuse le tag s’il diverge de Cargo.toml. Une release dont le binaire annonce une version différente de son tag est un problème de support qui survit à la release.
  2. Compile en croisé les cinq cibles publiées.
  3. Empaquette chacune en rescriptum-<version>-<cible>.tar.gz, avec README.md et LICENSE à côté du binaire, plus une somme SHA-256 — qui fait tourner cela en root devrait pouvoir vérifier ce qu’il a téléchargé.
  4. Construit les chargeurs iPXE marqués depuis le commit épinglé et les attache en rescriptum-boot-assets-<version>.tar.gz, après avoir demandé à boot check si le répertoire satisfait la table de chargeurs depuis laquelle le serveur distribue. Sans cela la release est incomplète, et silencieusement : un déploiement obtient un serveur TFTP sans rien à distribuer, et chaque machine que l’extrait DHCP généré envoie là demande un fichier, n’obtient rien, et s’arrête. C’est un téléchargement à part, jamais dans une archive binaire ni dans un .spk — c’est iPXE, en GPLv2, et des fichiers séparés servis à côté relèvent de la simple agrégation, avec packaging/ipxe/ pour offre écrite.
  5. Emballe les builds musl Linux en paquets Synology, rescriptum-<version>-<build>-<abi>.spk, et contrôle structurellement chacun avant qu’il puisse être publié.
  6. Crée la GitHub Release avec gh et --generate-notes, ou verse dedans si elle existe déjà.

Il est relançable à la main via workflow_dispatch avec un tag, pour quand un job échoue après que le tag est déjà poussé.

Un correctif d’empaquetage seul n’a pas besoin de tag. Les versions SPK sont faites de segments tous numériques et le dernier est un numéro de build de paquet, donc v0.1.0 donne 0.1.0-1 ; un déclenchement manuel avec spk_build: 2 attache rescriptum-0.1.0-2-<abi>.spk à la même Release. Une préversion ne produit aucun .spk — les archives sont le canal des préversions.

Un tag ne doit pas être la première fois qu’un .spk est installé sur une machine DSM. Le contrôle structurel attrape une archive cassée ; seul Package Center attrape un paquet cassé, et le premier publié est celui dont les scripts de désinstallation tourneront pendant la première mise à jour de tout le monde. La liste des vérifications est dans packaging/dsm/README.md.

Chaque action utilisée est une action officielle actions/*, et gh est déjà sur le runner. C’est délibéré, pour la même raison que tout le reste de cette page.

Versionnage

SemVer. Le tag est vX.Y.Z et doit correspondre exactement à Cargo.toml.

Les documents de réponse sont des données, pas de l’état : rien ne migre, et un nouveau binaire lit le même répertoire. L’exception est le schéma SQLite, qui porte un user_version — voir stores. Il n’y a qu’une version pour l’instant. En ajouter une seconde veut dire écrire l’étape de migration et une montée mineure au minimum, et les notes de version doivent le dire, parce qu’un binaire plus ancien refusera la base mise à jour plutôt que de la lire à moitié.

Documentation

Le site de documentation est publié depuis main, donc un changement de doc part avec la prochaine release — ou en lançant le workflow docs à la main (workflow_dispatch) quand il ne doit pas attendre.

Pièges déjà rencontrés

Pièges déjà rencontrés

Chacun de ceux-ci a coûté du temps réel. Aucun n’est évident à la seule lecture du code.

À l’exécution, pas à la compilation

hyper panique si un timeout est défini sans timer. http1::Builder::header_read_timeout exige .timer(TokioTimer::new()). Omettez-le et chaque connexion panique à l’exécution — cela ne casse pas la compilation. Les tests d’intégration l’ont attrapé ; des tests unitaires n’auraient pas pu.

header_read_timeout s’arrête à la fin des en-têtes. hyper n’a pas de délai de lecture de corps, donc un client qui promet un corps sans l’envoyer garerait une connexion indéfiniment. Le tokio::time::timeout sur la connexion entière dans connection() est ce qui couvre cela. Les deux sont nécessaires ; aucun n’est redondant.

hyper émet les noms d’en-tête en minuscules. C’est correct — ils sont insensibles à la casse — donc affirmez sur une copie en minuscules. Voir has_header dans les tests d’intégration.

Performance

fs::metadata par entrée de répertoire est un appel système stat chacun. DirEntry::file_type() revient gratuitement avec le readdir sur Unix ; seul un lien symbolique a besoin du stat pour être résolu. Cela seul valait 65 % à 2 000 fichiers, avant que le cache ne soit ajouté.

Éditer le contenu d’un fichier de groupe ne change aucun mtime de répertoire. Seul RELOAD_BACKSTOP (1 s) l’attrape, ce qui est pourquoi le filet n’est pas redondant avec la vérification du mtime. Un test d’intégration le couvre.

Un Content-Length aberrant doit être refusé depuis l’en-tête, pas en laissant Limited sauter après avoir tamponné un mégaoctet.

Fermer sur un pair qui écrit encore jette la réponse qu’on vient d’écrire. Le noyau envoie un reset, et le reset détruit les octets non lus — le client voit donc une connexion coupée, pas votre réponse. shed() avait exactement ce défaut : il écrivait son 503 et fermait aussitôt, si bien que l’installateur à qui il essayait de dire « réessaie » recevait un reset. Il draine maintenant brièvement d’abord, comme le faisait déjà le put() de l’API d’administration. Un test au plafond de connexions l’épingle.

  • macOS autorise un processus non privilégié à lier le port UDP 69 ; Linux non. Un test qui atteint l’adresse TFTP par défaut prend donc une branche différente sur chaque plateforme — boot check traite un port libre mais silencieux comme une note, et un port non liable comme un problème, ce qui est la bonne règle et exactement ce qui rend le test dépendant de la plateforme. Il passait en local et échouait en CI pour une raison sans rapport avec le changement. Tout test qui définit RESCRIPTUM_BOOT_DIR doit aussi définir RESCRIPTUM_TFTP_ADDR=off, sauf si la sonde est le sujet ; tests/tftp.rs couvre le port non liable sur un port haut.
  • Une branche développée entièrement hors ligne n’a jamais rencontré la CI. Celle-ci a accumulé 57 commits avant son premier push, et le premier run a échoué sur deux choses qu’aucune exécution locale ne pouvait voir : un clippy cinq versions plus récent que la toolchain locale épinglée, et une permission de port propre à Linux. Poussez assez tôt pour le découvrir, ou attendez-vous à le découvrir tard.

Sélection et formats

Un Mac qui édite le répertoire de réponses en SMB peut détourner la réponse d’une machine. macOS écrit un fichier AppleDouble ._<nom> à côté d’un fichier dont le système n’accepte pas les attributs étendus — ._proxmox.toml a une extension présente dans la liste. Avec un répertoire par identité, c’est pire que du temps des réponses à plat : c’est un second .toml dans un répertoire qui n’en accepte qu’un, et il se trie avant le vrai, si bien qu’une règle prenant le premier servirait un contenu binaire à toutes les requêtes. La machine qu’on configurait reçoit alors une erreur d’analyse au lieu de sa réponse. .DS_Store n’est inoffensif que par chance (son extension n’est pas dans la liste). Le store fichiers ignore toute entrée dont le nom commence par . ; trouvé sur un vrai NAS, pas en lisant quoi que ce soit.

Normaliser un motif de sélecteur retire * et ? à moins d’utiliser normalize_pattern — ce qui transforme chaque glob en littéral, en silence.

Dans un format texte, un placeholder à l’intérieur d’un commentaire reste un placeholder. Kind::Text est une chaîne opaque, donc la substitution parcourt tout le document — un {{ mac }} écrit dans un commentaire # pour expliquer le templating doit quand même se résoudre, et fait échouer check exactement comme un vrai. Trouvé en ajoutant les exemples travaillés .ipxe et .cfg.

Un GET n’a pas de corps, donc la botte de foin est vide. Les valeurs de query et les segments de chemin doivent l’alimenter aussi, sinon un document nommé d’après une MAC ne peut jamais répondre à une récupération de preseed ou de kickstart.

quick-xml émet les références d’entité comme leurs propres événements. Les ignorer soude les fragments de texte alentour : 1 &lt; 2 &amp; 3 revenait en 123.

Des frères XML répétés ne sont pas toujours une liste. S’ils portent un attribut discriminant, ce sont une collection indexée ; les traiter comme une liste remplaçait chaque <component> d’un unattend.xml par celui que la surcouche mentionnait.

Deux documents ayant le même radical ne sont pas des doublons. Un put antérieur supprimait les autres formats d’un radical pour éviter « deux réponses pour une machine ». C’était le mauvais modèle : ce sont les réponses de cette machine pour deux systèmes d’exploitation.

Filtrer les endpoints sur l’extension, pas sur le Kind. .ks et .preseed sont tous deux Kind::Text ; filtrer par famille laisserait un preseed répondre à /rhel/ks.

Un alias doit être assez spécifique pour que personne ne l’atteigne par accident. seed a été retiré comme alias d’endpoint : s=http://server/seed/ est une URL de seed NoCloud ordinaire, et elle sert du YAML.

L’API d’administration

Lire le corps de la requête avant de la rejeter. Répondre et fermer pendant que le client écrit encore lui vaut un ECONNRESET au lieu de la réponse. put() draine d’abord, puis valide l’identifiant.

Les réponses d’administration doivent définir Connection: close. Sans cela, chaque client de test attendait l’expiration de la connexion — la suite prenait 30 s au lieu de 0,4 s — et la coupure finale arrivait parfois comme un reset plutôt qu’un EOF propre.

Les identifiants deviennent des noms de fichiers. export et le store fichiers construisent des chemins à partir d’identifiants de machine et de noms de groupe, donc valid_id est imposé à la frontière de l’API et dans les deux stores.

Tests

cargo test ne reconstruit pas target/debug/rescriptum. Une vérification manuelle contre un binaire périmé a un jour « reproduit » un bug déjà corrigé. Reconstruisez avant de triturer le binaire à la main.

Les tests d’invalidation de cache doivent partager une seule instance d’Answers. Un test qui en construit une nouvelle à chaque appel contourne complètement le cache et ne prouve silencieusement rien.

Affirmez sur des valeurs parsées, pas sur la mise en forme. Remplacer une table par un scalaire laisse la décoration d’origine de la clé, donc la sortie peut se lire value= 3 — du TOML valide, un texte différent.

Un patch python/sed qui « réussit » peut n’avoir rien matché. Deux éditions dans l’histoire de ce projet n’ont silencieusement rien fait et n’ont été attrapées qu’en vérifiant le nombre de tests ensuite. Vérifiez que l’ancien texte a été trouvé avant d’écrire.

Empaqueter pour DSM

Un script shell qui marche sur macOS n’est pas un script qui marche en CI. Deux cas trouvés en faisant tourner les harnais dans un conteneur Linux plutôt qu’en leur faisant confiance : stat -f '%Lp' est le drapeau de format sur BSD et statut du système de fichiers sur GNU — où il réussit, en déversant des informations d’overlayfs dans une variable censée contenir un mode de fichier, si bien que le repli ne se déclenche jamais. Demander d’abord à GNU (stat -c '%a' || stat -f '%Lp'), qui échoue proprement sur macOS. Et shasum est un script Perl qu’une Debian minimale n’a pas : sha256sum vient de coreutils et existe partout sur Linux. Les runners Ubuntu ont les deux, ce qui est exactement la façon dont un script pareil part cassé chez tous les autres.

musl 1.2 ne peut pas tourner sur les noyaux ARMv7 de Synology, et le symptôme ne nomme rien. Ces noyaux sont des 3.10 et répondent EINVAL aux appels time64 ; musl ne se replie sur les appels 32 bits que sur ENOSYS, donc clock_gettime, clock_nanosleep et le futex temporisé échouent tous. Le binaire s’installe, répond à --version, puis panique à time.rs:131 avec Os { code: 22, kind: InvalidInput } dès qu’il veut un horodatage — ce qui ressemble à un problème d’ABI ou de noyau trop vieux, et n’est ni l’un ni l’autre. La cible armv7 est en glibc avec un plancher 2.17 pour cette raison ; les cibles 64 bits n’ont pas le clivage time32/time64 et ne sont pas concernées. Prouvé par une sonde C de dix lignes sur la machine, pas en lisant quoi que ce soit.

SYNOPKG_PKGDEST vaut /volume1/@appstore/<paquet>, pas /var/packages/<paquet>/target. Le second est un lien vers le premier, donc dirname "$SYNOPKG_PKGDEST" donne /volume1/@appstore et tout ce qu’on y accroche — etc/, var/, shares/ — atterrit là où rien ne lit. La racine du paquet est un chemin fixe. Ça coûte un service qui s’installe parfaitement et ne démarre jamais, et un harnais sur faux arbre ne peut pas l’attraper : dans un arbre qu’on a construit soi-même, dirname tombe juste par construction.

$SYNOPKG_TEMP_UPGRADE_FOLDER survit à la mise à jour qui l’a créé. Une installation neuve qui le lit y trouve la configuration d’une installation que l’utilisateur a supprimée, et la restaure en silence — jetons compris. La restauration doit exiger SYNOPKG_PKG_STATUS = UPGRADE.

etc/ et var/ survivent à une désinstallation. Ce sont des liens vers /volume1/@appconf/<pkg> et /volume1/@appdata/<pkg>, que DSM conserve. Le fichier d’environnement, jetons inclus, reste donc sur le volume après la disparition du paquet — ce que la documentation doit dire, et qui fait échouer le tour suivant d’un banc qui ne les efface pas, pour des raisons appartenant au précédent.

Un compte DSM portant le nom de l’utilisateur du paquet est détruit avec lui. Le username de conf/privilege crée un utilisateur système à l’installation ; un administrateur du même nom est masqué par lui puis supprimé à la désinstallation.

Le répertoire du pare-feu est /usr/local/etc/services.d/ — au pluriel. Le guide développeur dit service.d, qui n’existe pas. Le worker port-config acquiert après postinst, donc le port de l’assistant atteint bien l’entrée pare-feu dès l’installation.

port-config et usr-local-linker acquièrent quand le paquet est activé, pas quand postinst tourne : vérifiés plus tôt, ils sont toujours absents.

L’unité générée n’a pas de Restart=Type=oneshot, RemainAfterExit=yes, TimeoutStartSec=3600. DSM ne relance pas le processus s’il meurt.

postinst tourne aussi à une mise à jour, et il tourne avant postupgrade. Donc « le fichier d’environnement est absent » n’est pas la même question que « c’est une installation neuve » : sur une mise à jour où etc/ n’a pas survécu, y écrire les valeurs par défaut détruit le port et les jetons de l’utilisateur avant que la restauration ne tourne. postinst consulte $SYNOPKG_TEMP_UPGRADE_FOLDER avant de décider. Trouvé en simulant ce cas précis, pas en lisant la séquence documentée.

Les preuninst/postuninst de l’ancienne version tournent pendant une mise à jour. Tout ce qu’ils ont de destructeur tourne donc à chaque mise à jour — et le premier .spk publié est celui dont les scripts de désinstallation tourneront pendant la première mise à jour de tout le monde. Ils ne peuvent pas être corrigés après coup.

status qui renvoie 1 veut dire « planté, pidfile resté », pas « arrêté ». Un paquet proprement arrêté, c’est 3. Renvoyer 1 dit à Package Center que le service est mort.

prestart tourne au boot, et DSM l’appelle que vous l’ayez écrit ou non — precheckstartstop vaut "yes" par défaut. Un case qui sort non-zéro sur un verbe inconnu empêche le paquet de démarrer après un reboot pour toujours, avec un symptôme (« marche à la main, jamais après un reboot ») qui ressemble à tout sauf à un bras de case manquant.

Les scripts de cycle de vie ne sont pas root. run-as: package les gouverne, pas seulement le service — donc un chown hors de l’arbre du paquet, ou synopkghelper, échoue, possiblement en silence.

data-share tourne au démarrage du paquet, pas à l’installation, donc rien dans postinst ne peut supposer que le dossier partagé existe. Et un nom d’utilisateur qui ne correspond pas à sa liste de permissions crée le partage et l’accorde à personne, sans un mot.

Une strophe logrotate sans copytruncate arrête silencieusement la journalisation : log::init ouvre le fichier une fois et ne le rouvre jamais, donc une rotation déplace l’inode sous un serveur qui continue d’écrire dans un fichier sans nom.

Un .spk dont le tar externe est gzippé est rejeté avec « invalid file format » et rien de plus. Idem pour un qui embarque des membres ._ de macOS. check-spk.sh vérifie les deux.

Il existe exactement une route vers le port 69 sur DSM 7, et c’est setcap. Les quatre ont été essayées sur une machine 7.2.2 le 2026-08-27, parce que l’affirmation « DSM 7 n’autorise pas un paquet non signé à tourner en root » traînait dans CLAUDE.md depuis un moment sans mesure derrière — vraie, mais par chance.

RouteRésultat
"defaults": {"run-as": "root"} dans conf/privilegerefusée — erreur synopkg 319, invalid package privilege content, stage: install_failed
"ctrl-script": [{"action":"start","run-as":"root"}] — la forme qu’utilisent les paquets de Synology (FileStation, QuickConnect et StorageManager tous les trois)refusée, même erreur 319
cap_net_bind_service embarquée en attribut étendu security.capability dans package.tgzs’installe très bien — le format pax interne est accepté — mais Package Center supprime l’attribut, et getcap revient vide
setcap cap_net_bind_service=+ep sur le binaire installé, en root, après l’installationfonctionne ; le paquet ouvre alors udp/69 sous son propre utilisateur non privilégié, à côté de 8000 et 8001

net.ipv4.ip_unprivileged_port_start n’existe pas sur ce noyau, donc cette route est fermée aussi. /volume1 est en btrfs avec nodev mais pas nosuid, donc les capacités de fichier y fonctionnent bien, et /usr/bin/setcap existe en mode 0700.

Le root sur DSM 7 est conditionné au fait d’être un paquet Synology, et libsynopkg.so.1 le dit noir sur blanc. Lire ses chaînes sur une machine 7.2.2 transforme la mesure ci-dessus en explication. Un paquet qui ne passe pas le contrôle de signature (verifyPackageSignature vit dans la même bibliothèque) se voit refuser tout ceci :

Failed to pass privilege check, ctrl-script and executable section should not exist
Failed to pass privilege check, defaults should be provided and defaults.run-as should be package
Failed to pass privilege check, join-groupname should not contains admin group
Failed to pass privilege check, tool capabilities should not exist
Failed to pass privilege check, tool user should be package
Failed to pass privilege check, non-synology package should not use privilege migration

D’où le fait que FileStation, StorageManager, QuickConnect et SecureSignIn portent tous "ctrl-script": [{"action": "start", "run-as": "root"}] dans leur propre conf/privilege et que nous ne le pouvons pas : la forme est légale, c’est la signature qui la rend légale pour eux.

La ligne la plus importante est tool capabilities should not exist. Le format de privilège de DSM a un champ capabilities natif — documenté comme "capabilities": "cap_chown,cap_net_raw" sur une entrée tool depuis 7.0-40656, et SYNOPackageTool::Privilege::ChangeCapabilities est bien là dans la bibliothèque. Un paquet signé déclare cap_net_bind_service et n’a jamais besoin de setcap. Le mécanisme que nous voulons existe, est documenté, et nous est fermé.

Le guide développeur de Synology énonce la règle sans détour : « If you are developing a package with root privilege, you are not able to install that package unless it is signed by synology. » C’est donc leur signature, pas celle d’un éditeur tiers de confiance — ce qui tranche ce que la chaîne de la bibliothèque laissait ouvert. SynoCommunity a heurté le même mur (spksrc#4170, #4215).

Il existe un contournement documenté, et ce n’est pas une voie de distribution : un jeton de développement. On génère debug.dat depuis Centre d’assistance → Services d’assistance, on l’envoie à Synology, on reçoit un jeton signé, on le dépose dans /var/packages/syno_dev_token. Il n’est valable que sur le NAS qui a produit le debug.dat : livrer ainsi voudrait dire que chaque utilisateur fasse un aller-retour avec Synology avant de pouvoir installer. Un setcap est une commande locale, et c’est strictement mieux pour lui.

Conclusion, tranchée et non provisoire : le setcap manuel est le prix de ne pas être signé par Synology, et aucun changement d’empaquetage ne l’enlève. Si le paquet est un jour signé, l’étape manuelle et la tâche au démarrage sont remplacées par trois lignes dans conf/privilege.

setcap fonctionne aussi sur un DS416j, et ce n’était pas acquis. Les quatre routes vers le port 69 ont été mesurées sur une VM 7.2.2, qui est en x86_64 avec /volume1 en btrfs monté nodev mais pas nosuid — or un volume monté nosuid fait ignorer les capacités de fichier par le noyau, ce qui aurait fermé la dernière route ouverte sur la seule machine pour laquelle ce projet existe. Mesuré sur le DS416j (ARMv7, armada38x) : la capacité tient, le paquet ouvre udp/69 sous son utilisateur non privilégié, et boot check rapporte 0.0.0.0:69 handed over ipxe-arm64.efi — une vraie requête de lecture à laquelle on a répondu avec de vraies données.

La capacité appartient au fichier, donc une mise à jour la perd. Une nouvelle version remplace le binaire et la capacité part avec l’ancien — d’où la tâche au démarrage du Planificateur de tâches documentée par le paquet plutôt qu’une commande unique, et d’où le fait qu’un bind TFTP raté ne soit pas fatal : quand il l’était, cette mise à jour coupait aussi le point d’entrée des réponses.

Lier n’est pas un contrôle de santé, et cela prouve le contraire de ce qu’on croit. Un bind qui réussit sur le port TFTP signifie que personne n’écoute — l’état dégradé, pas l’état sain — et un bind qui échoue ne distingue pas ce serveur d’un autre service qui squatterait le port, puisque les deux donnent AddrInUse. boot check envoie donc une vraie requête de lecture et rapporte ce qu’obtiendrait une machine. Sa première version annonçait « already in use — that is this server, if it is running » et un test avec un squatteur sur le port a montré tout de suite que c’était une supposition.

Un nouveau réglage n’atteint jamais une installation qui existe déjà, sauf si quelque chose l’y met. Le fichier d’environnement vivant n’est écrit que s’il est absent — ce qui est correct, une mise à jour ne doit jamais remplacer le port et les jetons de quelqu’un par des valeurs par défaut — mais à lui seul cela rend une nouvelle fonctionnalité invisible pour toute installation antérieure. Le boot media est arrivé avec les dossiers créés, les chargeurs déposés et 69/udp enregistré au pare-feu, et RESCRIPTUM_BOOT_DIR jamais posé : boot check répondait « boot assets are off » sur un DS416j où tout le reste était en place. Comme etc/ survit à une désinstallation, même désinstaller et réinstaller n’y change rien. Le .env.example n’aide pas : rien n’oblige personne à le lire.

postinst ajoute désormais les clés dont le fichier vivant n’a jamais entendu parler, sans toucher à ce qui est présent. Une clé commentée compte comme présente, et c’est là la propriété de sûreté : c’est ainsi qu’un exploitant dit « celle-là je la connais et je n’en veux pas ». Supprimer une ligne veut dire « jamais entendu parler » et la fait revenir ; la commenter veut dire non, et c’est respecté.

L’application de bureau DSM

Huit choses, mesurées sur une machine virtuelle DSM 7.2.2 et sur un DS416j en 7.1.1, et aucune dans le guide du développeur.

Un défaut calculé à l’exécution doit l’être aussi dans settings(). Le panneau rend le défaut d’une variable comme valeur du champ ; un défaut qui n’existe que là où le serveur le consomme s’affiche donc en case vide — pendant que le serveur, lui, tourne sur une adresse qu’il a déduite et jamais montrée. RESCRIPTUM_PUBLIC_HOST est parti comme ça : l’exploitant n’avait aucun moyen de voir vers quelle adresse ses machines seraient envoyées, sinon en lisant le journal de démarrage. Deux entrées de KNOWN sont dans ce cas, et toutes deux ont leur branche dans settings() : le nombre de threads et l’hôte public. Une troisième demanderait le même traitement, et rien dans le typage ne le dit.

Un CGI sous /webman/3rdparty/<pkg>/ tourne sous le propriétaire du script. Pas en http, et pas en root — sous celui qui possède le fichier. DSM attribue l’arborescence d’un paquet à l’utilisateur du paquet : le backend de l’application tourne donc en rescriptum et peut lire le fichier d’environnement en 0600 qu’il possède, ce qui est toute la raison pour laquelle la configuration reste modifiable pendant que le serveur est arrêté. Prouvé en attribuant le même script de deux façons et en regardant id changer. Un script resté possédé par root, lui, tourne bien en root là-bas : n’en laissez pas traîner.

Ce chemin n’est pas authentifié par DSM. Une requête non authentifiée atteint le script et reçoit 200. Ce qui garde le CGI d’un paquet, c’est le paquet qui l’a écrit — ici authenticate.cgi plus un contrôle administrators, et en perdre un serait silencieux.

su dans un CGI bloque la requête. Sans </dev/null, il hérite du stdin du CGI — un tube venant du serveur web que rien ne fermera —, y lit, et ne revient jamais. La page d’état s’arrêtait simplement en plein milieu. Puis, une fois cela corrigé, il échouait quand même avec « Permission denied », un processus non root ne pouvant devenir personne. Les deux étaient du travail perdu : le script est déjà l’utilisateur en question, donc un simple test -r était la réponse depuis le début.

Le framework qu’un paquet peut utiliser, c’est la machine qui le décide, pas le guide de Synology. DSM 7.2 embarque un framework Vue et le guide actuel ne documente que celui-là. Le DS416j plafonne en DSM 7.1.1, où Vue n’existe pas : une application bâtie dessus s’installe et donne à cette machine une icône qui n’ouvre rien. ExtJS est présent sur les deux (7.1.1 et 7.2.2 mesurés), d’où une seule application au lieu de deux.

L’exemple ExtJS du guide ne tourne pas. Il déclare les classes avec Ext.define et enchaîne avec callParent ; face à SYNO.SDS.AppInstance cela lève Cannot read properties of null (reading 'apply') avant que la fenêtre n’apparaisse. C’est ExtJS 3.4.1 avec une couche Ext.define par-dessus : utiliser Ext.define pour la déclaration — le lanceur de DSM trouve la classe ainsi, et superclass est bien posé — puis appeler MaClasse.superclass.constructor.call(this, config) plutôt que callParent.

La barre des tâches de DSM appelle getWindowTitle() sur la fenêtre. Sans titre, elle lève une exception depuis le propre code de DSM, et l’application ne s’ouvre pas du tout — avec une trace qui accuse Synology et pas vous.

Ne jamais nommer une méthode show. Ext.Window.prototype.show() est ce que DSM appelle pour afficher la fenêtre : un show(which) ajouté pour changer d’onglet l’a écrasé silencieusement. La fenêtre était construite, mise en page, capable de rendre une miniature correcte dans l’aperçu de la barre des tâches — et n’apparaissait jamais. Rien ne levait d’exception, sur aucune des deux versions de DSM, et c’est ce qui a coûté cher : trouvé en bisectant depuis l’exemple minimal du guide. Tout ce qu’on ajoute à ce prototype partage l’espace de noms de chaque méthode d’Ext.Window, qui est vaste.

fieldLabel est dessiné par la mise en page « form », pas par le champ. Un syno_displayfield dans un Ext.Panel ordinaire affiche sa valeur et perd son libellé sans rien dire, ce qui transformait la page d’état en colonne de valeurs nues. Il faut SYNO.ux.FormPanel, ou layout: 'form'.

Builds reproductibles et caches de navigateur ne s’entendent pas, et c’est le navigateur qui gagne. make-spk.sh fixe le mtime de chaque fichier empaqueté pour que les mêmes entrées produisent un .spk identique octet pour octet. nginx en fait un Last-Modified: 2019 sans Cache-Control, et la fraîcheur heuristique d’un navigateur est un dixième de l’âge apparent du fichier — des années. Un paquet mis à jour a continué de faire tourner l’ancien JavaScript contre le nouveau backend, malgré une réinstallation et un rechargement forcé. Le fichier de l’application porte donc le numéro de version dans son nom, et tout ce qu’elle va chercher elle-même porte ?v= ; check-spk.sh vérifie que le nom bouge toujours. deux.

Changements de comportement à retenir

Les documents de réponse doivent maintenant être valides. Avant la fusion, ils étaient servis comme des octets opaques, donc un document malformé atteignait l’installateur ; maintenant c’est un 500 avec l’erreur de parsing dans le log. C’est le meilleur échec, mais c’est un changement de comportement — des fixtures écrites en pseudo-YAML ont cessé de fonctionner à ce moment-là.

{{ machine }} n’est lié que si un document machine a matché. Une machine revendiquée par les members d’un groupe, sans document à elle, se résout avec machine: None — donc {{ machine }} dans un groupe échoue pour exactement les membres qu’il devait couvrir. Utilisez là un fait de requête comme {{ mac }}.

Le site de documentation

Le site de documentation

Ce site est docs/ dans le dépôt, rendu par notabene et publié sur GitHub Pages. Le binaire Rust n’en sait rien ; la chaîne d’outils de doc, c’est un package.json et un fichier de configuration, et la retirer laisserait docs/ comme du Markdown parfaitement lisible.

Pourquoi un site et pas un README plus long

Le README avait atteint 28 Ko et était trois documents portant un seul manteau : une présentation, un manuel utilisateur, et une note d’architecture. Un lecteur cherchant l’étape du pare-feu DSM devait faire défiler la sémantique de fusion. Donc :

  • docs/guide/ — utiliser rescriptum : installer, écrire des réponses, l’exploiter en production.
  • docs/development/ — construire rescriptum : les contraintes, les internes, la release.
  • README.md — ce que c’est, une démonstration de 30 secondes, et des liens vers le site.

Les deux espaces ont des publics différents et aucune raison de s’entremêler.

Deux langues

Le site est bilingue : l’anglais est la source, le français en est une traduction. La stratégie i18n suffix fait que les fichiers anglais gardent leurs chemins et leurs URL, et que les français sont des voisins *.fr.md :

docs/guide/answers/grouping.md      → /guide/answers/grouping
docs/guide/answers/grouping.fr.md   → /fr/guide/answers/grouping

Cet agencement a été choisi plutôt qu’un dossier par langue parce qu’il s’ajoute à une doc existante sans rien déplacer — les URL anglaises et leurs fils de commentaires survivent.

Les règles qui en découlent :

  • Écrire l’anglais d’abord, puis traduire. Un changement sur une page anglaise qui n’est pas répercuté laisse la page française périmée plutôt que cassée ; le lecteur retombe dessus avec un bandeau.
  • Les liens gardent le nom de base. Depuis une page française, écrivez ./selection.md, pas ./selection.fr.md — notabene résout la langue. En revanche, les ancres doivent être le slug du titre français : ./templating.md#machine-exige-un-document-machine.
  • Les commentaires sont par langue. Un commentaire laissé sur la page française est son propre fil et pointe sur le fichier source français.
  • L’habillage du site, la recherche et llms.txt sont également par langue.

README.md et README.fr.md suivent la même règle et se lient l’un à l’autre.

Travailler sur la doc

npm install          # une fois
npm run docs         # → http://localhost:3009

Cela ouvre le site avec la boucle de revue activée : sélectionnez n’importe quel texte sur la page rendue et laissez un commentaire exactement là où est le problème. Le commentaire ancré est l’instruction — pas besoin de citer un passage dans une fenêtre de chat en espérant que l’agent le retrouve.

Dites ensuite à votre agent « traite les commentaires de la doc ». Il lit docs/.notabene/, édite la source, marque chaque commentaire traité, et ajoute une entrée de journal disant ce qui a changé et pourquoi.

ScriptRôle
npm run docsle serveur de revue, avec rechargement à chaud
npm run docs:buildle site statique public dans ./_site
npm run docs:previewservir ce qui a été construit
npm run docs:lintvalider chaque lien interne contre les routes émises par le dernier build
npm run docs:status / docs:stopgérer un serveur de dev détaché

Vous utilisez Claude Code ? /plugin marketplace add z29k/notabene puis /plugin install notabene@z29k, et dites « set up notabene ». Le plugin lance son propre renderer épinglé, donc il n’entre pas en conflit avec celui du package.json.

Le mode de revue est approve

notabene.config.mjs définit review: "approve", donc l’agent propose au lieu de résoudre : chaque édition est validée contre son vrai diff git sur /review avant que le commentaire ne soit clos. Une documentation qui décrit des mots de passe root et de la configuration de démarrage mérite d’être lue avant d’être publiée. Passez à "auto" si cette cérémonie ne se justifie pas.

Écrire une page

Chaque page est du CommonMark avec un frontmatter YAML optionnel :

---
title: Groupes et fusion
description: Une phrase — elle devient la meta description et l'extrait de recherche.
sidebar:
  label: Groupement       # texte de la barre latérale, si le titre est trop long
  order: 3                # position parmi les frères, croissante
---

Tout a une valeur par défaut : une page sans frontmatter se rend très bien, triée alphabétiquement. Un dossier est nommé et positionné par son index.md.

Conventions dans ce dépôt :

  • Liens relatifs entre pages, avec l’extension .md./selection.md, ../reference/configuration.md. Ils deviennent des routes sur le site et restent cliquables sur GitHub.
  • URL GitHub absolues pour les fichiers du dépôtanswers/, CLAUDE.md, un workflow. Ils sont hors de docs/ et n’ont pas de route.
  • Diagrammes Mermaid rendus nativement, dans une clôture ```mermaid — voir architecture et le cycle de vie d’une requête.
  • Chaque page a besoin de son voisin *.fr.md, avec le frontmatter traduit aussi — le title, la description et le sidebar.label sont tous visibles par le lecteur.

Configuration

notabene.config.mjs à la racine du dépôt. Les parties qui comptent :

roots: [
  { key: "guide",       label: "Guide",                              path: "docs/guide" },
  { key: "development", label: { en: "Development", fr: "Développement" }, path: "docs/development" },
],
store: "docs/.notabene",
home: { en: "docs/home.md", fr: "docs/home.fr.md" },
i18n: { locales: ["en", "fr"], defaultLocale: "en", strategy: "suffix" },
branding: {
  logo: "assets/rescriptum-logo.jpg",
  favicon: "assets/rescriptum-logo.jpg",
  socialImage: "assets/rescriptum-logo.jpg",
},
editPattern: "https://github.com/z29k/rescriptum/edit/develop/{path}",
review: "approve",
publish: { site: "https://z29k.github.io", base: "/rescriptum" },

Chaque chaîne visible par le lecteur accepte une map par langue — le label et la description d’un espace, la page d’accueil, chaque libellé de lien de navigation, le titre du bloc latéral, le pied de page. Non définie pour une langue, elle retombe sur la langue par défaut.

Le logo est assets/rescriptum-logo.jpg : un rescrit scellé sur une disquette — une réponse écrite, remise par une machine. Il sert de logo dans la barre du haut, de favicon et de carte sociale, et c’est aussi l’image qu’utilise le README.

editPattern pointe sur develop, pas main : la doc y est fusionnée comme tout le reste, et main ne reçoit que des commits de release.

docs/.notabene/ est le store de commentaires et de journal — du JSON simple, commité, diffable dans une PR. Commitez-le.

Le garde-fou CI

.github/workflows/ci.yml a un job docs : npm ci, construction du site public, puis notabene lint, qui vérifie chaque lien interne contre les routes que le build a réellement émises et suggère les quasi-correspondances. Un lien mort dans de la documentation publiée est bon marché à empêcher et gênant à livrer.

Il tourne sur les mêmes pushes que les garde-fous Rust.

Publication

.github/workflows/docs.yml construit en --public et déploie sur GitHub Pages à chaque push sur main touchant docs/, la configuration ou le workflow — plus workflow_dispatch, pour publier une correction de doc sans attendre une release.

Comme main ne reçoit que des commits de release, la documentation part normalement avec une release. Lancez le workflow à la main quand elle ne doit pas attendre.

L’artefact est le build public en lecture seule : pas d’interface de revue, pas de données de store, plus llms.txt, un jumeau Markdown par page, un sitemap et des métadonnées OpenGraph. pagefind est une dépendance de développement, donc npm ci donne au site une recherche plein texte sans configuration supplémentaire.

Mise en place unique du dépôt : Settings → Pages → Source = GitHub Actions.

La situation des dépendances

npm audit signale des avis de sécurité dans Astro, esbuild et sharp, transitivement sous notabene, sans correctif disponible en amont pour l’instant. Ils sont limités au développement : rien de node_modules n’est exécuté par le site publié ni n’atteint le binaire Rust, et le job CI construit du HTML statique à partir de Markdown que ce dépôt possède.

À revérifier quand notabene se met à jour, pas à bloquer dessus.