@nodefony/devkit 10.0.0-beta.2 → 10.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +151 -5
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
  4. package/dist/index.js +24 -11
  5. package/dist/nodefony/config/config.js +16 -137
  6. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  7. package/dist/nodefony/controllers/DevkitController.js +7 -14
  8. package/dist/nodefony/controllers/McpController.js +27 -163
  9. package/dist/nodefony/controllers/ProfileSessionController.js +47 -0
  10. package/dist/nodefony/mcp/observe.js +235 -0
  11. package/dist/nodefony/mcp/profileReport.js +242 -0
  12. package/dist/nodefony/mcp/profileRoute.js +521 -0
  13. package/dist/nodefony/mcp/shotClient.js +84 -0
  14. package/dist/nodefony/mcp/shutdown.js +44 -0
  15. package/dist/nodefony/mcp/tools.js +696 -0
  16. package/dist/nodefony/service/DevkitService.js +93 -29
  17. package/dist/nodefony/src/card.js +1 -2
  18. package/dist/nodefony/src/errors/DevkitError.js +1 -2
  19. package/dist/nodefony/src/profileSession.js +14 -0
  20. package/dist/types/index.d.ts +15 -1
  21. package/dist/types/nodefony/config/config.d.ts +1 -1
  22. package/dist/types/nodefony/controllers/McpController.d.ts +13 -17
  23. package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
  24. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
  25. package/dist/types/nodefony/mcp/observe.d.ts +83 -0
  26. package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
  27. package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
  28. package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
  29. package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
  30. package/dist/types/nodefony/mcp/tools.d.ts +109 -0
  31. package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
  32. package/dist/types/nodefony/src/profileSession.d.ts +12 -0
  33. package/docs/index.md +217 -20
  34. package/package.json +17 -12
  35. package/skills/nodefony-add-crud/SKILL.md +5 -0
  36. package/skills/nodefony-add-realtime-channel/SKILL.md +66 -9
  37. package/skills/nodefony-add-service/SKILL.md +14 -3
  38. package/skills/nodefony-authentication/SKILL.md +234 -0
  39. package/skills/nodefony-browser/SKILL.md +65 -21
  40. package/skills/nodefony-browser/references/socket.md +13 -4
  41. package/skills/nodefony-browser/references/sondes.md +13 -0
  42. package/skills/nodefony-browser/scripts/audit.mjs +59 -10
  43. package/skills/nodefony-browser/scripts/inspect.mjs +123 -5
  44. package/skills/nodefony-browser/scripts/lib/browser.mjs +112 -6
  45. package/skills/nodefony-browser/scripts/lib/probes.mjs +219 -7
  46. package/skills/nodefony-browser/scripts/socket.mjs +70 -4
  47. package/skills/nodefony-browser/scripts/watch.mjs +101 -0
  48. package/skills/nodefony-dev/SKILL.md +78 -82
  49. package/skills/nodefony-dev/scripts/docs.mjs +24 -8
  50. package/skills/nodefony-devops/SKILL.md +98 -74
  51. package/skills/nodefony-devops/references/compose.md +8 -0
  52. package/skills/nodefony-devops/references/dimensionnement.md +122 -0
  53. package/skills/nodefony-devops/references/frontal.md +282 -48
  54. package/skills/nodefony-devops/references/kubernetes.md +41 -14
  55. package/skills/nodefony-devops/references/secrets.md +30 -5
  56. package/skills/nodefony-devops/references/variables.md +66 -31
  57. package/skills/nodefony-migrate-schema/SKILL.md +5 -2
  58. package/skills/nodefony-migrate-schema/references/verdicts.md +20 -19
  59. package/skills/nodefony-protect-route/SKILL.md +124 -21
  60. package/skills/nodefony-security-review/SKILL.md +127 -0
  61. package/skills/nodefony-security-review/scripts/review-diff.mjs +1090 -0
  62. package/skills/nodefony-troubleshoot/SKILL.md +225 -0
  63. package/skills/nodefony-troubleshoot/scripts/log-errors.mjs +465 -0
  64. package/skills/nodefony-upgrade/SKILL.md +130 -0
  65. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -1,28 +1,49 @@
1
- # Derrière un frontal — nginx, haproxy, Ingress
1
+ # Derrière un frontal — nginx, HAProxy, h2c, Ingress, Gateway API, Traefik
2
2
 
3
3
  > **Maintenance** : vérité courante. Éditer en place.
4
4
 
5
5
  ## 1. La configuration se DÉRIVE, elle ne s'écrit pas
6
6
 
7
7
  ```bash
8
- npx nodefony proxy:generate nginx [-o <fichier>] [-b <hôte>] [-l <port>] [--reencrypt]
9
- npx nodefony proxy:generate haproxy
8
+ npx nodefony proxy:generate nginx # sur la sortie standard, pour lire avant d'installer
9
+ npx nodefony proxy:generate haproxy -o haproxy.cfg
10
10
  ```
11
11
 
12
- La commande **démarre l'application sans ouvrir de port**, lit ses montages statiques réels, sa
13
- taille de corps maximale et son battement de cœur temps réel, puis écrit la configuration.
12
+ | Option | Rôle |
13
+ | ----------------------------------------- | ----------------------------------------------------------------------------- |
14
+ | `-o, --out <fichier>` | écrire dans un fichier plutôt que sur la sortie standard |
15
+ | `-b, --backend <hôte>` | l'adresse à laquelle le frontal joint l'application (défaut `127.0.0.1`) |
16
+ | `-l, --listen <port>` | le port d'écoute du frontal (défaut `80`) |
17
+ | `--reencrypt` | TLS jusqu'à l'application (port HTTPS) au lieu du clair sur le réseau interne |
18
+ | `--tls-cert`, `--tls-key`, `--tls-listen` | nginx seulement : le frontal termine le TLS (écoute `443` par défaut) |
19
+ | `--assets-root <dossier>` | servir tous les fichiers statiques depuis un seul arbre (`assets:publish`) |
20
+
21
+ La commande **démarre l'application sans ouvrir de port** et lit sa configuration **réelle** :
22
+ ports, domaines de confiance, dossiers statiques montés par chaque module, taille de corps
23
+ maximale, battement de cœur WebSocket, port h2c. 🔴 Elle lit la configuration **de l'environnement
24
+ où elle tourne** : lance-la avec les variables de production, sinon tu obtiens la configuration
25
+ d'un autre déploiement — par exemple sans h2c.
14
26
 
15
27
  **Pourquoi ça compte** : une configuration écrite à la main est juste le jour où on l'écrit. Au
16
28
  premier dossier statique ajouté, à la première route de téléversement, elle devient fausse — et
17
- elle ne le dit pas : elle sert un 404 ou un 413 que personne ne rattache à la configuration du
18
- frontal.
29
+ elle ne le dit pas : elle sert un 404 ou un 413 que personne ne rattache au frontal.
19
30
 
20
- Dans l'image, cette génération est un étage de construction : la configuration est produite **au
21
- moment où l'image est construite**, donc à partir du code exact qu'elle embarque.
31
+ ## 2. Ce que le générateur pose, et pourquoi
22
32
 
23
- ## 2. Le point qui casse tout si on l'oublie
33
+ | Ce qui est généré | Ce que ça évite |
34
+ | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
35
+ | `X-Forwarded-For` **écrasé** avec l'adresse vue du client | un client qui s'invente une adresse |
36
+ | `Upgrade` / `Connection` relayés | une WebSocket qui « ne se connecte pas », sans le moindre message |
37
+ | inactivité = **4 battements WebSocket, plancher 300 s** | un frontal qui coupe des sockets **vivantes** ; nginx `proxy_read_timeout`/`proxy_send_timeout`, HAProxy `timeout client`/`server` |
38
+ | HAProxy : `timeout tunnel` à la même valeur | une fois passée en WebSocket, la connexion n'obéit plus à `timeout server` : sans cette ligne, le défaut la coupe |
39
+ | `client_max_body_size` dérivé de la taille de corps de l'application | un `413` de nginx (1 Mo par défaut) que l'application n'a jamais vu passer |
40
+ | une chaîne de `try_files` sur **toutes** les racines statiques | les fichiers d'un module en `404` alors qu'ils sont bien montés |
41
+ | HAProxy : `Forwarded` entrant **effacé** avant le nôtre | un en-tête forgé conservé à côté du vrai |
42
+ | HAProxy : le schéma **constaté** sur la connexion entrante (`ssl_fc`) | annoncer `https` pour un client venu en clair — cookies `Secure` posés sur du clair |
43
+ | HAProxy : `option httpchk GET /readyz` dans chaque backend | un exemplaire qui draine gardé dans la table — un contrôle TCP le croit sain tant que son port accepte (§6) |
44
+ | nginx avec TLS : HTTP/2 côté navigateur (`http2 on`), HTTP/1.1 vers l'application | le plafond d'environ six connexions par origine est celui du navigateur envers le **frontal** : c'est là que le multiplexage compte |
24
45
 
25
- 🔴 **`NF__HTTP__TRUSTPROXY`**
46
+ ## 3. Le point qui casse tout si on l'oublie — `NF__HTTP__TRUSTPROXY`
26
47
 
27
48
  Quand ton frontal termine le TLS, l'application reçoit du **HTTP en clair**, plus un en-tête qui
28
49
  annonce le schéma d'origine. Sans réglage, elle ne croit pas cet en-tête — et c'est la bonne
@@ -30,18 +51,28 @@ position par défaut, parce qu'un en-tête est trivialement falsifiable par un c
30
51
 
31
52
  Conséquence si tu ne le poses pas : le schéma constaté est `http`, donc :
32
53
 
33
- - **le cookie `__Host-` n'est pas émis** — ce préfixe impose qu'il soit posé sur une origine
34
- sécurisée. Les sessions ne tiennent pas, **sans aucun message** ;
35
- - l'adresse cliente enregistrée est celle du frontal, donc l'audit et toute limitation par adresse
36
- portent sur une seule IP ;
37
- - les redirections peuvent pointer sur `http://`.
54
+ - **le cookie de session part sans le préfixe `__Host-`** — ce préfixe n'est posé que sur une
55
+ origine que l'application sait sécurisée. La session marche, mais perd ce qu'il garantit (pas de
56
+ `Domain`, donc pas de fixation de session depuis un sous-domaine), **sans aucun message** ;
57
+ - l'adresse cliente enregistrée est celle du frontal : l'audit, la limitation de débit et le
58
+ plafond de connexions par adresse portent sur **une seule** IP — une vague de reconnexions
59
+ WebSocket se fait alors refuser en `429` pour tout le monde ;
60
+ - les redirections peuvent pointer sur `http://` ;
61
+ - **toute connexion par mot de passe répond `403`** (« doit passer par HTTPS ») : en production,
62
+ un secret reçu sur une requête que l'application croit en clair est refusé avant toute
63
+ vérification. `security.allowInsecureCredentials` n'est PAS la correction — elle accepterait
64
+ vraiment le clair ; la correction est cette variable.
38
65
 
39
66
  ```yaml
40
67
  NF__HTTP__TRUSTPROXY: uniquelocal # le frontal est sur un réseau privé
41
68
  ```
42
69
 
43
- **Le prouver, plutôt que le croire** — c'est un réglage dont l'effet est invisible tant qu'on ne
44
- regarde pas le bon endroit :
70
+ Jamais `true` si l'application est joignable par un autre chemin que le frontal : n'importe quel
71
+ client pourrait alors écrire sa propre adresse. Dans une **cascade** (équilibreur d'hébergeur →
72
+ frontal → application), chaque saut doit être de confiance : la remontée de l'adresse se fait de
73
+ droite à gauche et s'arrête au premier inconnu.
74
+
75
+ **Le prouver, plutôt que le croire** :
45
76
 
46
77
  ```bash
47
78
  curl -ksSI https://<hôte>/
@@ -51,19 +82,20 @@ curl -ksSI https://<hôte>/
51
82
  Puis **débranche-le** : retire la variable, redéploie, refais la requête. Le cookie doit tomber. Un
52
83
  réglage qu'on n'a jamais vu manquer n'est pas un réglage prouvé.
53
84
 
54
- ## 3. Les autres réglages du mode « derrière un frontal »
85
+ ## 4. Les autres réglages du mode « derrière un frontal »
55
86
 
56
- | Variable | À poser | Ce qu'elle fait |
57
- | ---------------------------- | --------------------------- | ---------------------------------------------------------------------------------------- |
58
- | `NF__HTTP__TRUSTEDHOSTS` | les hôtes réellement servis | Un `Host` étranger est refusé — coupe l'empoisonnement d'en-tête |
59
- | `NF__APP__DOMAINCHECK` | `"true"` | Contrôle du domaine actif |
60
- | `NF__HTTP__STATICS__ENABLED` | `"false"` | **Le frontal sert les fichiers statiques** ; l'application cesse de les servir en double |
87
+ | Variable | À poser | Ce qu'elle fait |
88
+ | ---------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
89
+ | `NF__HTTP__TRUSTEDHOSTS` | les hôtes réellement servis | Un `Host` étranger est refusé — coupe l'empoisonnement d'en-tête |
90
+ | `NF__APP__DOMAINCHECK` | `"true"` | Contrôle du domaine actif |
91
+ | `NF__HTTP__STATICS__ENABLED` | `"false"` | **Le frontal sert les fichiers statiques** ; l'application cesse de les servir en double |
92
+ | `NF_OAUTH_REDIRECT_BASE` | l'URL publique, sans chemin | Connexion par un fournisseur d'identité : sans elle, l'adresse de retour vaut `https://localhost:<port>` et le fournisseur refuse (`redirect_uri`) |
61
93
 
62
94
  Laisser l'application servir les statiques derrière un frontal qui les sert aussi n'est pas une
63
95
  erreur fonctionnelle — c'est un gaspillage silencieux : chaque fichier peut être servi par le
64
96
  mauvais des deux, avec des en-têtes de cache différents.
65
97
 
66
- ## 4. Ne publie aucun port de l'application
98
+ ## 5. Ne publie aucun port de l'application
67
99
 
68
100
  Derrière un frontal, l'application **ne doit publier aucun port**. C'est la définition d'être
69
101
  derrière quelque chose : le seul chemin doit passer par le frontal. C'est ce que fait le profil de
@@ -73,43 +105,228 @@ Si tu publies quand même un port « pour déboguer », tu as deux portes d'entr
73
105
  sans limitation et sans les en-têtes que ton frontal pose. Utilise `docker compose exec` ou un
74
106
  transfert de port temporaire.
75
107
 
76
- ## 5. Les sondes, vues du frontal
77
-
78
- Ton frontal a besoin de savoir quand retirer un exemplaire. Les deux sondes ne servent pas à la
79
- même chose :
108
+ ## 6. Les sondes, vues du frontal
80
109
 
81
110
  | Sonde | Pour quoi | Pendant l'arrêt |
82
111
  | --------- | ------------------------------- | --------------- |
83
112
  | `/livez` | « le process est-il vivant ? » | **reste `200`** |
84
113
  | `/readyz` | « peut-il prendre du trafic ? » | passe à `503` |
85
114
 
86
- **Ton frontal doit sonder `/readyz`**, jamais `/livez` : c'est `/readyz` qui bascule dès le début de
87
- l'arrêt, donc c'est lui qui te fait retirer l'exemplaire **avant** qu'il cesse de répondre. Un
88
- frontal qui sonde `/livez` garde l'exemplaire dans sa table pendant tout le drain et envoie du
89
- trafic à quelque chose qui refuse déjà.
115
+ Un frontal qui retire les exemplaires doit sonder **`/readyz`**, jamais `/livez` : c'est `/readyz`
116
+ qui bascule dès le début de l'arrêt. Les deux répondent **avant la limitation de débit** — un `429`
117
+ sur une sonde veut dire que tu n'interroges pas le chemin nu (une réécriture ajoute un préfixe).
118
+
119
+ 🔴 **Ce que les configurations générées font réellement** — à savoir dès que tu mets
120
+ **plusieurs** exemplaires derrière un même frontal :
121
+
122
+ | Frontal généré | Contrôle de santé |
123
+ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
124
+ | HAProxy | `option httpchk GET /readyz` dans **chaque** backend : l'exemplaire est retiré dès que `/readyz` répond `503`, alors que son port accepte encore. En h2c, la sonde part sur le port HTTP en HTTP/1.1 (`check port 5151 check-proto h1`) |
125
+ | nginx | nginx libre n'a **pas** de contrôle actif : il retire un amont après des échecs constatés, jamais sur `/readyz` |
126
+
127
+ Le chemin vient de la configuration (`health.readinessPath`) ; sondes désactivées, le générateur
128
+ retombe sur un contrôle de connexion, plutôt que d'interroger un chemin que plus rien ne sert.
129
+ Éprouvé par un HAProxy 3.0 réel sur la configuration générée : un exemplaire dont la sonde ne
130
+ répond pas `200` sort du répartiteur alors que son port accepte.
131
+
132
+ Ce que la sonde ne règle pas seule : le frontal ne la lit que toutes les deux secondes. Pose un
133
+ **délai avant drain** (`shutdownDelay`, en millisecondes) au moins égal à deux
134
+ intervalles de contrôle, pour que le frontal ait vu le `503` avant que l'exemplaire cesse
135
+ d'accepter.
136
+
137
+ ## 7. Les flux SSE derrière le frontal
138
+
139
+ Un flux SSE est une réponse HTTP qui ne finit pas. Trois réglages décident s'il est bien relayé :
140
+
141
+ | Réglage | Qui le pose | Pourquoi |
142
+ | ---------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------- |
143
+ | pas de mise en tampon | l'application envoie `X-Accel-Buffering: no` | nginx relaie chaque événement au lieu d'attendre de remplir son tampon. Ne filtre pas cet en-tête |
144
+ | inactivité ≥ 300 s | le générateur | un flux muet n'est tenu que par son battement de 15 s : vingt battements de marge |
145
+ | HTTP/2 côté navigateur | le générateur (nginx avec TLS) | au-delà de six flux par origine en HTTP/1.1, le navigateur met les suivants en attente |
146
+
147
+ Entre le frontal et l'application, chaque flux SSE occupe **une connexion** en HTTP/1.1. Beaucoup
148
+ de flux longs par pod : c'est le cas où h2c (§8) vaut la peine.
149
+
150
+ ## 8. HTTP/2 jusqu'à l'application — le port h2c
151
+
152
+ Par défaut le frontal parle HTTP/1.1 à l'application. Un frontal qui termine le TLS peut aussi lui
153
+ parler **HTTP/2 en clair** (h2c, connaissance préalable) sur un port **dédié**, désactivé par défaut :
154
+
155
+ ```bash
156
+ NF__APP__SERVERS__H2C__ENABLED=true # port 5153 ; NF__APP__SERVERS__H2C__PORT pour le changer
157
+ ```
158
+
159
+ **h2c n'est pas « plus rapide » par principe.** Mesuré sur une route triviale, un seul process :
160
+ débit h2c rapporté à HTTP/1.1 = **−48 %** à forme égale (64 connexions × 1 flux), **−18 %** dans la
161
+ forme d'un frontal (4 connexions × 16 flux). Le frontal garde déjà un pool de connexions HTTP/1.1
162
+ qui masque le blocage en tête de file, et HTTP/2 coûte du travail par requête (trames, compression
163
+ des en-têtes). Le choix se fait sur la **forme du trafic** :
164
+
165
+ | Forme du trafic | h2c ? | Pourquoi |
166
+ | -------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------- |
167
+ | gRPC | **requis** | gRPC n'existe qu'en HTTP/2 |
168
+ | Beaucoup de flux SSE longs par pod | **oui** | en HTTP/1.1 chaque flux tient une connexion du frontal ; en h2c ils partagent |
169
+ | En-têtes lourds et répétés (cookies, jetons) | probable | la compression HPACK les factorise d'une requête à l'autre |
170
+ | REST classique, petites réponses | **mesurer** | souvent neutre, parfois perdant |
171
+ | WebSocket | **non** | reste sur le port HTTP |
172
+ | Plusieurs workers dans un pod | **attention** | le cluster Node répartit des connexions : avec les rares connexions h2c, un worker prend tout |
173
+
174
+ **Trois règles valent pour TOUS les frontaux :**
175
+
176
+ | Règle | Pourquoi |
177
+ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
178
+ | les requêtes vont au port **h2c**, les **WebSocket** au port **HTTP** | le port h2c ne sert ni `Upgrade` ni le CONNECT étendu (RFC 8441), seul chemin d'une WebSocket en HTTP/2 |
179
+ | les **sondes** visent le port **HTTP** | le port h2c refuse HTTP/1.1, et une sonde de kubelet parle HTTP/1.1. Garde le port HTTP **actif** |
180
+ | le schéma passe par `X-Forwarded-Proto` **et** `NF__HTTP__TRUSTPROXY` | en clair, l'application ne croit jamais le `:scheme` que le frontal écrit |
181
+
182
+ Vérifier le port, puis comparer avec le même instrument :
183
+
184
+ ```bash
185
+ curl --http2-prior-knowledge http://127.0.0.1:5153/readyz
186
+ h2load --h1 -n 100000 -c 64 http://127.0.0.1:5151/<route> # HTTP/1.1
187
+ h2load -n 100000 -c 4 -m 16 http://127.0.0.1:5153/<route> # h2c, forme d'un frontal
188
+ ```
189
+
190
+ ### HAProxy — généré
191
+
192
+ Lancé avec h2c actif, `proxy:generate haproxy` envoie les requêtes au port h2c en `proto h2` et
193
+ garde les WebSocket sur le port HTTP. Rien à écrire :
194
+
195
+ ```text
196
+ use_backend be_nodefony if { hdr(upgrade) -i websocket }
197
+ default_backend be_nodefony_h2c
198
+
199
+ backend be_nodefony_h2c
200
+ option httpchk GET /readyz
201
+ http-check expect status 200
202
+ # la sonde vise le port HTTP en HTTP/1.1 : le port h2c refuse HTTP/1.1
203
+ server nodefony_h2c 10.0.0.12:5153 check port 5151 check-proto h1 proto h2
204
+ ```
90
205
 
91
- Les deux répondent **avant la limitation de débit**. Si tu obtiens un `429` sur une sonde, ce n'est
92
- pas la sonde que tu as appelée : vérifie que tu interroges bien le chemin nu, sans préfixe ajouté
93
- par une règle de réécriture.
206
+ Avec `--reencrypt`, h2c est ignoré : le port TLS négocie déjà HTTP/2. **C'est la seule cible jouée
207
+ contre un frontal réel** (HAProxy 3.0, configuration générée) : requête servie en h2c, sonde lue
208
+ sur le port HTTP, WebSocket ouverte sur le port HTTP.
94
209
 
95
- ## 6. En Kubernetes — l'Ingress, pas une configuration nginx
210
+ ### nginx — reste en HTTP/1.1 vers l'application
211
+
212
+ nginx ne parle pas HTTP/2 à ses amonts (hors gRPC) : `proxy:generate nginx` ignore h2c.
213
+
214
+ ### Envoy
215
+
216
+ Un cluster h2c pour les requêtes, un cluster HTTP/1.1 pour les WebSocket :
217
+
218
+ ```yaml
219
+ routes:
220
+ - match:
221
+ prefix: "/"
222
+ headers:
223
+ [
224
+ {
225
+ name: upgrade,
226
+ string_match: { exact: websocket, ignore_case: true },
227
+ },
228
+ ]
229
+ route:
230
+ { cluster: nodefony_http, upgrade_configs: [{ upgrade_type: websocket }] }
231
+ - match: { prefix: "/" }
232
+ route: { cluster: nodefony_h2c }
233
+ clusters:
234
+ - name: nodefony_h2c # port 5153
235
+ typed_extension_protocol_options:
236
+ envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
237
+ "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
238
+ explicit_http_config: { http2_protocol_options: {} }
239
+ - name: nodefony_http # port 5151, HTTP/1.1 par défaut
240
+ ```
241
+
242
+ ### Traefik
243
+
244
+ ```yaml
245
+ labels:
246
+ - "traefik.http.routers.app.rule=Host(`app.example.com`)"
247
+ - "traefik.http.routers.app.service=app-h2c"
248
+ - "traefik.http.services.app-h2c.loadbalancer.server.port=5153"
249
+ - "traefik.http.services.app-h2c.loadbalancer.server.scheme=h2c"
250
+ - "traefik.http.routers.app-ws.rule=Host(`app.example.com`) && HeaderRegexp(`Upgrade`, `(?i)^websocket$`)"
251
+ - "traefik.http.routers.app-ws.priority=100"
252
+ - "traefik.http.routers.app-ws.service=app-http"
253
+ - "traefik.http.services.app-http.loadbalancer.server.port=5151"
254
+ ```
255
+
256
+ > Envoy, Istio, Gateway API et Traefik : recettes tirées de leur documentation, **pas encore jouées
257
+ > contre un frontal réel**. Les éprouver avant de les déclarer justes.
258
+
259
+ ## 9. Traefik sans h2c
260
+
261
+ `proxy:generate` ne connaît que nginx et HAProxy. Avec Traefik, la déclaration vit avec les
262
+ conteneurs :
263
+
264
+ ```yaml
265
+ labels:
266
+ - "traefik.enable=true"
267
+ - "traefik.http.routers.app.rule=Host(`app.example.com`)"
268
+ - "traefik.http.routers.app.entrypoints=websecure"
269
+ - "traefik.http.routers.app.tls.certresolver=le"
270
+ - "traefik.http.services.app.loadbalancer.server.port=5151"
271
+ - "traefik.http.services.app.loadbalancer.responseforwarding.flushinterval=100ms"
272
+ - "traefik.http.services.app.loadbalancer.healthcheck.path=/readyz"
273
+ - "traefik.http.services.app.loadbalancer.healthcheck.interval=5s"
274
+ ```
275
+
276
+ Traefik relaie `Upgrade` de lui-même et ne croit les `X-Forwarded-*` entrants que des adresses
277
+ déclarées dans `entryPoints.<nom>.forwardedHeaders.trustedIPs` : derrière un équilibreur
278
+ d'hébergeur, déclarer cet amont, sinon l'adresse cliente est perdue.
279
+
280
+ ## 10. En Kubernetes — pas de configuration nginx générée
96
281
 
97
282
  Le frontal dérivé sert pour une machine, un compose, une machine virtuelle. **Dans un cluster, ce
98
- rôle appartient au contrôleur d'Ingress**, que tu as choisi et que l'application ne connaît pas.
283
+ rôle appartient au contrôleur d'entrée** (Ingress ou Gateway API), que l'application ne connaît pas.
99
284
 
100
- Donc : un `Ingress` standard — chemins, TLS, taille de corps en annotation — et non une
101
- configuration nginx générée, qui ne fonctionnerait qu'avec un seul contrôleur.
285
+ > 🔴 **ingress-nginx est retiré depuis mars 2026** (annonce du projet Kubernetes, novembre 2025) :
286
+ > plus de version, plus de correctif de sécurité. Les déploiements existants tournent encore, mais
287
+ > un nouveau déploiement vise la **Gateway API** ou un autre contrôleur.
102
288
 
103
- Ce qui reste vrai, et ce qui change :
289
+ | Point | Avec un frontal à toi | Avec un contrôleur d'entrée |
290
+ | ------------------------------- | ----------------------- | ---------------------------------------------------- |
291
+ | `NF__HTTP__TRUSTPROXY` | **obligatoire** | **obligatoire** — le CIDR des pods du contrôleur |
292
+ | `NF__HTTP__TRUSTEDHOSTS` | recommandé | recommandé |
293
+ | Configuration dérivée | oui | non — `Ingress`, `HTTPRoute` |
294
+ | Inactivité ≥ 300 s | posée par le générateur | **à poser toi-même** — sinon les WebSocket tombent |
295
+ | Taille de corps | dérivée | **à poser toi-même**, égale à celle de l'application |
296
+ | Qui sert les fichiers statiques | le frontal | l'application, ou un service dédié |
104
297
 
105
- | Point | Avec un frontal à toi | Avec un Ingress |
106
- | ------------------------------- | --------------------- | ---------------------------------- |
107
- | `NF__HTTP__TRUSTPROXY` | **obligatoire** | **obligatoire** |
108
- | `NF__HTTP__TRUSTEDHOSTS` | recommandé | recommandé |
109
- | Configuration dérivée | oui | non — un `Ingress` |
110
- | Qui sert les fichiers statiques | le frontal | l'application, ou un service dédié |
298
+ **Si tu restes sur ingress-nginx**, les réglages que le générateur aurait posés s'écrivent en
299
+ annotations (la WebSocket, elle, passe sans rien demander) :
300
+
301
+ ```yaml
302
+ metadata:
303
+ annotations:
304
+ nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
305
+ nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
306
+ nginx.ingress.kubernetes.io/proxy-buffering: "off" # flux SSE
307
+ nginx.ingress.kubernetes.io/proxy-body-size: "<taille de corps de l'application>"
308
+ ```
111
309
 
112
- ## 7. Sous Podman sans privilèges — ce qu'on ne peut pas réparer
310
+ **h2c sous Kubernetes** : c'est le `Service` qui déclare le protocole de chaque port, puis la route
311
+ envoie les requêtes au port h2c et `Upgrade: websocket` au port HTTP (`VirtualService` sous Istio,
312
+ `HTTPRoute` avec une règle sur l'en-tête `upgrade` sous Gateway API) :
313
+
314
+ ```yaml
315
+ apiVersion: v1
316
+ kind: Service
317
+ spec:
318
+ ports:
319
+ - name: http # sondes, WebSocket
320
+ port: 5151
321
+ appProtocol: http
322
+ - name: h2c
323
+ port: 5153
324
+ appProtocol: kubernetes.io/h2c # Gateway API ; Istio : http2
325
+ ```
326
+
327
+ Les sondes du `Deployment` visent le port **5151**, jamais 5153.
328
+
329
+ ## 11. Sous Podman sans privilèges — ce qu'on ne peut pas réparer
113
330
 
114
331
  L'adresse source du client **est perdue** : le trafic arrive depuis la passerelle de redirection.
115
332
  Derrière un frontal, l'en-tête d'adresse d'origine porte donc la passerelle, pas le client.
@@ -117,3 +334,20 @@ Derrière un frontal, l'en-tête d'adresse d'origine porte donc la passerelle, p
117
334
  Ce n'est pas un réglage à trouver : c'est une propriété du mode sans privilèges. **Énonce-le** —
118
335
  ton audit et ta limitation par adresse sont faux, sans aucune erreur pour le signaler. Voir
119
336
  [`podman.md`](podman.md).
337
+
338
+ ## 12. Symptôme → cause → correction
339
+
340
+ | Symptôme | Cause | Correction |
341
+ | -------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
342
+ | La WebSocket se ferme seule au bout d'une minute | l'inactivité du frontal est plus courte que le battement | régénérer ; sur un contrôleur d'entrée, `proxy-read-timeout` ≥ 300 s |
343
+ | La WebSocket ne s'ouvre pas, aucune erreur nulle part | `Upgrade`/`Connection` non relayés | régénérer ; sur une configuration écrite à la main, les ajouter |
344
+ | Depuis h2c, la WebSocket ne s'ouvre plus | le frontal l'envoie au port h2c | router `Upgrade: websocket` vers le port HTTP |
345
+ | Depuis h2c, le pod n'est jamais prêt | la sonde vise le port h2c, qui refuse HTTP/1.1 | sonder `/readyz` sur le port HTTP |
346
+ | Un flux SSE n'arrive qu'en bloc, ou à la fin | le frontal met la réponse en tampon | ne pas filtrer `X-Accel-Buffering` ; sur ingress-nginx, `proxy-buffering: "off"` |
347
+ | `413` sur un envoi que l'application accepte | limite de corps du frontal plus basse | régénérer ; sur un contrôleur d'entrée, `proxy-body-size` |
348
+ | Les statiques d'un module en `404` derrière nginx | une seule racine statique déclarée | régénérer : la chaîne de `try_files` vient des montages réels |
349
+ | Des `5xx` à chaque déploiement, plusieurs exemplaires | le frontal ne lit pas `/readyz` (§6), ou pas de délai avant drain | régénérer le frontal (sonde `/readyz`) + `shutdownDelay` |
350
+ | `429` en rafale juste après un déploiement | reconnexions comptées sur la seule adresse du frontal | `NF__HTTP__TRUSTPROXY` |
351
+ | Toutes les adresses des journaux sont celles du frontal | `NF__HTTP__TRUSTPROXY` absent — le défaut sûr | déclarer l'adresse ou le CIDR du frontal |
352
+ | Une adresse cliente **forgée** apparaît dans l'audit | confiance accordée alors que l'application est joignable directement | fermer l'accès direct, ne jamais poser `true` dans ce cas |
353
+ | `Invalid parameter: redirect_uri` en ligne, pas en local | `NF_OAUTH_REDIRECT_BASE` absente | la poser à l'URL publique de l'application |
@@ -21,14 +21,19 @@ qui n'existent pas.
21
21
 
22
22
  ## 2. Le contexte de sécurité — politique Restricted
23
23
 
24
- **Commence par lire `deploy/migrate-job.yaml` : son contexte de sécurité est déjà conforme.** Il
25
- est le seul modèle juste que tu aies sous la main, et il a été écrit pour être recopié.
26
-
27
- Six exigences, toutes portées par le **manifeste** — aucune n'est « héritée de l'image ». Ce que
28
- l'image apporte, c'est de les rendre satisfaisables sans contorsion : elle déclare
24
+ **Commence par lire `deploy/migrate-job.yaml` : il passe la politique Restricted.** Recopie-en le
25
+ contexte de sécurité — mais pas son `readOnlyRootFilesystem: false`, qui convient à un travail
26
+ éphémère et pas à un exemplaire qui sert du trafic.
27
+
28
+ Restricted exige **quatre** champs, tous portés par le **manifeste** — aucun n'est « hérité de
29
+ l'image » : `runAsNonRoot: true` (et un `runAsUser`, s'il est posé, différent de 0),
30
+ `allowPrivilegeEscalation: false`, `capabilities.drop: ["ALL"]`, `seccompProfile` `RuntimeDefault`.
31
+ Ce que l'image apporte, c'est de les rendre satisfaisables sans contorsion : elle déclare
29
32
  `USER 1000:1000`, donc l'identifiant numérique existe ; elle n'écoute qu'au-dessus de 1024, donc
30
- aucune capacité n'est nécessaire. Cinq ne coûtent rien à poser. **La sixième —
31
- `readOnlyRootFilesystem` — demande les deux volumes**, et c'est la seule qui se travaille.
33
+ aucune capacité n'est nécessaire. Aucun ne coûte rien.
34
+
35
+ **`readOnlyRootFilesystem: true` n'est PAS exigé par Restricted** : c'est un durcissement en plus,
36
+ recommandé, et le seul qui se travaille — il demande les deux volumes.
32
37
 
33
38
  ```yaml
34
39
  spec:
@@ -69,8 +74,10 @@ kubectl label ns <ns> pod-security.kubernetes.io/enforce=restricted
69
74
  kubectl apply -f <ton-deployment>.yaml # doit passer SANS avertissement
70
75
  ```
71
76
 
72
- Puis **débranche** : retire un `emptyDir`, réapplique. Le pod ne doit plus démarrer. Une politique
73
- qu'on n'a jamais vue refuser quelque chose n'est pas une politique.
77
+ Puis **débranche** : retire `seccompProfile`, réapplique. L'admission doit **refuser** le pod. Une
78
+ politique qu'on n'a jamais vue refuser quelque chose n'est pas une politique. (Retirer un
79
+ `emptyDir` fait échouer le **démarrage**, pas l'admission : c'est une autre preuve, celle des
80
+ volumes.)
74
81
 
75
82
  ## 3. Les sondes
76
83
 
@@ -93,6 +100,8 @@ livenessProbe: { httpGet: { path: /livez, port: 5151 }, periodSeconds: 10 }
93
100
  son arrêt propre — exactement ce qu'on cherche à éviter.
94
101
  - Les deux répondent **avant la limitation de débit** : un kubelet limité recevrait `429` et
95
102
  conclurait à un échec.
103
+ - **Toujours le port HTTP (5151)**, même si h2c est actif : le port h2c (5153) refuse HTTP/1.1,
104
+ la langue d'une sonde de kubelet. Un pod qui sonde 5153 n'est jamais prêt.
96
105
 
97
106
  ## 4. L'arrêt — ne perdre aucune requête
98
107
 
@@ -130,6 +139,11 @@ resources:
130
139
  processeur ne tue pas — elle **ralentit en permanence**, et ça ne ressemble pas à une limite, ça
131
140
  ressemble à une application lente. Les demandes suffisent à garantir la part.
132
141
 
142
+ Ces valeurs sont un **départ** pour un trafic modeste. Un pod qui doit tenir son plein débit
143
+ demande environ **un cœur** (`requests.cpu: "1"`) : ton code tourne sur une seule boucle. Combien
144
+ de pods, quelle mémoire par pod, et pourquoi c'est souvent le temps réel qui décide →
145
+ [`dimensionnement.md`](dimensionnement.md).
146
+
133
147
  Si tu bornes le tas de la machine virtuelle JavaScript face à une limite mémoire de cgroup,
134
148
  dimensionne-le avec une mesure, pas au jugé.
135
149
 
@@ -141,8 +155,10 @@ C'est la différence la plus structurante avec le compose de développement.
141
155
  2. **Ne reporte jamais l'hôte du compose.** `compose.yaml` pointe un **service du compose** ; le
142
156
  même nom dans un cluster désigne un Service qui n'existe pas. Rien ne casse au déploiement, tout
143
157
  casse au premier accès.
144
- 3. **L'URL passe par un Secret, en `_FILE`** — elle porte un mot de passe, elle n'a rien à faire
145
- dans un ConfigMap.
158
+ 3. **L'URL passe par un Secret** — elle porte un mot de passe, elle n'a rien à faire dans un
159
+ ConfigMap. Deux formes, au choix : `valueFrom.secretKeyRef` (ce que fait
160
+ `deploy/migrate-job.yaml`) ou le Secret monté en fichier et `NF_DATABASE_URL_FILE`. Ne pose
161
+ jamais les deux : `NF_DATABASE_URL` et `NF_DATABASE_URL_FILE` ensemble arrêtent le démarrage.
146
162
  4. **Le plafond de connexions.** Une base administrée les compte strictement. N exemplaires × le
147
163
  pool de chacun peuvent la saturer, et l'erreur sort de **son** côté, pas du tien. Reste prudent
148
164
  sur le nombre d'exemplaires.
@@ -164,14 +180,25 @@ env:
164
180
  - { name: NF_POD_NAME, valueFrom: { fieldRef: { fieldPath: metadata.name } } }
165
181
  - { name: NF__HTTP__TRUSTPROXY, value: uniquelocal }
166
182
  - { name: NF_CSRF_SECRET_FILE, value: /run/secrets/csrf }
167
- - { name: NF_DATABASE_URL_FILE, value: /run/secrets/db-url }
183
+ - name: NF_DATABASE_URL # ou NF_DATABASE_URL_FILE vers un Secret monté (§6)
184
+ valueFrom: { secretKeyRef: { name: <app>-db, key: url } }
185
+ # plusieurs exemplaires + temps réel : un bus partagé, sinon chaque pod parle seul
186
+ - { name: NF_REALTIME_DRIVER, value: redis }
187
+ - name: NF_REDIS_URL
188
+ valueFrom: { secretKeyRef: { name: <app>-redis, key: url } }
168
189
  ```
169
190
 
170
191
  `NF_POD_NAME` vient de l'API descendante. Elle dérive l'identité d'origine du bus temps réel : sans
171
192
  elle, plusieurs exemplaires se confondent et les messages s'écoutent eux-mêmes.
172
193
 
173
- `NF__HTTP__TRUSTPROXY` est **obligatoire** dès qu'un Ingress termine le TLS —
174
- [`frontal.md`](frontal.md) dit ce qui tombe sans lui, et comment le voir tomber.
194
+ `NF_REALTIME_DRIVER=redis` n'est utile qu'à partir de **deux** exemplaires : avec un seul, le bus
195
+ local suffit. Sans lui à deux exemplaires, un message n'atteint que les clients du pod qui l'émet,
196
+ **sans erreur** — détail dans [`dimensionnement.md`](dimensionnement.md) §4.
197
+
198
+ `NF__HTTP__TRUSTPROXY` est **obligatoire** dès qu'un contrôleur d'entrée termine le TLS —
199
+ [`frontal.md`](frontal.md) dit ce qui tombe sans lui, et comment le voir tomber. L'Ingress ou la
200
+ Gateway API, les annotations d'inactivité qui gardent les WebSocket, et le `Service` à deux ports
201
+ pour h2c : [`frontal.md`](frontal.md) §10.
175
202
 
176
203
  ## 8. Éprouver sans cluster de production
177
204
 
@@ -24,7 +24,7 @@ Il est rendu avec l'application, et il exclut quatre familles. Ne les retire pas
24
24
  | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
25
25
  | `**/node_modules`, `**/dist` | Reconstruits **dans** l'image. Entrés depuis ta machine, ils masqueraient la construction et l'image partirait avec le code de la veille. |
26
26
  | `var`, `logs`, `**/*.log`, `**/*.jsonl` | Écritures propres à une machine, sans valeur dans une image |
27
- | `*.local`, `**/*.local` | La convention des fichiers de secrets locaux — jamais commités, et pas davantage dans une image |
27
+ | `.env`, `.env.*` (sauf `.env.example`) | Les valeurs du poste, secrets de dev compris — jamais commitées, et pas davantage dans une image |
28
28
  | **La matière cryptographique** | Clés privées, certificats |
29
29
 
30
30
  > ⚠️ **Un motif de `.dockerignore` n'obéit pas aux règles du `.gitignore`.** Sans `**/`, il est
@@ -47,19 +47,44 @@ qu'aucun certificat auto-signé de ta machine ne descende en production.
47
47
  | `NF_CSRF_SECRET` | Les jetons anti-CSRF | **oui** — sinon un jeton émis par un exemplaire est refusé par l'autre |
48
48
  | `NF_TOTP_KEY` | Le chiffrement au repos des secrets de double authentification | **oui** — sinon les seconds facteurs deviennent illisibles |
49
49
  | `NF_WEBHOOK_KEY` | Le chiffrement des signatures de webhooks | **oui** |
50
+ | `NF_JWT_KEYSET` | La clé qui signe les jetons (privée) | **oui** — sinon chaque exemplaire refuse les jetons des autres |
50
51
  | `NF_ADMIN_PASSWORD` | Le compte d'administration | — |
51
52
 
52
53
  > 🔴 **Le partage entre exemplaires n'est pas un détail de confort.** Un secret différent par
53
54
  > exemplaire produit des échecs **intermittents** — une requête sur deux, selon le répartiteur.
54
55
  > C'est le symptôme le plus coûteux à diagnostiquer, parce qu'il ressemble à un problème réseau.
55
56
 
56
- `NF_ADMIN_PASSWORD` a un défaut en développement. **En production il est obligatoire** : sans lui,
57
- aucun compte d'administration n'est créé et le démarrage échoue — volontairement, plutôt que de
58
- créer un compte avec un mot de passe connu de tout le monde.
57
+ **Lesquels arrêtent le démarrage en production** : `NF_CSRF_SECRET` toujours, `NF_JWT_KEYSET`
58
+ dès que l'application émet des jetons. Sans `NF_TOTP_KEY` ou `NF_WEBHOOK_KEY`, l'application
59
+ démarre mais la brique se désactive (double authentification, webhooks), avec un journal CRITIC.
60
+
61
+ ```bash
62
+ npx nodefony security:secrets --env # les secrets, en lignes CLÉ=valeur — pour un `docker --env-file` ou un gestionnaire
63
+ npx nodefony security:secrets --jwt-keyset # la valeur de NF_JWT_KEYSET — une ligne JSON, clé PRIVÉE
64
+ ```
65
+
66
+ Une valeur par ENVIRONNEMENT (staging ≠ production), générée une fois, rangée dans le
67
+ gestionnaire de secrets ; ne laisse traîner aucun fichier qui la contient.
68
+
69
+ **En intégration continue**, la CI générée (preset `complete`) tire ces clés à CHAQUE exécution,
70
+ juste avant `test:e2e` : `security:secrets --env >> "$GITHUB_ENV"` sous GitHub, une boucle
71
+ `export "$ligne"` sous GitLab. Tirées pour un run, elles ne protègent rien — elles n'ont rien à
72
+ faire dans les `secrets` du dépôt. Un `CRITIC … AUCUNE clé de chiffrement` dans le journal de
73
+ l'e2e veut dire que l'étape a sauté : la suite est verte sur une production amputée.
74
+
75
+ > 🔴 **Ne jamais charger ces lignes par `set -a && . fichier`.** Le shell interprète les
76
+ > guillemets du JSON de `NF_JWT_KEYSET` et le rend illisible. `docker run --env-file`, un
77
+ > `env_file:` de compose ou `export "$ligne"` ligne à ligne gardent la valeur telle quelle.
78
+
79
+ `NF_ADMIN_PASSWORD` a un défaut en développement, aucun en production : sans lui, le premier
80
+ démarrage ne crée **aucun** compte d'administration — plutôt qu'un compte au mot de passe connu de
81
+ tout le monde. Il ne sert qu'à ce premier démarrage ; `npx nodefony security:user:add <id> --admin`
82
+ fait le même geste à la main.
59
83
 
60
84
  ## 5. Les monter, pas les poser en clair
61
85
 
62
- Le suffixe `_FILE` marche sur **toute** variable — détail complet dans
86
+ Le suffixe `_FILE` marche sur les variables du catalogue, URL de base et de cache comprises —
87
+ l'URL d'une base porte un mot de passe, c'est elle qu'on monte en premier. Détail complet dans
63
88
  [`variables.md`](variables.md). Ce qu'il faut retenir ici :
64
89
 
65
90
  ```yaml