Merge branch 'dev' into feat/deploy
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# 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`.
|
||||
|
||||
L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4
|
||||
ou plus récent**. Sur une version antérieure, la fusion échoue au lieu de dépublier les ports.
|
||||
|
||||
## Routage
|
||||
|
||||
| Chemin | Destination | Remarque |
|
||||
|---|---|---|
|
||||
| `/.well-known/acme-challenge/` | `/var/www/certbot` sur le port 80 | Seul chemin non redirigé vers HTTPS |
|
||||
| `/api/v1/auth/` + `login`, `password`, `forgot-password`, `reset-password` | `backend:8000` | Zone resserrée, 30 requêtes par minute |
|
||||
| `/api/` | `backend:8000` | Préfixe `/api/v1` préservé tel quel, 20 requêtes par seconde |
|
||||
| `/` | `frontend:3000` | Le SPA, qui renvoie `index.html` sur les routes inconnues |
|
||||
|
||||
La zone resserrée ne couvre que les routes qui vérifient un secret. `/auth/me` et `/auth/refresh`
|
||||
partent à chaque chargement de page et restent dans la zone générale : derrière un NAT, où une
|
||||
seule adresse porte tous les postes, les y soumettre aurait produit des 429 en usage normal.
|
||||
|
||||
L'interface Airflow, celle de Mailpit et la base ne passent pas par le proxy : l'overlay les
|
||||
ramène sur `127.0.0.1`, donc joignables par tunnel SSH et pas autrement. Les publier derrière le
|
||||
proxy demanderait une authentification propre, qui n'est pas la leur.
|
||||
|
||||
`/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
|
||||
|
||||
`nginx -t` charge les certificats : `tls/` doit être rempli, par `make tls-selfsigned` au besoin.
|
||||
|
||||
```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,69 @@
|
||||
# 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.
|
||||
# Pourquoi : la redirection 80 vers 443 conserve `$host` plutôt qu'un nom canonique, faute de
|
||||
# quoi l'accès par IP cesserait de fonctionner sur la cible. Risque acté dans l'ADR 0007.
|
||||
|
||||
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;
|
||||
|
||||
# Piège : la zone `auth` ne couvre que les routes qui vérifient un secret. Derrière le NAT de
|
||||
# l'école, `/auth/me` et `/auth/refresh` y produiraient des 429 à chaque chargement de page.
|
||||
location ~ ^/api/v1/auth/(login|password|forgot-password|reset-password)$ {
|
||||
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