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
extendsde machine pointant sur un groupe manquant est détecté au chargement, dansselect.rs, plutôt qu’au moment où cette machine demande. Le garde-fou ne peut attraper que ce queproblems()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 :
| Constante | Valeur |
|---|---|
MAX_FAILURES | 5 |
FAILURE_WINDOW | 60 s |
BASE_BLOCK | 60 s, doublant à chaque récidive |
MAX_BLOCK | 900 s |
MAX_TRACKED | 4096 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_TRACKEDest 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 /healthest 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
ECONNRESETau 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=tomlsur 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.
rescriptum