feat(infra): reverse proxy Nginx et terminaison TLS devant la stack

Le SPA appelle /api/v1 en relatif et rien ne routait cet appel vers l'API
une fois en conteneur. Le cookie de rafraîchissement prend le préfixe
__Secure- dès que APP_ENV sort de local, donc sans HTTPS il n'était jamais
posé et l'authentification ne survivait pas à un rechargement de page.

Un service proxy, image officielle nginx dont la configuration est montée en
volume, devient le seul composant publié : 80 redirige vers 443 et sert le
défi ACME, 443 termine le TLS, sert le SPA sur / et l'API sur /api/ sous la
même origine, pose HSTS et CSP que l'application refuse délibérément de
poser, et ajoute une limitation de débit au frontal. Backend et frontend ne
sont plus publiés, la base et l'interface Mailpit sont ramenées sur la
boucle locale.

nginx lit toujours les deux mêmes fichiers de certificat : seule leur
fabrication varie, script openssl pour la démonstration, deploy-hook certbot
le jour où un domaine public existera. Le chemin ACME est livré et
documenté, pas exercé : sur une IP privée le défi HTTP-01 ne peut pas
aboutir.
This commit is contained in:
Johan LEROY
2026-09-21 09:51:09 +02:00
parent 2d7b4bd74d
commit b3efb98208
11 changed files with 363 additions and 1 deletions
+10
View File
@@ -17,3 +17,13 @@ APP_LOG_LEVEL=INFO
APP_SECRET_KEY=change_me
APP_CORS_ORIGINS=http://localhost:4200
BACKEND_PORT=8000
FRONTEND_PORT=3000
# Mailpit capture les courriels du backend, rien ne sort vers l'extérieur.
MAILPIT_SMTP_PORT=1025
MAILPIT_UI_PORT=8025
# Stack complète derrière le reverse proxy (docker-compose.prod.yml).
# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat.
PUBLIC_HOST=enervision.local
ACME_EMAIL=
+6
View File
@@ -38,3 +38,9 @@ updates:
directory: "/apps/frontend"
schedule:
interval: "weekly"
# Images du reverse proxy et du compagnon ACME, épinglées dans les fichiers Compose
- package-ecosystem: "docker-compose"
directory: "/"
schedule:
interval: "weekly"
+3
View File
@@ -66,6 +66,9 @@ ml/mlruns/
ml/mlartifacts/
ml/mlflow.db
# TLS : certificats du reverse proxy, générés par script ou par certbot
infra/proxy/tls/*.pem
# IDE et OS
.idea/
.vscode/
+37 -1
View File
@@ -1,12 +1,23 @@
BACKEND := apps/backend
FRONTEND := apps/frontend
ML := ml
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
export PUBLIC_HOST
endif
ifdef ACME_EMAIL
export ACME_EMAIL
endif
.DEFAULT_GOAL := help
.PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \
lint format typecheck test test-cov test-integration check \
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
ml-lint ml-typecheck ml-test ml-check ml-train ml-score
ml-lint ml-typecheck ml-test ml-check ml-train ml-score \
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs
help: ## Liste les cibles disponibles
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
@@ -80,6 +91,31 @@ ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=che
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,)
stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... requis
@test -f infra/proxy/tls/fullchain.pem \
|| { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; }
$(COMPOSE_PROD) up -d --build
stack-down: ## Arrête la stack complète en conservant les données
$(COMPOSE_PROD) stop
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
$(COMPOSE_PROD) --profile acme run --rm certbot certonly --webroot -w /var/www/certbot \
-d $${PUBLIC_HOST:?PUBLIC_HOST=... requis} \
--email $${ACME_EMAIL:?ACME_EMAIL=... requis} \
--agree-tos --no-eff-email --deploy-hook /deploy-hook.sh
$(COMPOSE_PROD) exec proxy nginx -s reload
tls-renew: ## Renouvelle les certificats Let's Encrypt et recharge le proxy
$(COMPOSE_PROD) --profile acme run --rm certbot renew --deploy-hook /deploy-hook.sh
$(COMPOSE_PROD) exec proxy nginx -s reload
db-up: ## Démarre la base PostgreSQL TimescaleDB
docker compose up -d db
+66
View File
@@ -0,0 +1,66 @@
# Piège : `APP_ENV` et `APP_DEBUG` sont en dur et non en `${APP_ENV:-prod}` : le `.env` du poste
# vaut `local` et reprendrait le dessus, ce qui laisserait le cookie sans `__Secure-` et
# 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.
name: enervision
services:
db:
ports: !override
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
mailpit:
ports: !override
- "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
backend:
ports: !reset null
command:
- uvicorn
- app.main:create_app
- --factory
- --host
- 0.0.0.0
- --port
- "8000"
- --proxy-headers
- --forwarded-allow-ips=*
environment:
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
frontend:
ports: !reset null
proxy:
image: nginx:1.28-alpine
depends_on:
- backend
- frontend
ports:
- "80:80"
- "443:443"
volumes:
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
- ./infra/proxy/tls:/etc/nginx/tls:ro
- acme_webroot:/var/www/certbot
restart: unless-stopped
certbot:
image: certbot/certbot
profiles: ["acme"]
volumes:
- letsencrypt:/etc/letsencrypt
- acme_webroot:/var/www/certbot
- ./infra/proxy/tls:/tls
- ./infra/proxy/acme-deploy-hook.sh:/deploy-hook.sh:ro
volumes:
acme_webroot:
letsencrypt:
+75
View File
@@ -0,0 +1,75 @@
# Reverse proxy
Terminaison TLS et routage de la stack déployée. Seul composant publié sur le réseau : il
écoute en 80 et 443, et rien d'autre ne sort du réseau Compose.
- `nginx.conf` : bloc `http`, journalisation, compression, zones de limitation de débit.
- `conf.d/enervision.conf` : redirection 80 vers 443, terminaison TLS, en-têtes de sécurité,
routage.
- `tls/` : les deux fichiers que nginx lit, `fullchain.pem` et `privkey.pem`. Ignorés par git.
- `acme-deploy-hook.sh` : recopie le résultat de certbot dans `tls/`.
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`.
## 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 |
| `/` | `frontend:3000` | Le SPA, qui renvoie `index.html` sur les routes inconnues |
`/docs`, `/redoc`, `/openapi.json`, `/static` et `/metrics` sont montés par l'API **à la racine**,
pas sous `/api`. Ils tombent donc dans `location /`, donc sur le SPA : ils ne sont pas joignables
depuis l'extérieur, sans qu'aucune règle de blocage ait à être écrite. Y toucher, c'est les
exposer.
## Certificat : deux modes, un seul emplacement
nginx lit toujours `tls/fullchain.pem` et `tls/privkey.pem`. Seule leur fabrication change, la
configuration n'a jamais à bouger.
### Démonstration, certificat auto-signé
```bash
make tls-selfsigned PUBLIC_HOST=enervision.local
make stack-up
```
Le navigateur avertira d'un émetteur inconnu : c'est attendu, et c'est le seul mode exploitable
tant que la machine cible n'a pas de nom de domaine public.
### Let's Encrypt
Le défi HTTP-01 exige un nom de domaine **résolvable publiquement** et le port 80 joignable
depuis Internet. La cible documentée aujourd'hui (`ssh_host = "10.0.0.10"`, serveur de l'école)
ne remplit ni l'une ni l'autre condition : le chemin ci-dessous est livré et documenté, il n'a
pas été exercé.
```bash
make stack-up # nginx doit tourner pour servir le défi
make tls-acme PUBLIC_HOST=enervision.fr ACME_EMAIL=ops@enervision.fr
```
Renouvellement, à passer en tâche planifiée sur la machine :
```cron
17 3 * * * cd /srv/enervision && make tls-renew >> /var/log/enervision-tls.log 2>&1
```
Pour un domaine sans port 80 entrant, le défi DNS-01 est l'alternative : elle demande un
greffon certbot propre au fournisseur DNS et un jeton d'API, hors périmètre à ce jour.
## Vérifier la configuration sans démarrer la stack
```bash
docker run --rm \
-v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \
-v "$PWD/infra/proxy/conf.d:/etc/nginx/conf.d:ro" \
-v "$PWD/infra/proxy/tls:/etc/nginx/tls:ro" \
nginx:1.28-alpine nginx -t
```
Monter `infra/proxy/` entier sur `/etc/nginx` échouerait : `mime.types` vient de l'image.
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# Contrainte : certbot écrit dans /etc/letsencrypt/live/<domaine>/, nginx lit /etc/nginx/tls/.
# Ce hook recopie le résultat à l'emplacement unique que la configuration nginx connaît, ce
# qui rend le mode auto-signé et le mode ACME interchangeables sans toucher à un vhost.
set -eu
cp -L "$RENEWED_LINEAGE/fullchain.pem" /tls/fullchain.pem
cp -L "$RENEWED_LINEAGE/privkey.pem" /tls/privkey.pem
chmod 644 /tls/fullchain.pem
chmod 600 /tls/privkey.pem
+65
View File
@@ -0,0 +1,65 @@
# Piège : `X-Forwarded-For` se construit avec `$proxy_add_x_forwarded_for`, qui ajoute l'IP
# réelle en fin de chaîne. `get_client_ip()` (apps/backend/app/api/deps.py) ne lit que le
# dernier élément : toute autre forme rend la limitation de débit par IP globale, donc le
# déni de service auto-infligé que ce code cherche précisément à éviter.
# 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.
server {
listen 80 default_server;
server_name _;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl default_server;
http2 on;
server_name _;
resolver 127.0.0.11 valid=10s ipv6=off;
ssl_certificate /etc/nginx/tls/fullchain.pem;
ssl_certificate_key /etc/nginx/tls/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# L'application refuse délibérément de poser ces deux en-têtes, verrouillé par
# tests/api/test_hardening.py. Ils appartiennent au terminateur TLS, c'est-à-dire ici.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
location /api/v1/auth/ {
limit_req zone=auth burst=20 nodelay;
set $cible_api http://backend:8000;
proxy_pass $cible_api$request_uri;
}
location /api/ {
limit_req zone=api burst=40 nodelay;
set $cible_api http://backend:8000;
proxy_pass $cible_api$request_uri;
}
location / {
set $cible_web http://frontend:3000;
proxy_pass $cible_web$request_uri;
}
}
+40
View File
@@ -0,0 +1,40 @@
# Contrainte : les directives `limit_req_zone` ne sont valides que dans le bloc `http`.
# Les `location` de conf.d/enervision.conf s'y réfèrent par nom, `api` et `auth`.
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
log_format enervision '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent $request_time '
'"$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access.log enervision;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
client_max_body_size 2m;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types application/javascript application/json application/xml
image/svg+xml text/css text/plain;
limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=30r/m;
limit_req_status 429;
include /etc/nginx/conf.d/*.conf;
}
View File
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Contrainte : nginx lit toujours infra/proxy/tls/{fullchain,privkey}.pem, quel que soit le
# mode d'obtention. Ce script remplit ces deux fichiers pour la démonstration, certbot les
# remplit par acme-deploy-hook.sh. La configuration nginx ne connaît pas la différence.
set -euo pipefail
RACINE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DESTINATION="$RACINE/infra/proxy/tls"
HOTE="${PUBLIC_HOST:-enervision.local}"
ADRESSE="${PUBLIC_IP:-}"
JOURS="${TLS_DAYS:-365}"
ECRASER=0
for argument in "$@"; do
case "$argument" in
--force) ECRASER=1 ;;
*)
echo "Usage : PUBLIC_HOST=exemple.local [PUBLIC_IP=10.0.0.10] $0 [--force]" >&2
exit 2
;;
esac
done
if [[ -f "$DESTINATION/fullchain.pem" && $ECRASER -eq 0 ]]; then
echo "Un certificat existe déjà dans $DESTINATION." >&2
echo "Relancer avec --force pour l'écraser." >&2
exit 1
fi
mkdir -p "$DESTINATION"
NOMS="DNS:$HOTE,DNS:localhost"
if [[ -n "$ADRESSE" ]]; then
NOMS="$NOMS,IP:$ADRESSE"
fi
openssl req -x509 -nodes -newkey rsa:2048 -sha256 -days "$JOURS" \
-subj "/CN=$HOTE" \
-addext "subjectAltName=$NOMS" \
-keyout "$DESTINATION/privkey.pem" \
-out "$DESTINATION/fullchain.pem" 2>/dev/null
chmod 600 "$DESTINATION/privkey.pem"
chmod 644 "$DESTINATION/fullchain.pem"
echo "Certificat auto-signé écrit dans $DESTINATION."
echo " Noms couverts : $NOMS"
echo " Validité : $JOURS jours"
echo "Le navigateur avertira d'un émetteur inconnu, c'est attendu hors Let's Encrypt."