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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user