feat(deploy): URL sans port et certificats Let's Encrypt sur la VM ENI

Les trois environnements passent sur enervision-g3.duckdns.org, rec. et
dev. : noms publics qui visent l'IP privée de la VM, donc résolus sans
/etc/hosts sur le réseau de l'école et injoignables ailleurs (ADR 0018).

- infra/front : nginx sur le réseau de l'hôte, seul exposé en 80 et 443.
  Aiguille par SNI vers la stack visée sans déchiffrer le TLS, et lui
  transmet l'IP du client en PROXY protocol.
- Proxy de stack : écouteur 4443 en PROXY protocol, real_ip_header ;
  sans lui, limit_req et get_client_ip() compteraient tous les postes
  comme un seul. PROXY_FRONT_PORT le publie sur 127.0.0.1.
- make tls-duckdns : Let's Encrypt par défi DNS-01 via l'API DuckDNS
  (acme.sh 3.1.6), rejouable, rejoué à chaque déploiement et chaque nuit.
- provision-host.sh fait foi pour l'adressage et les secrets : un .env
  existant garde ses secrets, reçoit ceux qui manquent (supervision) et
  voit hôte et ports réalignés. Planifie le renouvellement des certificats.
- deploy.yml : nouvelles URL, sonde prod sur 10443, front-up en prod.
- Terraform : variable domaine. CI : validation du frontal.
This commit is contained in:
Johan LEROY
2026-09-23 11:21:12 +02:00
parent fe0d4222a5
commit 3e871a3e8b
17 changed files with 345 additions and 75 deletions
+3
View File
@@ -69,6 +69,9 @@ PUBLIC_ORIGIN=
# l'extérieur. Décaler aussi POSTGRES_PORT, MAILPIT_UI_PORT et AIRFLOW_PORT (5434, 8026, 8082).
PROXY_HTTP_PORT=
PROXY_HTTPS_PORT=
# Écouteur PROXY protocol du proxy, que seul le frontal de la VM joint (infra/front, ADR 0018).
# Vide : port aléatoire sur 127.0.0.1. VM : 127.0.0.1:10444 en prod, 8444 en recette, 9444 en dev.
PROXY_FRONT_PORT=
# Réglages mémoire de la stack déployée. Sans eux, timescaledb-tune réserve 25 % de la RAM de la
# machine à chaque base au premier démarrage. L'api-server Airflow 3 n'a rien à régler ici : son
# nombre de workers vaut 1 par défaut, contre 4 pour le webserver d'Airflow 2.
+1
View File
@@ -81,6 +81,7 @@ jobs:
compose:
- "docker-compose*.yml"
- ".env.example"
- "infra/front/**"
- "monitoring/**"
- ".github/workflows/infra.yml"
workflows:
+4 -2
View File
@@ -28,10 +28,10 @@ jobs:
timeout-minutes: 30
environment:
name: ${{ github.ref_name == 'main' && 'prod' || github.ref_name == 'dev' && 'rec' || 'dev' }}
url: ${{ github.ref_name == 'main' && 'https://enervision.local' || github.ref_name == 'dev' && 'https://rec.enervision.local:8443' || 'https://dev.enervision.local:9443' }}
url: ${{ github.ref_name == 'main' && 'https://enervision-g3.duckdns.org' || github.ref_name == 'dev' && 'https://rec.enervision-g3.duckdns.org' || 'https://dev.enervision-g3.duckdns.org' }}
env:
ENVIRONNEMENT: ${{ github.ref_name == 'main' && 'prod' || github.ref_name == 'dev' && 'rec' || 'dev' }}
PORT_HTTPS: ${{ github.ref_name == 'main' && '443' || github.ref_name == 'dev' && '8443' || '9443' }}
PORT_HTTPS: ${{ github.ref_name == 'main' && '10443' || github.ref_name == 'dev' && '8443' || '9443' }}
steps:
# Un seul step : le verrou tombe avec le shell qui l'a posé.
- name: Déploie le commit testé, sans jamais reculer
@@ -54,7 +54,9 @@ jobs:
echo "::endgroup::"
echo "::group::Reconstruit et redémarre la stack"
if [ -r /srv/enervision/duckdns.token ]; then make tls-duckdns; fi
make stack-up
if [ "${ENVIRONNEMENT}" = prod ]; then make front-up; fi
echo "::endgroup::"
echo "::group::Attend que l'API réponde derrière le proxy"
+5
View File
@@ -73,6 +73,11 @@ jobs:
- name: Valide la stack déployée, profils compris
run: docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile acme --profile monitoring --profile load config --quiet
- name: Valide le frontal SNI de la VM
run: |
docker compose -f infra/front/compose.yml config --quiet
docker run --rm -v "$PWD/infra/front/nginx.conf:/etc/nginx/nginx.conf:ro" nginx:1.31-alpine nginx -t
# Mêmes commandes que `make monitoring-check` : images et montages viennent du fichier Compose.
- name: Valide la configuration de Prometheus et ses règles
run: docker compose --profile monitoring run --rm --no-deps --entrypoint promtool prometheus check config /etc/prometheus/prometheus.yml
+1
View File
@@ -75,6 +75,7 @@ etl/airflow/tests/.airflow_home/
# TLS : certificats du reverse proxy, générés par script ou par certbot
infra/proxy/tls/*.pem
infra/proxy/acme/
# IDE et OS
.idea/
+25 -1
View File
@@ -73,7 +73,7 @@ DEMO_NOW ?= 2024-12-31T00:00:00Z
migrate migrate-test bootstrap-admin services-up demo-data demo-data-force \
ml-lint ml-typecheck ml-test ml-check ml-train ml-score mlflow-up detect-alerts recommendations \
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs \
tls-selfsigned tls-acme tls-renew tls-duckdns front-up stack-up stack-down stack-logs \
e2e-install e2e-prepare e2e load-smoke load-test load-stress load-limits \
db-ensure-supervision monitoring-up monitoring-down monitoring-logs monitoring-check
@@ -235,6 +235,30 @@ 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
# Pourquoi : la VM n'a qu'une IP privée, que Let's Encrypt ne joint pas ; le défi DNS-01 passe
# par l'API DuckDNS (ADR 0018). Le jeton transite par l'environnement, jamais par `argv`.
ACME_SH := neilpang/acme.sh:3.1.6
DUCKDNS_TOKEN_FILE ?= $(abspath $(CURDIR)/../duckdns.token)
acme-sh = docker run --rm --user "$$(id -u):$$(id -g)" -e DuckDNS_Token -e AUTO_UPGRADE=0 \
-v "$(CURDIR)/infra/proxy/acme:/acme.sh" -v "$(CURDIR)/infra/proxy/tls:/tls" $(ACME_SH)
# acme.sh sort en 2 quand le certificat n'est pas encore à renouveler : rejouable à chaque déploiement.
tls-duckdns: ## Certificat Let's Encrypt par DNS-01 DuckDNS, renouvelé seulement à échéance
@case "$(PUBLIC_HOST)" in *.duckdns.org) ;; *) echo "PUBLIC_HOST=$(PUBLIC_HOST) n'est pas un nom DuckDNS"; exit 1 ;; esac
@test -r "$(DUCKDNS_TOKEN_FILE)" || { echo "Jeton DuckDNS illisible : $(DUCKDNS_TOKEN_FILE)"; exit 1; }
@mkdir -p infra/proxy/acme
@DuckDNS_Token="$$(cat "$(DUCKDNS_TOKEN_FILE)")"; export DuckDNS_Token; \
$(acme-sh) --issue --server letsencrypt --dns dns_duckdns -d "$(PUBLIC_HOST)"; \
code=$$?; [ $$code -eq 0 ] || [ $$code -eq 2 ] || exit $$code
@$(acme-sh) --install-cert --ecc -d "$(PUBLIC_HOST)" \
--fullchain-file /tls/fullchain.pem --key-file /tls/privkey.pem
@$(COMPOSE_PROD) exec -T proxy nginx -s reload 2>/dev/null \
|| echo "Proxy arrêté : il lira le certificat à son démarrage"
front-up: ## Démarre ou recharge le frontal SNI de la VM, sur les ports 80 et 443 de l'hôte
docker compose -f infra/front/compose.yml up -d
docker compose -f infra/front/compose.yml exec -T front nginx -s reload
e2e-install: ## Installe Playwright et Chromium pour les tests de bout en bout
cd $(E2E) && npm ci && npx playwright install chromium
+2
View File
@@ -58,6 +58,8 @@ services:
ports:
- "${PROXY_HTTP_PORT:-80}:80"
- "${PROXY_HTTPS_PORT:-443}:443"
# Vide : port aléatoire sur la boucle locale, pour que deux stacks sans frontal cohabitent.
- "${PROXY_FRONT_PORT:-127.0.0.1:}:4443"
volumes:
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
@@ -0,0 +1,75 @@
# 0018 - Noms DuckDNS, certificats Let's Encrypt par DNS-01 et frontal SNI sans port
- Statut : accepté
- Date : 2026-09-23
## Contexte
Les trois environnements de la VM ENI ([ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md),
[ADR 0017](0017-environnement-dev-a-la-demande.md)) répondaient sur `enervision.local`,
`rec.enervision.local:8443` et `dev.enervision.local:9443`, avec des certificats auto-signés.
Chaque poste devait éditer son `/etc/hosts` et accepter trois avertissements du navigateur :
rien de présentable à un jury, et rien d'utilisable par quelqu'un qui n'a pas la main sur son
poste.
Contraintes : la VM n'a qu'une IP privée, `10.101.200.37`, que ni Internet ni Let's Encrypt ne
joignent, et le réseau de l'école ne doit pas être touché. Deux vérifications faites le 23/09 :
les résolveurs de l'école rendent bien une adresse privée pour un nom public, et la VM sort en
HTTPS vers Let's Encrypt.
## Décision
**Des noms publics qui visent l'IP privée.** `enervision-g3.duckdns.org` porte la prod,
`rec.` et `dev.` en sous-domaines, que DuckDNS résout d'office. Tout poste du réseau de l'école
les résout sans configuration ; hors de ce réseau, l'IP ne mène nulle part.
**Des certificats Let's Encrypt par défi DNS-01.** Le défi passe par l'API DuckDNS, qui pose
l'enregistrement TXT : Let's Encrypt n'a jamais à joindre la VM. `make tls-duckdns` (acme.sh
épinglé) le joue dans chaque stack ; il ne renouvelle qu'à échéance, d'où son rejeu à chaque
déploiement et chaque nuit par cron. Un certificat par environnement : DuckDNS ne tient qu'un
TXT à la fois, ce qui interdit un certificat unique pour le domaine et son joker.
**Un frontal SNI sur 443, le seul composant exposé.** `infra/front`, un nginx sur le réseau de
l'hôte, lit le nom demandé dans le ClientHello et relaie le flux TLS intact vers la stack visée,
publiée sur la boucle locale. Il ne détient aucun certificat. Le port 80 y redirige vers
HTTPS. Les URL perdent leur port.
**Le PROXY protocol entre frontal et stacks.** Relayé tel quel, le flux arriverait avec l'IP du
frontal : `limit_req` et `get_client_ip()` compteraient tous les postes comme un seul, et un
utilisateur bloquerait la connexion de tous. Chaque proxy de stack reçoit donc le frontal sur un
écouteur dédié, 4443, qui exige l'en-tête PROXY protocol et en tire l'IP du client. Le 443 de
la stack reste sans PROXY protocol, pour les postes de développement et la sonde du déploiement.
**`scripts/provision-host.sh` fait foi pour l'adressage et les secrets.** Un `.env` existant
garde ses secrets, reçoit ceux qui lui manquent et voit hôte, ports et profils réalignés sur le
tableau du script. C'est ce qui permet de migrer trois `.env` nés avant ce changement, et le
clone de la prod, en retard sur `main`, sans dépendre de son `.env.example`.
## Alternatives écartées
- **Garder `/etc/hosts` et l'auto-signé** : trois manipulations par poste et trois
avertissements, précisément ce qu'il fallait supprimer.
- **nip.io ou sslip.io** : résolution sans compte, mais aucun moyen d'y obtenir un certificat.
- **Services à certificat joker public (traefik.me, local-ip.co)** : leur clé privée est publiée
par conception, n'importe qui peut usurper ces noms.
- **Tunnel vers Internet (Cloudflare Tunnel, Tailscale Funnel)** : accès depuis l'extérieur,
mais l'application serait exposée hors de l'école, décision refusée.
- **Autorité de certification interne (mkcert, step-ca)** : chaque poste devrait l'installer.
- **Terminaison TLS au frontal** : un seul endroit pour les certificats, mais les stacks
recevraient du HTTP clair que leur proxy redirige vers HTTPS, et en-têtes de sécurité comme
limitation de débit seraient à déplacer. Le relais SNI ne touche à rien de tout cela.
- **Domaine acheté** : plus présentable, mais un achat et un compte de plus pour un bénéfice nul
sur l'accès. Seule `DOMAINE` changerait.
## Conséquences
- L'objection de l'ADR 0009 à un proxy frontal, qui aurait dû joindre plusieurs réseaux Compose
aux services homonymes, tombe : le frontal ne joint que des ports de la boucle locale.
- Sans le frontal, plus rien n'est joignable sur la VM. `deploy.yml` le relance à chaque
déploiement de la prod, et son `restart: unless-stopped` le ramène après un redémarrage.
- Le jeton DuckDNS vit dans `/srv/enervision/duckdns.token`, jamais dans git, GitHub ni le state
Terraform. Qui le détient peut repointer les trois noms.
- DuckDNS devient une dépendance : s'il tombe, les noms cessent de résoudre et les
renouvellements échouent. Les certificats valent 90 jours, la marge est large.
- Les noms sont publics mais ne mènent qu'à une IP privée : ils révèlent l'existence de la VM,
pas son contenu.
+15 -8
View File
@@ -237,7 +237,8 @@ Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent
Statut : `En cours`. Décision et motifs dans
l'[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md), étendue à un troisième
environnement par l'[ADR 0017](../adr/0017-environnement-dev-a-la-demande.md).
environnement par l'[ADR 0017](../adr/0017-environnement-dev-a-la-demande.md) ; noms,
certificats et frontal sans port dans l'[ADR 0018](../adr/0018-noms-duckdns-certificats-dns01-et-frontal-sni.md).
La VM `eadl-2025-nantes-g3` porte le développement, la recette et la production, chacun dans son
clone du dépôt, son `.env` et son projet Compose. Le nom de projet préfixe volumes, réseau et
conteneurs : rien n'est partagé. `scripts/provision-host.sh` prépare les trois dossiers, génère
@@ -247,16 +248,21 @@ les secrets et les certificats, et ne démarre rien.
|---|---|---|---|
| Branche, environnement GitHub | toute branche lancée à la main, `dev` | `dev`, `rec` | `main`, `prod` |
| Dossier, projet Compose | `/srv/enervision/dev`, `enervision-dev` | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` |
| URL | `https://dev.enervision.local:9443` | `https://rec.enervision.local:8443` | `https://enervision.local` |
| Proxy HTTP, HTTPS | `127.0.0.1:8083`, `9443` | `127.0.0.1:8081`, `8443` | `80`, `443` |
| URL | `https://dev.enervision-g3.duckdns.org` | `https://rec.enervision-g3.duckdns.org` | `https://enervision-g3.duckdns.org` |
| Proxy HTTP, HTTPS, PROXY protocol, sur `127.0.0.1` | `8083`, `9443`, `9444` | `8081`, `8443`, `8444` | `10080`, `10443`, `10444` |
| PostgreSQL, Mailpit, Airflow, sur `127.0.0.1` | `5435`, `8027`, `8084` | `5434`, `8026`, `8082` | `5433`, `8025`, `8080` |
| Supervision (profil `monitoring`) | à la demande, `make monitoring-up` | à la demande, `make monitoring-up` | active, `COMPOSE_PROFILES=monitoring` |
| Grafana, Prometheus, Alertmanager, sur `127.0.0.1` | `3003`, `9092`, `9095` | `3002`, `9091`, `9094` | `3001`, `9090`, `9093` |
Les trois noms d'hôte visent la même IP, à déclarer dans le `/etc/hosts` des postes. Deux noms
distincts sont nécessaires : le cookie `__Secure-ev_refresh` est posé par hôte, pas par port.
La redirection HTTP de la recette et du développement est ramenée sur la boucle locale parce que la configuration
Nginx renvoie vers `https://$host` sans port, c'est-à-dire vers la production.
Les trois noms sont publics chez DuckDNS et visent l'IP privée de la VM : rien à déclarer sur
les postes du réseau de l'école, et rien n'est joignable hors de ce réseau. Trois noms distincts
sont nécessaires : le cookie `__Secure-ev_refresh` est posé par hôte, pas par port.
Aucune stack ne publie hors de la boucle locale. Le frontal `infra/front`, sur le réseau de
l'hôte, écoute 80 et 443 : il redirige le premier, et aiguille le second d'après le nom demandé
(SNI) vers l'écouteur PROXY protocol de la stack visée, sans déchiffrer le TLS. Chaque stack
garde son certificat Let's Encrypt, obtenu par défi DNS-01 (`make tls-duckdns`) et renouvelé à
chaque déploiement ainsi que chaque nuit par `/etc/cron.d/enervision-tls`.
Le déploiement est décrit dans [50-cicd.md](50-cicd.md) : un runner GitHub Actions installé sur
la VM aligne le dossier sur la branche poussée et lance `make stack-up`.
@@ -362,7 +368,8 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| API | `8000` | Identique en conteneur et hors conteneur |
| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` |
| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte |
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`, via `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`. 80 ne sert que la redirection et le défi ACME. La recette publie `8443` et `127.0.0.1:8081` |
| Reverse proxy | `80` et `443`, plus `4443` | Les seuls ports publiés par `docker-compose.prod.yml`, via `PROXY_HTTP_PORT`, `PROXY_HTTPS_PORT` et `PROXY_FRONT_PORT`. 80 ne sert que la redirection et le défi ACME ; 4443 n'accepte que le PROXY protocol du frontal. Sur la VM, tous sur `127.0.0.1` |
| Frontal SNI de la VM | `80` et `443` de l'hôte | `infra/front`, seul composant exposé sur le réseau de l'école (ADR 0018) |
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
+6
View File
@@ -38,6 +38,12 @@ Apres l'apply, la machine porte `/srv/enervision/dev`, `/srv/enervision/rec` et
manuel, `make stack-up` dans chaque dossier ; les suivants sont joues par le runner a chaque push
sur `dev` et sur `main`, et a chaque lancement manuel d'une autre branche pour `dev`.
Noms et certificats (ADR 0018) : avant l'apply, l'enregistrement DuckDNS de `domaine` doit viser
`ssh_host`, et son jeton se trouver dans `<racine>/duckdns.token` (600, proprietaire). L'apply
obtient alors un certificat Let's Encrypt par environnement et planifie leur renouvellement ;
sans jeton, chaque environnement garde un certificat auto-signe. Le frontal SNI (`infra/front`)
se demarre une fois depuis le dossier de la prod, `make front-up`.
Retirer le runner se fait a la main, depuis les parametres du depot : `terraform destroy` ne le
desinscrit pas.
+12
View File
@@ -0,0 +1,12 @@
# Pourquoi : le réseau de l'hôte, parce que les trois stacks publient leur écouteur PROXY protocol
# sur 127.0.0.1 et que seul un conteneur sur l'hôte joint cette boucle locale (ADR 0018).
name: enervision-front
services:
front:
image: nginx:1.31-alpine
network_mode: host
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
restart: unless-stopped
+46
View File
@@ -0,0 +1,46 @@
# Pourquoi : trois environnements sur une seule IP, des URL sans port (ADR 0018). Ce frontal lit
# le nom demandé dans le ClientHello (SNI) et relaie le flux TLS intact vers le proxy de la
# stack visée : il ne détient aucun certificat, chaque stack garde le sien et ses en-têtes.
# Piège : relayé tel quel, le flux arriverait avec l'IP du frontal, et les limitations de débit
# de nginx et du backend deviendraient globales. D'où `proxy_protocol on`, reçu sur l'écouteur
# 4443 de chaque stack (infra/proxy/conf.d/enervision.conf), qui y restaure l'IP du client.
# Contrainte : ces ports sont ceux que `scripts/provision-host.sh` donne à PROXY_FRONT_PORT.
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
stream {
log_format aiguillage '$remote_addr [$time_local] $ssl_preread_server_name '
'-> $upstream_addr $status $session_time';
access_log /var/log/nginx/access.log aiguillage;
map $ssl_preread_server_name $stack {
~^rec\. 127.0.0.1:8444;
~^dev\. 127.0.0.1:9444;
default 127.0.0.1:10444;
}
server {
listen 443;
ssl_preread on;
proxy_pass $stack;
proxy_protocol on;
proxy_connect_timeout 5s;
}
}
http {
server_tokens off;
access_log off;
server {
listen 80 default_server;
server_name _;
return 301 https://$host$request_uri;
}
}
+21 -2
View File
@@ -76,8 +76,27 @@ Renouvellement, à passer en tâche planifiée sur la machine :
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.
### Let's Encrypt par DNS-01, le mode de la VM
La VM n'a qu'une IP privée : le défi HTTP-01 y est impossible. Ses trois noms sont chez DuckDNS,
dont l'API pose l'enregistrement TXT du défi DNS-01, et acme.sh le fait sans rien ouvrir
([ADR 0018](../../docs/adr/0018-noms-duckdns-certificats-dns01-et-frontal-sni.md)).
```bash
make tls-duckdns # PUBLIC_HOST lu dans .env, jeton dans ../duckdns.token (600)
```
La cible est rejouable : acme.sh ne renouvelle qu'à trente jours de l'échéance, installe le
résultat dans `tls/` et recharge le proxy s'il tourne. Son état vit dans `acme/`, ignoré par git.
`deploy.yml` la rejoue avant chaque `make stack-up`, et `/etc/cron.d/enervision-tls` chaque nuit.
## Écouteur PROXY protocol
Sur la VM, le frontal `infra/front` relaie les connexions TLS sans les déchiffrer. Reçues sur
443, elles porteraient son adresse, et `limit_req` comme `get_client_ip()` compteraient tous les
postes comme un seul. Le port 4443 ne les accepte qu'avec l'en-tête PROXY protocol, d'où
`real_ip_header proxy_protocol` tire l'IP du client ; seules les adresses des réseaux Docker ont
le droit de l'annoncer, et le port n'est publié que sur `127.0.0.1` (`PROXY_FRONT_PORT`).
## Vérifier la configuration sans démarrer la stack
+7
View File
@@ -7,6 +7,8 @@
# 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.
# Piège : 4443 n'accepte que le PROXY protocol du frontal (infra/front, ADR 0018), qui y porte
# l'IP du client. Seules les adresses des réseaux Docker ont le droit de l'annoncer.
server {
listen 80 default_server;
@@ -23,9 +25,14 @@ server {
server {
listen 443 ssl default_server;
listen 4443 ssl proxy_protocol default_server;
http2 on;
server_name _;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header proxy_protocol;
resolver 127.0.0.11 valid=10s ipv6=off;
ssl_certificate /etc/nginx/tls/fullchain.pem;
@@ -60,6 +60,7 @@ resource "null_resource" "environnements" {
script = filesha256(local.provisionneur)
racine = var.racine
depot = var.depot_url
domaine = var.domaine
}
connection {
@@ -83,6 +84,7 @@ resource "null_resource" "environnements" {
${local.sudo}env RACINE='${var.racine}' \
REPO_URL='${var.depot_url}' \
PROPRIETAIRE='${var.proprietaire}' \
DOMAINE='${var.domaine}' \
PUBLIC_IP='${var.adresse_publique}' \
bash /tmp/provision-host.sh
rm -f /tmp/provision-host.sh
@@ -43,6 +43,12 @@ variable "depot_url" {
default = "https://github.com/ineszang/ProjetPiscine_EnerVision.git"
}
variable "domaine" {
type = string
description = "Nom DuckDNS de la prod, rec. et dev. en sous-domaines (ADR 0018). Son enregistrement doit viser ssh_host, et son jeton se trouver dans <racine>/duckdns.token sur la machine."
default = "enervision-g3.duckdns.org"
}
variable "adresse_publique" {
type = string
description = "Adresse annoncee dans les certificats auto-signes. Vide : la premiere adresse de la VM."
+104 -52
View File
@@ -1,25 +1,39 @@
#!/usr/bin/env bash
# Pourquoi : la machine porte trois environnements, chacun un clone du dépôt, un `.env` et un
# projet Compose (ADR 0009, ADR 0017). Ce script prépare la machine et les trois dossiers sans
# rien démarrer : construction des images et démarrage restent à l'opérateur, puis au runner.
# projet Compose (ADR 0009, 0017), derrière un frontal SNI sur 443 (ADR 0018). Ce script prépare
# la machine et les trois dossiers sans démarrer aucune stack : c'est le rôle du runner.
# Piège : le script, et non le `.env.example` du clone, fait foi pour les secrets (GENERATEURS)
# et l'adressage (tableau du bas). Un `.env` existant garde ses secrets, reçoit ceux qui lui
# manquent et voit son adressage réaligné : sans ça, un `.env` né avant une clé ne la reçoit
# jamais, et le clone de la prod, en retard sur `main`, ne connaîtrait pas les nouvelles.
# Piège : lancé en root, git refuse un clone déjà chowné au runner (propriété douteuse). D'où
# `safe.directory` passé en ligne de commande, seule portée où git l'accepte - preparer().
# Rejouable : un dossier déjà cloné est réaligné sur sa branche, un `.env` existant n'est jamais
# réécrit, un certificat présent n'est jamais régénéré.
# Rejouable : un certificat n'est refait que s'il ne couvre plus l'hôte, et celui de Let's Encrypt
# n'est renouvelé qu'à échéance.
set -euo pipefail
DEPOT="${REPO_URL:-https://github.com/ineszang/ProjetPiscine_EnerVision.git}"
RACINE="${RACINE:-/srv/enervision}"
DOMAINE="${DOMAINE:-enervision-g3.duckdns.org}"
ADRESSE="${PUBLIC_IP:-$(hostname -I | awk '{print $1}')}"
PROPRIETAIRE="${PROPRIETAIRE:-${SUDO_USER:-}}"
JETON_DUCKDNS="$RACINE/duckdns.token"
COMPOSE_MINIMALE="2.24.4"
erreur() { echo "erreur : $*" >&2; exit 1; }
secret() { openssl rand -base64 48 | tr -d '/+=\n' | cut -c1-48; }
court() { secret | cut -c1-20; }
# Clé Fernet : 32 octets en base64 urlsafe, padding compris.
fernet() { openssl rand -base64 32 | tr '+/' '-_'; }
declare -A GENERATEURS=(
[POSTGRES_PASSWORD]=secret [APP_SECRET_KEY]=secret [AIRFLOW_FERNET_KEY]=fernet
[AIRFLOW_API_SECRET_KEY]=secret [AIRFLOW_JWT_SECRET]=secret [AIRFLOW_ADMIN_PASSWORD]=court
[AIRFLOW_APP_SECRET_KEY]=secret [APP_METRICS_TOKEN]=secret [GRAFANA_ADMIN_PASSWORD]=court
[SUPERVISION_DB_PASSWORD]=secret
)
verifier_outils() {
for outil in git make openssl curl; do
command -v "$outil" >/dev/null || erreur "$outil absent (apt-get install $outil)"
@@ -35,11 +49,27 @@ verifier_outils() {
echo "docker compose $version, sortie Internet : ok"
}
valeur() { sed -n "s/^$2=//p" "$1" | tail -1; }
poser() {
local fichier="$1" cle="$2" contenu="$3"
if grep -q "^$cle=" "$fichier"; then
CLE="$cle" CONTENU="$contenu" awk -F= '
$1 == ENVIRON["CLE"] { print ENVIRON["CLE"] "=" ENVIRON["CONTENU"]; next } { print }
' "$fichier" > "$fichier.tmp"
mv "$fichier.tmp" "$fichier"
else
printf '%s=%s\n' "$cle" "$contenu" >> "$fichier"
fi
}
preparer() {
local env="$1" branche="$2" hote="$3" origine="$4"
local port_https="$5" port_http="$6" port_pg="$7" port_mailpit="$8" port_airflow="$9"
local profils="${10}" port_grafana="${11}" port_prometheus="${12}" port_alertmanager="${13}"
local env="$1" branche="$2" hote="$3"
local port_https="$4" port_http="$5" port_front="$6" port_pg="$7" port_mailpit="$8"
local port_airflow="$9" profils="${10}" port_grafana="${11}" port_prometheus="${12}"
local port_alertmanager="${13}"
local dossier="$RACINE/$env"
local fichier="$dossier/.env" brouillon="$dossier/.env.brouillon" cle oubliees ajoutees=""
if [[ -d "$dossier/.git" ]]; then
local git=(git -c "safe.directory=$dossier" -C "$dossier")
@@ -50,71 +80,93 @@ preparer() {
git clone --quiet --branch "$branche" "$DEPOT" "$dossier"
fi
if [[ ! -f "$dossier/.env" ]]; then
local brouillon="$dossier/.env.brouillon" oubliees
sed -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|" \
-e "s|^POSTGRES_PORT=.*|POSTGRES_PORT=$port_pg|" \
-e "s|^APP_SECRET_KEY=.*|APP_SECRET_KEY=$(secret)|" \
-e "s|^MAILPIT_UI_PORT=.*|MAILPIT_UI_PORT=$port_mailpit|" \
-e "s|^AIRFLOW_PORT=.*|AIRFLOW_PORT=$port_airflow|" \
-e "s|^AIRFLOW_FERNET_KEY=.*|AIRFLOW_FERNET_KEY=$(fernet)|" \
-e "s|^AIRFLOW_API_SECRET_KEY=.*|AIRFLOW_API_SECRET_KEY=$(secret)|" \
-e "s|^AIRFLOW_JWT_SECRET=.*|AIRFLOW_JWT_SECRET=$(secret)|" \
-e "s|^AIRFLOW_ADMIN_PASSWORD=.*|AIRFLOW_ADMIN_PASSWORD=$(secret | cut -c1-20)|" \
-e "s|^AIRFLOW_APP_SECRET_KEY=.*|AIRFLOW_APP_SECRET_KEY=$(secret)|" \
-e "s|^PUBLIC_HOST=.*|PUBLIC_HOST=$hote|" \
-e "s|^PUBLIC_ORIGIN=.*|PUBLIC_ORIGIN=$origine|" \
-e "s|^COMPOSE_PROJECT_NAME=.*|COMPOSE_PROJECT_NAME=enervision-$env|" \
-e "s|^PROXY_HTTP_PORT=.*|PROXY_HTTP_PORT=$port_http|" \
-e "s|^PROXY_HTTPS_PORT=.*|PROXY_HTTPS_PORT=$port_https|" \
-e "s|^COMPOSE_PROFILES=.*|COMPOSE_PROFILES=$profils|" \
-e "s|^APP_METRICS_TOKEN=.*|APP_METRICS_TOKEN=$(secret)|" \
-e "s|^GRAFANA_ADMIN_PASSWORD=.*|GRAFANA_ADMIN_PASSWORD=$(secret | cut -c1-20)|" \
-e "s|^SUPERVISION_DB_PASSWORD=.*|SUPERVISION_DB_PASSWORD=$(secret)|" \
-e "s|^GRAFANA_PORT=.*|GRAFANA_PORT=$port_grafana|" \
-e "s|^PROMETHEUS_PORT=.*|PROMETHEUS_PORT=$port_prometheus|" \
-e "s|^ALERTMANAGER_PORT=.*|ALERTMANAGER_PORT=$port_alertmanager|" \
"$dossier/.env.example" > "$brouillon"
# Branche antérieure à l'ADR 0009 : ces clés manquent alors dans .env.example.
for cle in "COMPOSE_PROJECT_NAME=enervision-$env" "PUBLIC_ORIGIN=$origine" \
"PROXY_HTTP_PORT=$port_http" "PROXY_HTTPS_PORT=$port_https"; do
grep -q "^${cle%%=*}=" "$brouillon" || echo "$cle" >> "$brouillon"
local masque
masque="$(umask)"
umask 077
if [[ -f "$fichier" ]]; then
cp -p "$fichier" "$brouillon"
awk -F= 'NR == FNR { connues[$1]; next } /^[A-Z_][A-Z0-9_]*=/ && !($1 in connues)' \
"$fichier" "$dossier/.env.example" >> "$brouillon"
else
cp "$dossier/.env.example" "$brouillon"
fi
for cle in "${!GENERATEURS[@]}"; do
case "$(valeur "$brouillon" "$cle")" in
"" | change_me) poser "$brouillon" "$cle" "$("${GENERATEURS[$cle]}")"; ajoutees+=" $cle" ;;
esac
done
# Piège : une clé renommée en amont garde sa valeur d'exemple, que le `:?` du compose ne
# voit pas puisqu'elle n'est pas vide. Cas vécu : AIRFLOW_WEBSERVER_SECRET_KEY, Airflow 3.
declare -A adressage=(
[PUBLIC_HOST]="$hote" [PUBLIC_ORIGIN]="https://$hote" [COMPOSE_PROJECT_NAME]="enervision-$env"
[PROXY_HTTPS_PORT]="$port_https" [PROXY_HTTP_PORT]="$port_http" [PROXY_FRONT_PORT]="$port_front"
[POSTGRES_PORT]="$port_pg" [MAILPIT_UI_PORT]="$port_mailpit" [AIRFLOW_PORT]="$port_airflow"
[COMPOSE_PROFILES]="$profils" [GRAFANA_PORT]="$port_grafana"
[PROMETHEUS_PORT]="$port_prometheus" [ALERTMANAGER_PORT]="$port_alertmanager"
)
for cle in "${!adressage[@]}"; do
poser "$brouillon" "$cle" "${adressage[$cle]}"
done
# Piège : une clé renommée en amont garde sa valeur d'exemple, que le `:?` du compose ne voit
# pas puisqu'elle n'est pas vide. Cas vécu : AIRFLOW_WEBSERVER_SECRET_KEY, Airflow 3.
oubliees="$(grep '=change_me$' "$brouillon" | grep -v '^APP_MOCK_API_' | cut -d= -f1 | tr '\n' ' ' || true)"
if [[ -n "$oubliees" ]]; then
rm -f "$brouillon"
erreur "$env : secrets non générés, .env non écrit : $oubliees"
erreur "$env : clés sans générateur, .env inchangé : $oubliees"
fi
chmod 600 "$brouillon"
mv "$brouillon" "$dossier/.env"
if [[ -f "$fichier" ]]; then
cat "$brouillon" > "$fichier"
rm -f "$brouillon"
echo "$env : .env réaligné sur le tableau${ajoutees:+, secrets ajoutés :$ajoutees}"
else
mv "$brouillon" "$fichier"
echo "$env : .env généré. Reste à renseigner APP_MOCK_API_USERNAME et APP_MOCK_API_PASSWORD."
fi
umask "$masque"
if [[ ! -f "$dossier/infra/proxy/tls/fullchain.pem" ]]; then
(cd "$dossier" && PUBLIC_HOST="$hote" PUBLIC_IP="$ADRESSE" ./scripts/tls-selfsigned.sh)
if ! openssl x509 -in "$dossier/infra/proxy/tls/fullchain.pem" -noout -checkhost "$hote" 2>/dev/null \
| grep -q " does match"; then
(cd "$dossier" && PUBLIC_HOST="$hote" PUBLIC_IP="$ADRESSE" ./scripts/tls-selfsigned.sh --force)
fi
echo "$env : $dossier sur $branche, $origine"
if [[ -r "$JETON_DUCKDNS" && "$hote" == *.duckdns.org ]]; then
make -C "$dossier" --no-print-directory tls-duckdns PUBLIC_HOST="$hote" \
|| echo "$env : pas de certificat Let's Encrypt, l'auto-signé reste en place" >&2
fi
echo "$env : $dossier sur $branche, https://$hote"
}
planifier_renouvellement() {
[[ "$(id -u)" -eq 0 && -n "$PROPRIETAIRE" && -d /etc/cron.d ]] || return 0
cat > /etc/cron.d/enervision-tls <<CRON
# Renouvellement Let's Encrypt des trois environnements (ADR 0018), écrit par provision-host.sh.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
23 4 * * * $PROPRIETAIRE for e in prod rec dev; do make -C $RACINE/\$e --no-print-directory tls-duckdns; done 2>&1 | logger -t enervision-tls
CRON
chmod 644 /etc/cron.d/enervision-tls
echo "renouvellement planifié : /etc/cron.d/enervision-tls"
}
verifier_outils
mkdir -p "$RACINE"
# Supervision active en prod seulement (ADR 0016) ; les ports de la recette et de dev restent
# décalés au cas où on l'y lancerait à la demande.
# env branche hôte origine https http pg mailpit airflow profils grafana prometheus alertmanager
preparer prod main enervision.local https://enervision.local 443 80 5433 8025 8080 monitoring 3001 9090 9093
preparer rec dev rec.enervision.local https://rec.enervision.local:8443 8443 127.0.0.1:8081 5434 8026 8082 "" 3002 9091 9094
preparer dev dev dev.enervision.local https://dev.enervision.local:9443 9443 127.0.0.1:8083 5435 8027 8084 "" 3003 9092 9095
# Supervision active en prod seulement (ADR 0016). Le frontal (infra/front) publie 80 et 443 et
# relaie vers les ports `front` ; les stacks ne publient plus rien hors de la boucle locale.
# env branche hôte https http front pg mailpit airflow profils grafana prometheus alertmanager
preparer prod main "$DOMAINE" 127.0.0.1:10443 127.0.0.1:10080 127.0.0.1:10444 5433 8025 8080 monitoring 3001 9090 9093
preparer rec dev "rec.$DOMAINE" 127.0.0.1:8443 127.0.0.1:8081 127.0.0.1:8444 5434 8026 8082 "" 3002 9091 9094
preparer dev dev "dev.$DOMAINE" 127.0.0.1:9443 127.0.0.1:8083 127.0.0.1:9444 5435 8027 8084 "" 3003 9092 9095
planifier_renouvellement
if [[ -n "$PROPRIETAIRE" && "$(id -u)" -eq 0 ]]; then
chown -R "$PROPRIETAIRE" "$RACINE"
fi
cat <<FIN
Démarrage, dans chaque dossier : make stack-up, qui applique aussi les migrations.
Démarrage, dans chaque dossier : make stack-up, qui applique aussi les migrations. Puis, une
fois, depuis $RACINE/prod : make front-up, que chaque déploiement de la prod rejoue ensuite.
Premier administrateur, stack démarrée, dans chaque dossier :
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend \\
python -m app.cli create-admin --email <adresse>
@@ -124,6 +176,6 @@ L'installer sous le propriétaire de $RACINE, sinon git refuse ces dépôts et l
échappe : relancer au besoin ce script avec PROPRIETAIRE=<utilisateur du runner>.
Données historiques : git ne porte pas data/raw, déposer les fichiers dans chaque dossier avant
de déclencher le DAG historical_import.
Depuis un poste : ajouter « $ADRESSE enervision.local rec.enervision.local dev.enervision.local »
à /etc/hosts.
Noms et certificats : l'enregistrement DuckDNS de $DOMAINE doit viser $ADRESSE, et son jeton
se trouver dans $JETON_DUCKDNS (600, propriétaire du runner). Sans jeton : auto-signé.
FIN