diff --git a/Makefile b/Makefile
index 763e5f5..5be293d 100644
--- a/Makefile
+++ b/Makefile
@@ -5,10 +5,10 @@ AIRFLOW := etl/airflow
COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml
# Piège : sans `export`, une valeur passée en ligne de commande n'atteindrait pas docker compose.
-# Le `ifdef` évite d'exporter une valeur vide, qui masquerait alors celle du fichier `.env`.
-ifdef PUBLIC_HOST
+# PUBLIC_HOST retombe sur le `.env`, que make ne lit pas, puis sur la valeur de `.env.example`.
+PUBLIC_HOST ?= $(shell sed -n 's/^PUBLIC_HOST=//p' .env 2>/dev/null | tail -1)
+PUBLIC_HOST := $(or $(strip $(PUBLIC_HOST)),enervision.local)
export PUBLIC_HOST
-endif
ifdef ACME_EMAIL
export ACME_EMAIL
endif
@@ -119,11 +119,13 @@ docker-build: ## Construit l'image du backend
docker build -t enervision-backend:local $(BACKEND)
tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser
- PUBLIC_HOST=$${PUBLIC_HOST:-enervision.local} ./scripts/tls-selfsigned.sh $(if $(FORCE),--force,)
+ ./scripts/tls-selfsigned.sh $(if $(FORCE),--force,)
-stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... requis
+stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... au besoin
@test -f infra/proxy/tls/fullchain.pem \
|| { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; }
+ @openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \
+ || { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; }
$(COMPOSE_PROD) up -d --build
stack-down: ## Arrête la stack complète en conservant les données
@@ -132,9 +134,11 @@ stack-down: ## Arrête la stack complète en conservant les données
stack-logs: ## Suit les journaux du reverse proxy
$(COMPOSE_PROD) logs -f proxy
-tls-acme: ## Demande un certificat Let's Encrypt. PUBLIC_HOST et ACME_EMAIL requis
+tls-acme: ## Demande un certificat Let's Encrypt. PUBLIC_HOST public et ACME_EMAIL requis
+ @test "$(PUBLIC_HOST)" != enervision.local \
+ || { echo "PUBLIC_HOST doit être un domaine public résolvable, pas le nom de démonstration"; exit 1; }
$(COMPOSE_PROD) --profile acme run --rm certbot certonly --webroot -w /var/www/certbot \
- -d $${PUBLIC_HOST:?PUBLIC_HOST=... requis} \
+ -d $(PUBLIC_HOST) \
--email $${ACME_EMAIL:?ACME_EMAIL=... requis} \
--agree-tos --no-eff-email --deploy-hook /deploy-hook.sh
$(COMPOSE_PROD) exec proxy nginx -s reload
diff --git a/README.md b/README.md
index 7e1181e..e560c94 100644
--- a/README.md
+++ b/README.md
@@ -103,7 +103,8 @@ curl -s localhost:8000/api/v1/health/ready
## Stack complète derrière le reverse proxy
-Pour servir l'application comme sur la machine cible, en HTTPS et sous une seule origine :
+Pour servir l'application comme sur la machine cible, en HTTPS et sous une seule origine.
+L'overlay emploie `!override` et `!reset`, donc **Docker Compose 2.24.4 ou plus récent** :
```bash
make tls-selfsigned PUBLIC_HOST=enervision.local # certificat de démonstration
diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json
index 981779f..8e508c2 100644
--- a/apps/frontend/angular.json
+++ b/apps/frontend/angular.json
@@ -34,6 +34,11 @@
},
"configurations": {
"production": {
+ "optimization": {
+ "styles": {
+ "inlineCritical": false
+ }
+ },
"budgets": [
{
"type": "initial",
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 4f2d425..868ae17 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -3,6 +3,8 @@
# rouvrirait `/docs`. Hors `local`, l'API exige en retour une origine CORS non vide.
# Piège : les listes de ports se cumulent à la fusion des deux fichiers. `!reset` est le seul
# moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy.
+# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour
+# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi.
name: enervision
@@ -35,8 +37,8 @@ services:
APP_ENV: prod
APP_DEBUG: "false"
APP_TRUST_PROXY_HEADERS: "true"
- APP_CORS_ORIGINS: https://${PUBLIC_HOST:?PUBLIC_HOST est requis pour la stack complète}
- APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST}/reset-password
+ APP_CORS_ORIGINS: https://${PUBLIC_HOST:-enervision.local}
+ APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST:-enervision.local}/reset-password
frontend:
ports: !reset null
@@ -44,8 +46,10 @@ services:
proxy:
image: nginx:1.28-alpine
depends_on:
- - backend
- - frontend
+ backend:
+ condition: service_healthy
+ frontend:
+ condition: service_started
ports:
- "80:80"
- "443:443"
@@ -57,7 +61,7 @@ services:
restart: unless-stopped
certbot:
- image: certbot/certbot
+ image: certbot/certbot:v5.8.0
profiles: ["acme"]
volumes:
- letsencrypt:/etc/letsencrypt
diff --git a/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md b/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md
index 1d5eab4..014f95e 100644
--- a/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md
+++ b/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md
@@ -73,8 +73,30 @@ Swagger, il publiera les métriques avec.
`$proxy_add_x_forwarded_for`, qui ajoute l'IP réelle en fin de chaîne. C'est exactement ce que
lit `get_client_ip()`. Toute autre forme ferait compter la limitation de débit par IP sur l'IP
du proxy, c'est-à-dire globalement.
+- `--forwarded-allow-ips=*` reste sans conséquence : uvicorn s'en sert pour réécrire
+ `request.client` depuis `X-Forwarded-For`, et `get_client_ip()` est le seul lecteur de
+ `request.client` du backend, en dernier recours quand l'en-tête est absent.
- Une limitation de débit au frontal existe désormais, distincte de celle de l'application : 20
- requêtes par seconde sur l'API, 30 par minute sur `/api/v1/auth/`.
+ requêtes par seconde sur l'API, et 30 par minute sur les seules routes qui vérifient un secret,
+ `login`, `password`, `forgot-password` et `reset-password`. `/auth/me` et `/auth/refresh` en
+ sont exclues : elles partent à chaque chargement de page, et le NAT de l'école donnant une seule
+ adresse à toute la promotion, la zone resserrée les aurait transformées en 429 en démonstration.
+- **La CSP contraint le build du frontend.** `script-src 'self'` interdit les gestionnaires
+ d'événements en ligne, et l'inlining du CSS critique d'Angular produisait exactement cela :
+ ``. La feuille serait restée en
+ `media="print"`, donc l'application entière sans style. D'où `styles.inlineCritical: false` dans
+ `angular.json`. `style-src` garde `'unsafe-inline'`, dont Angular a besoin pour les styles de
+ composants injectés à l'exécution.
+- **La redirection 80 vers 443 conserve `$host`.** Un client qui forge son en-tête `Host` obtient
+ donc une redirection vers l'hôte de son choix. Risque accepté : un navigateur ne peut pas être
+ amené à envoyer un `Host` étranger, aucun cache ne s'intercale, et figer un nom canonique
+ couperait l'accès par adresse IP, seule voie ouverte sur `10.0.0.10`.
+- **Aucun `:?` dans l'overlay.** Compose interpole tout le fichier avant n'importe quelle
+ sous-commande : une garde y casserait `stop` et `logs` autant que `up`. `PUBLIC_HOST` retombe
+ donc sur `enervision.local`, et `make stack-up` vérifie à la place que le certificat présent
+ couvre l'hôte demandé, ce qui est la condition réelle à tenir.
+- Le proxy attend une API saine et pas seulement démarrée : le `HEALTHCHECK` de l'image du backend
+ sert de condition à `depends_on`, faute de quoi les premiers appels à `/api/` répondent 502.
- La ligne API8 transport de `owasp-traceabilite.md` se referme.
- **Let's Encrypt n'est pas prouvé.** Le chemin ACME est livré, monté et documenté ; il n'a pas
été exercé faute de domaine. Le certificat de démonstration est auto-signé, le navigateur
diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md
index ee0b6f8..14a7012 100644
--- a/docs/architecture/30-frontend.md
+++ b/docs/architecture/30-frontend.md
@@ -134,6 +134,13 @@ Compose.
`/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le
rafraîchissement sur 401. Détail complet dans
[31-contrat-authentification.md](31-contrat-authentification.md).
+- **La CSP posée par le reverse proxy contraint le build.** `script-src 'self'` interdit les
+ gestionnaires d'événements en ligne ; l'inlining du CSS critique en produisait un
+ (``), ce qui aurait laissé l'application sans
+ style derrière le proxy. D'où `optimization.styles.inlineCritical: false` dans la configuration
+ de production d'`angular.json`. La contrepartie est un rendu non stylé très bref au premier
+ affichage. `style-src` conserve `'unsafe-inline'` : Angular injecte les styles de composants à
+ l'exécution, et s'en passer demanderait un `ngCspNonce` que le SPA statique ne peut pas produire.
## Tests
diff --git a/infra/proxy/README.md b/infra/proxy/README.md
index cb33d5f..558e8b9 100644
--- a/infra/proxy/README.md
+++ b/infra/proxy/README.md
@@ -12,15 +12,22 @@ Terminaison TLS et routage de la stack déployée. Seul composant publié sur le
Pas de `Dockerfile` : l'image officielle `nginx:1.28-alpine` est utilisée telle quelle et la
configuration est montée en volume par `docker-compose.prod.yml`.
+L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4
+ou plus récent**. Sur une version antérieure, la fusion échoue au lieu de dépublier les ports.
+
## Routage
| Chemin | Destination | Remarque |
|---|---|---|
| `/.well-known/acme-challenge/` | `/var/www/certbot` sur le port 80 | Seul chemin non redirigé vers HTTPS |
-| `/api/v1/auth/` | `backend:8000` | Limitation de débit resserrée, 30 requêtes par minute |
-| `/api/` | `backend:8000` | Préfixe `/api/v1` préservé tel quel |
+| `/api/v1/auth/` + `login`, `password`, `forgot-password`, `reset-password` | `backend:8000` | Zone resserrée, 30 requêtes par minute |
+| `/api/` | `backend:8000` | Préfixe `/api/v1` préservé tel quel, 20 requêtes par seconde |
| `/` | `frontend:3000` | Le SPA, qui renvoie `index.html` sur les routes inconnues |
+La zone resserrée ne couvre que les routes qui vérifient un secret. `/auth/me` et `/auth/refresh`
+partent à chaque chargement de page et restent dans la zone générale : derrière un NAT, où une
+seule adresse porte tous les postes, les y soumettre aurait produit des 429 en usage normal.
+
L'interface Airflow, celle de Mailpit et la base ne passent pas par le proxy : l'overlay les
ramène sur `127.0.0.1`, donc joignables par tunnel SSH et pas autrement. Les publier derrière le
proxy demanderait une authentification propre, qui n'est pas la leur.
@@ -68,6 +75,8 @@ greffon certbot propre au fournisseur DNS et un jeton d'API, hors périmètre à
## Vérifier la configuration sans démarrer la stack
+`nginx -t` charge les certificats : `tls/` doit être rempli, par `make tls-selfsigned` au besoin.
+
```bash
docker run --rm \
-v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \
diff --git a/infra/proxy/conf.d/enervision.conf b/infra/proxy/conf.d/enervision.conf
index cbadfaa..d3f067e 100644
--- a/infra/proxy/conf.d/enervision.conf
+++ b/infra/proxy/conf.d/enervision.conf
@@ -5,6 +5,8 @@
# Piège : un nom d'hôte littéral dans `proxy_pass` fige l'IP du conteneur au démarrage de
# nginx, et recréer `backend` seul donnerait des 502 jusqu'au rechargement du proxy. D'où la
# variable et le résolveur interne de Docker : la résolution redevient dynamique.
+# Pourquoi : la redirection 80 vers 443 conserve `$host` plutôt qu'un nom canonique, faute de
+# quoi l'accès par IP cesserait de fonctionner sur la cible. Risque acté dans l'ADR 0007.
server {
listen 80 default_server;
@@ -46,7 +48,9 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
- location /api/v1/auth/ {
+ # Piège : la zone `auth` ne couvre que les routes qui vérifient un secret. Derrière le NAT de
+ # l'école, `/auth/me` et `/auth/refresh` y produiraient des 429 à chaque chargement de page.
+ location ~ ^/api/v1/auth/(login|password|forgot-password|reset-password)$ {
limit_req zone=auth burst=20 nodelay;
set $cible_api http://backend:8000;
proxy_pass $cible_api$request_uri;