diff --git a/.env.example b/.env.example index 69dace9..78243da 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4126d35..5e2f4ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,7 @@ jobs: compose: - "docker-compose*.yml" - ".env.example" + - "infra/front/**" - "monitoring/**" - ".github/workflows/infra.yml" workflows: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d18c94a..1e24499 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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.dynv6.net' || github.ref_name == 'dev' && 'https://rec.enervision-g3.dynv6.net' || 'https://dev.enervision-g3.dynv6.net' }} 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,10 @@ jobs: echo "::endgroup::" echo "::group::Reconstruit et redémarre la stack" + # Un `.env` pas encore réaligné par provision-host.sh porte encore un nom en `.local`. + if [ -r ../dns.token ] && ! grep -q '^PUBLIC_HOST=.*\.local$' .env; then make tls-dns01; 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" diff --git a/.github/workflows/infra.yml b/.github/workflows/infra.yml index 51884b2..3c7f221 100644 --- a/.github/workflows/infra.yml +++ b/.github/workflows/infra.yml @@ -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 diff --git a/.gitignore b/.gitignore index d3b47d8..a0a805b 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Makefile b/Makefile index c05f0a5..1cb5669 100644 --- a/Makefile +++ b/Makefile @@ -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-dns01 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,33 @@ 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 du fournisseur DNS, dynv6 par défaut (ADR 0018). Le jeton ne passe jamais par `argv`. +ACME_SH := neilpang/acme.sh:3.1.6 +DNS01_API ?= dns_dynv6 +DNS01_JETON_VAR ?= DYNV6_TOKEN +DNS01_JETON_FICHIER ?= $(abspath $(CURDIR)/../dns.token) +acme-sh = docker run --rm --user "$$(id -u):$$(id -g)" -e $(DNS01_JETON_VAR) -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, et recopie le jeton dans +# acme/account.conf : d'où le chmod, qui le soustrait aux autres comptes de la machine. +tls-dns01: ## Certificat Let's Encrypt par DNS-01, renouvelé seulement à échéance. Jeton : ../dns.token + @case "$(PUBLIC_HOST)" in *.local | localhost) echo "PUBLIC_HOST=$(PUBLIC_HOST) n'est pas un nom public"; exit 1 ;; esac + @test -r "$(DNS01_JETON_FICHIER)" || { echo "Jeton DNS illisible : $(DNS01_JETON_FICHIER)"; exit 1; } + @mkdir -p infra/proxy/acme && chmod 700 infra/proxy/acme + @$(DNS01_JETON_VAR)="$$(tr -d '[:space:]' < "$(DNS01_JETON_FICHIER)")"; export $(DNS01_JETON_VAR); \ + $(acme-sh) --issue --server letsencrypt --dns $(DNS01_API) -d "$(PUBLIC_HOST)"; \ + code=$$?; chmod -R go-rwx infra/proxy/acme; [ $$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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 971b608..e9c2608 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docs/adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md b/docs/adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md new file mode 100644 index 0000000..448e980 --- /dev/null +++ b/docs/adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md @@ -0,0 +1,89 @@ +# 0018 - Noms publics, 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é. Vérifications faites le 23/09 : les +résolveurs de l'école rendent bien une adresse privée pour un nom public, la VM sort en HTTPS +vers Let's Encrypt et vers l'API de dynv6, mais le filtrage de l'école bloque duckdns.org, site +et API, depuis les postes comme depuis la VM. + +## Décision + +**Des noms publics qui visent l'IP privée.** `enervision-g3.dynv6.net`, zone gratuite de dynv6, +porte la prod, et deux enregistrements A portent `rec.` et `dev.`. `provision-host.sh` les publie +par l'API dynv6 : le DNS est décrit par le code comme le reste. 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 dynv6, qui pose +l'enregistrement TXT : Let's Encrypt n'a jamais à joindre la VM. `make tls-dns01` (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 plutôt qu'un joker : chaque +stack garde le sien, et la clé de la prod n'est pas lisible depuis le clone de dev. + +**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. + +**Le fournisseur est un paramètre.** `DNS01_API` et `DNS01_JETON_VAR` nomment le greffon acme.sh, +le jeton vit dans `dns.token` quel que soit le fournisseur : passer à un domaine acheté chez +Cloudflare ou OVH ne demande que ces deux variables et `domaine`, plus `publier_dns()`. + +**`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. +- **DuckDNS** : premier choix, inscription en un clic, mais bloqué par le filtrage de l'école : + sans son API, pas de défi DNS-01. +- **deSEC (`dedyn.io`)** : joignable et associatif, mais les inscriptions de nouveaux domaines + `dedyn.io` étaient fermées le 23/09 ; il reste le bon choix pour un domaine acheté. +- **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 dynv6 vit dans `/srv/enervision/dns.token`, jamais dans git, GitHub ni le state + Terraform ; acme.sh en garde une copie dans `infra/proxy/acme/`, retirée à la lecture des + autres comptes. Qui le détient peut repointer les trois noms. +- dynv6 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. +- Un filtrage de l'école qui viendrait à bloquer dynv6 arrêterait les renouvellements, pas les + noms : la résolution passe par les serveurs DNS de l'école, pas par le site. +- 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. diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 6c0deb6..0750c8e 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -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-publics-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.dynv6.net` | `https://rec.enervision-g3.dynv6.net` | `https://enervision-g3.dynv6.net` | +| 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 dynv6 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-dns01`) 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` | diff --git a/infra/README.md b/infra/README.md index da74e09..506009f 100644 --- a/infra/README.md +++ b/infra/README.md @@ -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, la zone `domaine` doit exister chez dynv6 et son +jeton se trouver dans `/dns.token` (600, proprietaire). L'apply fait alors pointer la +zone, `rec` et `dev` vers la machine, obtient 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. diff --git a/infra/front/compose.yml b/infra/front/compose.yml new file mode 100644 index 0000000..8e21a5a --- /dev/null +++ b/infra/front/compose.yml @@ -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 diff --git a/infra/front/nginx.conf b/infra/front/nginx.conf new file mode 100644 index 0000000..c1d9f1b --- /dev/null +++ b/infra/front/nginx.conf @@ -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; + } +} diff --git a/infra/proxy/README.md b/infra/proxy/README.md index 031664a..0630576 100644 --- a/infra/proxy/README.md +++ b/infra/proxy/README.md @@ -76,8 +76,28 @@ 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 dynv6, +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-publics-certificats-dns01-et-frontal-sni.md)). + +```bash +make tls-dns01 # PUBLIC_HOST lu dans .env, jeton dans ../dns.token (600) +``` + +Autre fournisseur : `DNS01_API` et `DNS01_JETON_VAR` nomment le greffon acme.sh et sa variable +(`dns_cf` et `CF_Token` pour Cloudflare, par exemple). 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 diff --git a/infra/proxy/conf.d/enervision.conf b/infra/proxy/conf.d/enervision.conf index d3f067e..0d456c5 100644 --- a/infra/proxy/conf.d/enervision.conf +++ b/infra/proxy/conf.d/enervision.conf @@ -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; diff --git a/infra/terraform/environments/vm-eni/main.tf b/infra/terraform/environments/vm-eni/main.tf index 2641282..17eb3be 100644 --- a/infra/terraform/environments/vm-eni/main.tf +++ b/infra/terraform/environments/vm-eni/main.tf @@ -57,9 +57,10 @@ resource "null_resource" "environnements" { depends_on = [null_resource.docker_engine] triggers = { - script = filesha256(local.provisionneur) - racine = var.racine - depot = var.depot_url + 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 diff --git a/infra/terraform/environments/vm-eni/variables.tf b/infra/terraform/environments/vm-eni/variables.tf index 9608717..50e60f3 100644 --- a/infra/terraform/environments/vm-eni/variables.tf +++ b/infra/terraform/environments/vm-eni/variables.tf @@ -43,6 +43,12 @@ variable "depot_url" { default = "https://github.com/ineszang/ProjetPiscine_EnerVision.git" } +variable "domaine" { + type = string + description = "Zone dynv6 de la prod, rec. et dev. en sous-domaines (ADR 0018). Son jeton doit se trouver dans /dns.token sur la machine : provision-host.sh y fait pointer la zone et ses deux sous-domaines vers la machine." + default = "enervision-g3.dynv6.net" +} + variable "adresse_publique" { type = string description = "Adresse annoncee dans les certificats auto-signes. Vide : la premiere adresse de la VM." diff --git a/scripts/provision-host.sh b/scripts/provision-host.sh index 1e552d2..70e01ba 100755 --- a/scripts/provision-host.sh +++ b/scripts/provision-host.sh @@ -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.dynv6.net}" ADRESSE="${PUBLIC_IP:-$(hostname -I | awk '{print $1}')}" PROPRIETAIRE="${PROPRIETAIRE:-${SUDO_USER:-}}" +JETON_DNS="$RACINE/dns.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,140 @@ 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" - 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" - fi - chmod 600 "$brouillon" - mv "$brouillon" "$dossier/.env" - echo "$env : .env généré. Reste à renseigner APP_MOCK_API_USERNAME et APP_MOCK_API_PASSWORD." + 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 - if [[ ! -f "$dossier/infra/proxy/tls/fullchain.pem" ]]; then - (cd "$dossier" && PUBLIC_HOST="$hote" PUBLIC_IP="$ADRESSE" ./scripts/tls-selfsigned.sh) + for cle in "${!GENERATEURS[@]}"; do + case "$(valeur "$brouillon" "$cle")" in + "" | change_me) poser "$brouillon" "$cle" "$("${GENERATEURS[$cle]}")"; ajoutees+=" $cle" ;; + esac + done + + 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 : clés sans générateur, .env inchangé : $oubliees" fi - echo "$env : $dossier sur $branche, $origine" + chmod 600 "$brouillon" + 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 ! 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 + if [[ -r "$JETON_DNS" && "$hote" != *.local ]]; then + make -C "$dossier" --no-print-directory tls-dns01 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" +} + +# Pourquoi : dynv6 est le fournisseur que le filtrage de l'école laisse passer (ADR 0018). Le +# jeton passe par l'environnement du seul processus Python, jamais par `argv`. +publier_dns() { + [[ -r "$JETON_DNS" ]] || { echo "pas de jeton $JETON_DNS : ni DNS ni Let's Encrypt"; return 0; } + DNS_TOKEN="$(tr -d '[:space:]' < "$JETON_DNS")" python3 - "$DOMAINE" "$ADRESSE" rec dev <<'PY' \ + || echo "DNS : dynv6 refuse la mise à jour de $DOMAINE, enregistrements inchangés" >&2 +import json, os, sys, time, urllib.request + +domaine, adresse, *sous_noms = sys.argv[1:] + +def appel(methode, chemin, corps=None): + requete = urllib.request.Request( + f"https://dynv6.com/api/v2/{chemin}", method=methode, + data=None if corps is None else json.dumps(corps).encode(), + headers={"Authorization": f"Bearer {os.environ['DNS_TOKEN']}", "User-Agent": "enervision-provision", + "Content-Type": "application/json", "Accept": "application/json"}) + with urllib.request.urlopen(requete, timeout=60) as reponse: + contenu = reponse.read() + return json.loads(contenu) if contenu else None + +def synchroniser(): + zone = appel("GET", f"zones/by-name/{domaine}") + if zone.get("ipv4address") != adresse: + appel("PATCH", f"zones/{zone['id']}", {"ipv4address": adresse}) + existants = {(r["type"], r["name"]): r for r in appel("GET", f"zones/{zone['id']}/records")} + for nom in sous_noms: + actuel = existants.get(("A", nom)) + if actuel is None: + appel("POST", f"zones/{zone['id']}/records", {"type": "A", "name": nom, "data": adresse}) + elif actuel["data"] != adresse: + appel("PATCH", f"zones/{zone['id']}/records/{actuel['id']}", {"data": adresse}) + +# dynv6 laisse parfois une écriture sans réponse, appliquée ou non : chaque essai relit l'état +# avant d'écrire, si bien qu'une création aboutie malgré le délai n'est jamais dupliquée. +for essai in range(3): + try: + synchroniser() + break + except OSError: + if essai == 2: + raise + time.sleep(5) +print(f"DNS : {domaine}, {', '.join(sous_noms)} visent {adresse}") +PY +} + +planifier_renouvellement() { + [[ "$(id -u)" -eq 0 && -n "$PROPRIETAIRE" && -d /etc/cron.d ]] || return 0 + cat > /etc/cron.d/enervision-tls <&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" +publier_dns -# 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 < @@ -124,6 +223,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=. 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 : le jeton dynv6 de la zone $DOMAINE doit se trouver dans $JETON_DNS (600, +propriétaire du runner). Sans lui, ni enregistrement DNS ni Let's Encrypt : auto-signé. FIN