From b3efb9820830db0e434955a87ac412c7a185f147 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 09:51:09 +0200 Subject: [PATCH] feat(infra): reverse proxy Nginx et terminaison TLS devant la stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 10 ++++ .github/dependabot.yml | 6 +++ .gitignore | 3 ++ Makefile | 38 ++++++++++++++- docker-compose.prod.yml | 66 ++++++++++++++++++++++++++ infra/proxy/README.md | 75 ++++++++++++++++++++++++++++++ infra/proxy/acme-deploy-hook.sh | 11 +++++ infra/proxy/conf.d/enervision.conf | 65 ++++++++++++++++++++++++++ infra/proxy/nginx.conf | 40 ++++++++++++++++ infra/proxy/tls/.gitkeep | 0 scripts/tls-selfsigned.sh | 50 ++++++++++++++++++++ 11 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 docker-compose.prod.yml create mode 100644 infra/proxy/README.md create mode 100755 infra/proxy/acme-deploy-hook.sh create mode 100644 infra/proxy/conf.d/enervision.conf create mode 100644 infra/proxy/nginx.conf create mode 100644 infra/proxy/tls/.gitkeep create mode 100755 scripts/tls-selfsigned.sh diff --git a/.env.example b/.env.example index 54dc3d8..a57c7d9 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ecebb0d..a925ee4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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" diff --git a/.gitignore b/.gitignore index 47574d1..6a16931 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Makefile b/Makefile index 2a4b3d2..a541692 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..70599b8 --- /dev/null +++ b/docker-compose.prod.yml @@ -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: diff --git a/infra/proxy/README.md b/infra/proxy/README.md new file mode 100644 index 0000000..0e5659b --- /dev/null +++ b/infra/proxy/README.md @@ -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. diff --git a/infra/proxy/acme-deploy-hook.sh b/infra/proxy/acme-deploy-hook.sh new file mode 100755 index 0000000..8ce7143 --- /dev/null +++ b/infra/proxy/acme-deploy-hook.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Contrainte : certbot écrit dans /etc/letsencrypt/live//, 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 diff --git a/infra/proxy/conf.d/enervision.conf b/infra/proxy/conf.d/enervision.conf new file mode 100644 index 0000000..cbadfaa --- /dev/null +++ b/infra/proxy/conf.d/enervision.conf @@ -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; + } +} diff --git a/infra/proxy/nginx.conf b/infra/proxy/nginx.conf new file mode 100644 index 0000000..d0da97b --- /dev/null +++ b/infra/proxy/nginx.conf @@ -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; +} diff --git a/infra/proxy/tls/.gitkeep b/infra/proxy/tls/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/tls-selfsigned.sh b/scripts/tls-selfsigned.sh new file mode 100755 index 0000000..633f3ad --- /dev/null +++ b/scripts/tls-selfsigned.sh @@ -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."