feat: ingestion et lecture des postes (POST /hosts/<id>, GET /hosts) (#13) #20
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/vps-health-api#20
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-13-hosts-endpoints"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Maillon 3 de la pile chainee — base
issue-12-auth-tests, pasmain.Ouvre la premiere surface d'ecriture de l'API : les postes poussent leur snapshot CPU / RAM / disque, le dashboard admin le relit date.
Ce qui change dans le routage
Le routage passe d'une liste de chemins exacts a une table de descripteurs, en gardant un seul point de controle d'auth :
L'ordre routage -> auth est preserve intentionnellement. Une route inconnue ou une mauvaise methode repond toujours 404, jamais 401 — comportement de production inchange. Les deux tests
404 (not 401)poses par #12 passent tels quels (verifies nommement).Contrat
POST /hosts/<id><id>bien forme mais hors allowlist<id>hors format — n'atteint jamais le handlerHOSTS_INGEST_TOKENnon configure (fail-closed)GET /hosts(token de lecture) retourne{ staleAfterSeconds, hosts: [...] }, une entree par identifiant de l'allowlist,onlinecalcule cote serveur.Controles de securite
^/hosts/([a-z0-9][a-z0-9-]{0,31})$, restreinte a POST, est le controle anti-traversee. Verifie en Node et contre un vrai serveur :/hosts/../../etc/passwdest normalise parURL()en/etc/passwd,..%2f..%2f/THINKPAD//hosts//-lead/ 33 caracteres echouent la regex — tous tombent en 404. Le 403 reste reserve aux identifiants bien formes hors allowlist. Source uniqueHOST_ID_PATTERN: la regex de route et celle de l'allowlist ne peuvent pas deriver l'une de l'autre.tokenMatches()compare des empreintes SHA-256 avectimingSafeEqual(longueurs toujours egales, pas de fuite de longueur). Applique au seul chemin d'ingestion — realigner leHEALTH_TOKENexistant toucherait tous les chemins de lecture en production (issue separee). Commentaire pose dans le code pour eviter qu'un relecteur « corrige » l'asymetrie.Content-Length(un client peut mentir, le chunke l'omet). Au-dela : 413 puisreq.destroy()une fois la reponse ecoulee — detruire avant le flush laisserait le client avec une erreur reseau au lieu du code.Number.isFinite, chaines plafonnees a 128 caracteres, tout le reste jete). Le fichier est relu parGET /hostset finit dans l'arbre React de l'admin. La relecture repasse par la meme liste blanche :HOSTS_DIRest un montage en lecture-ecriture, le fichier sur disque n'est pas plus digne de confiance que le payload.renameSync, pour qu'unGET /hostsconcurrent ne voie jamais un JSON tronque.HOSTS_ALLOWED_IDSvalide ses propres entrees au demarrage avec la meme regex ; les entrees refusees (../defenseurs/status,POPOS) sont journalisees et ecartees au lieu de devenir des chemins de fichier.X-Real-IP, l'identifiant et le motif. Les valeurs sont filtrees en ASCII imprimable pour empecher la forge de lignes de log.receivedAtest pose par le serveur a la reception ; un horodatage fourni par le client est ecarte par la liste blanche (test dedie).Tests
61 au total — les 39 existants inchanges + 22 nouveaux dans
__tests__/hosts.test.js.La suite ajoutee est volontairement du smoke, pas la matrice exhaustive (c'est l'issue #14) : elle epingle les decisions couteuses a perdre en silence — ordre de routage, regex anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche (
__proto__ethostnamede 300 caracteres inclus), 413,neverSeen, fraicheur serveur, entree corrompue degradee, validation de l'allowlist au demarrage.Points a relire en priorite
auth.test.js, et un peu plus de volume de log en prod. A trancher si ce n'etait pas l'intention.GET /hostsdegrade enneverSeen: trueplutot qu'en un champerrorsupplementaire — la forme de reponse contractee ne prevoit queneverSeen?, et le dashboard affiche alors « agent non installe » au lieu de planter sur un champ inconnu. Le detail reel part dans les logs serveur.HOSTS_DIRpar defaut/data/hosts(non specifie par l'issue), aligne sur la convention des montages existants.node, l'utilisateur du conteneur), contrairement aux montages/data/defenseurs. Sinon chaque ingestion repond 500. Documente dans le README et le CLAUDE.md.HOSTS_INGEST_TOKENa poser sur Coolify enis_runtime=true, is_buildtime=false, meme regle queHEALTH_TOKEN.Docs
.env.example:HOSTS_DIR,HOSTS_ALLOWED_IDS,HOSTS_INGEST_TOKEN,HOSTS_STALE_SECONDSREADME.md: table des endpoints avec la colonne token, contrat dePOST /hosts/<id>etGET /hosts, table de config, bind-mounts scindes en lecture seule / lecture-ecritureCLAUDE.md: endpoints, nouvelle section « Routage et auth » qui documente l'ordre et le point de controle unique, config, compte de tests, et le gotcha « read-only » corrige — l'API n'est plus en lecture seuleAucun module runtime ajoute a la racine (
node:cryptoest un builtin), donc leCOPYexplicite du Dockerfile reste valide.Resolves #13
Generated autonomously by /autopilot run of 2026-08-16
Open the API's first write surface. Workstations push their CPU / memory / disk snapshot, the admin dashboard reads it back with a server-computed freshness. Routing moves from an exact path list to a descriptor table, keeping ONE authentication checkpoint: resolveRoute() -> no match means an immediate 404, deny by default checkAuth() -> the single gate; only the expected token varies per descriptor (HEALTH_TOKEN for reads, HOSTS_INGEST_TOKEN for ingestion) dispatch The routing-before-auth ordering is preserved on purpose: an unknown path or a wrong method still answers 404, never 401, exactly as before. The two `404 (not 401)` tests added in #12 pin that ordering and still pass. Security controls on the new write path: - The route regex ^/hosts/([a-z0-9][a-z0-9-]{0,31})$, POST-only, IS the path traversal control. URL() normalises /hosts/../../etc/passwd to /etc/passwd and encoded traversals fail the match, so every hostile id lands on 404. 403 is reserved for well-formed ids outside the allowlist. - 401 wins over 403, so an unauthenticated caller cannot probe the allowlist. - tokenMatches() compares SHA-256 digests with timingSafeEqual. Scoped to the ingestion path only; realigning HEALTH_TOKEN touches every read route in production and is tracked separately. - Body capped at 4 KiB by counting received bytes, never Content-Length: a client can lie in the header and chunked encoding omits it. Past the cap, 413 then req.destroy() once the response has flushed. - The persisted object is rebuilt field by field from a whitelist — finite numbers, strings capped at 128 chars, everything else dropped — because the file is read back by GET /hosts and ends up in the admin React tree. The read path re-runs the same whitelist: the bind-mount is writable, so the file on disk earns no more trust than the payload did. - Snapshots are written to a temp file in the same directory then renamed, so a concurrent GET /hosts can never observe a truncated JSON. - HOSTS_ALLOWED_IDS entries are validated at startup with the same regex; rejects are logged and dropped rather than becoming file paths. - Every 401/403 is logged with X-Real-IP, the host id and the reason. Log values are filtered to printable ASCII so a crafted header cannot forge extra log lines. - receivedAt is stamped by the server on arrival; a client-supplied timestamp is discarded by the whitelist. online = ageSeconds <= HOSTS_STALE_SECONDS. Runtime stays zero-dependency — node:crypto is a builtin and no new module file was added, so the Dockerfile's explicit COPY list is unchanged. Tests: 61 (39 existing untouched + 22 new). __tests__/hosts.test.js is smoke coverage of the decisions that would be silent to regress; the exhaustive matrix is issue #14. Docs: .env.example gains HOSTS_DIR / HOSTS_ALLOWED_IDS / HOSTS_INGEST_TOKEN / HOSTS_STALE_SECONDS; CLAUDE.md and README.md document the endpoints, the routing/auth ordering and the config. The CLAUDE.md "read-only" gotcha is corrected — the API now writes, and HOSTS_DIR must be writable by uid 1000. Resolves #13Verdict : APPROVE
Seul maillon qui touche du code servi en production. Les six invariants de la vague tiennent, verification faite. La table de routes est une amelioration structurelle sur la chaine de
ifdemain: ajouter une route ne peut plus accidentellement livrer du non-authentifie.Invariants verifies
resolveRoute()-> 404 avantcheckAuth(). Les deux tests de #19 restent verts pour la bonne raison.checkAuth()est le seul site d'authentification ; seulexpectedvarie viatokenKind. Aucune dispersion par branche.../../etc/passwd,..%2f..%2f,%2e%2e,%2E%2E,THINKPAD,%00,.hidden,-lead, 33 caracteres,//,/final — tous 404. Seul[a-z0-9][a-z0-9-]{0,31}atteintpath.join. Les affirmations du commentaire sont exactes telles qu'ecrites.tokenMatchescloisonne a l'ingestion. Le chemin de lecture gardeheader === Bearer ${TOKEN}; #17 correctement laisse hors vague.sanitizeSnapshotrebatit champ par champ ;__proto__ne peut pas se propager (JSON.parseen fait une propriete propre, jamais lue, sortie via un litteral neuf). Fichier temporaire dans le meme repertoire +renameSync, mode 0600, temp supprime en cas d'echec.Egalement verifie : 401 avant 403 (allowlist non sondable),
logRejectionne touche jamais l'en-teteAuthorization,safeLogValueplafonne et filtre le non-imprimable, le 204 retireContent-Type, le catch-all dehandlerest garde parheadersSent,HOSTS_STALE_SECONDSrejette 0/negatif/NaN,parseAllowedIdsecarte les entrees invalides (repose sur le hoisting desafeLogValue— valide, c'est une declaration de fonction).Suggestions (non bloquantes)
Cette PR casse le contrat que #19 venait de poser.
auth.test.jsporteconst ROUTES = [...]avec le commentaire « Every route reachable by the handler. Any new route must be added here. » #20 ajouteGET /hostsetPOST /hosts/<id>sans y toucher. Verifie contre le tip cumule (e24f5b6f), pas seulement contre cette base :ROUTEScontient toujours les quatre routes d'origine, et aucun test nulle part n'affirme queGET /hostsrepond 401 sans en-teteAuthorization. La couverture existe pour le mauvais token (token d'ingestion -> 401) et pourHEALTH_TOKENabsent -> 401, donc l'exposition reelle est faible etcheckAuthrend le trou structurellement quasi impossible — mais c'est exactement la classe de derive que #19 existait pour attraper, et elle est passee des sa premiere sortie. Correctif : une ligne dansROUTES.sanitizeCpuaccepteloadAvg: [](comportement explicitement beni par un test de #21). Un consommateur qui faitloadAvg[0].toFixed(1)prend un TypeError. Le dashboard est le consommateur et le contrat est neuf : soit completer a 3 entrees, soit documenter que le tableau peut etre plus court. Le README documenteloadAvgsans annoncer de garantie de longueur.cleanStringplafonne la longueur mais ne filtre pas les caracteres de controle : unhostnamepeut porter\nou des sequences ANSI. C'est delibere (un test de #21 fige « stored verbatim, not escaped ») et correct pour un consommateur React — mais la surete de ce choix repose entierement sur l'echappement cote consommateur. Une ligne dans le README sousGET /hostsdisant que les valeurs sont stockees brutes et doivent etre echappees au rendu eviterait qu'un futur consommateur non-React herite du probleme sans le savoir.receivedAtdans le futur n'est pas borne :Math.max(0, …)le transforme enageSeconds: 0->online: true. Atteignable seulement par qui peut ecrire dans le bind-mount (donc deja a l'interieur), donc cosmetique. #21 fige ce comportement comme voulu.Debris de fichiers temporaires : des
.{id}.{pid}.{ts}.tmps'accumulent dansHOSTS_DIRsi le processus meurt entre l'ecriture et le rename.handleHostsListitere l'allowlist et non le repertoire, donc ils sont invisibles — ils restent simplement la. Un balayage au demarrage fermerait le sujet.Le corps du 503 nomme la variable d'environnement a un appelant non authentifie. Coherent avec le
HEALTH_TOKEN not configuredpreexistant, donc pas une regression — juste a noter que cette divulgation est maintenant sur un endpoint d'ecriture public.Revue adversariale — maillon 3/5, revu contre sa base
issue-12-auth-tests. Constats revalides contre le tip cumule avant publication.Mergee localement sur
mainen fast-forward (pile chainee : l API de merge Forgejo ne peut pas traiter une pile dont la base n est pasmain). Tip integre :e181a96. Forgejo ne detecte pas un merge ff local, donc cette PR est fermee a la main — le code EST surmain.Pull request closed