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."