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:
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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.
|
||||
Executable
+11
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+50
@@ -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."
|
||||
Reference in New Issue
Block a user