From 3e871a3e8baeaec066e85efbbfe29e42669cf3e2 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 23 Sep 2026 11:21:12 +0200 Subject: [PATCH 1/5] feat(deploy): URL sans port et certificats Let's Encrypt sur la VM ENI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 3 + .github/workflows/ci.yml | 1 + .github/workflows/deploy.yml | 6 +- .github/workflows/infra.yml | 5 + .gitignore | 1 + Makefile | 26 ++- docker-compose.prod.yml | 2 + ...uckdns-certificats-dns01-et-frontal-sni.md | 75 ++++++++ docs/architecture/10-infra.md | 23 ++- infra/README.md | 6 + infra/front/compose.yml | 12 ++ infra/front/nginx.conf | 46 +++++ infra/proxy/README.md | 23 ++- infra/proxy/conf.d/enervision.conf | 7 + infra/terraform/environments/vm-eni/main.tf | 8 +- .../environments/vm-eni/variables.tf | 6 + scripts/provision-host.sh | 170 ++++++++++++------ 17 files changed, 345 insertions(+), 75 deletions(-) create mode 100644 docs/adr/0018-noms-duckdns-certificats-dns01-et-frontal-sni.md create mode 100644 infra/front/compose.yml create mode 100644 infra/front/nginx.conf 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..14e392c 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.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" 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..dfd883d 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-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 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-duckdns-certificats-dns01-et-frontal-sni.md b/docs/adr/0018-noms-duckdns-certificats-dns01-et-frontal-sni.md new file mode 100644 index 0000000..56f30a7 --- /dev/null +++ b/docs/adr/0018-noms-duckdns-certificats-dns01-et-frontal-sni.md @@ -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. diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 6c0deb6..a5546d0 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-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` | diff --git a/infra/README.md b/infra/README.md index da74e09..7df569b 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, l'enregistrement DuckDNS de `domaine` doit viser +`ssh_host`, et son jeton se trouver dans `/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. 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..c865d51 100644 --- a/infra/proxy/README.md +++ b/infra/proxy/README.md @@ -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 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..55ebea2 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 = "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 /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." diff --git a/scripts/provision-host.sh b/scripts/provision-host.sh index 1e552d2..7808457 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.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" - 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_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 <&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 < @@ -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=. 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 From d687d7dc58053837bdbb53ee98bb7a2b1ba86306 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 23 Sep 2026 11:38:44 +0200 Subject: [PATCH 2/5] =?UTF-8?q?fix(deploy):=20passe=20de=20DuckDNS=20?= =?UTF-8?q?=C3=A0=20deSEC,=20filtr=C3=A9=20par=20l'=C3=A9cole?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le filtrage du réseau de l'école bloque duckdns.org, site et API, depuis les postes comme depuis la VM : sans API, pas de défi DNS-01. deSEC (dedyn.io) répond depuis les deux. - Domaine enervision-g3.dedyn.io ; provision-host.sh publie par l'API deSEC l'enregistrement du domaine et son joker vers la VM. - make tls-desec remplace tls-duckdns. acme.sh recopie le jeton dans acme/account.conf : le dossier est retiré aux autres comptes. - deploy.yml ne demande un certificat qu'à un .env déjà réaligné sur le domaine deSEC, pour ne pas faire échouer un déploiement en cours de migration. --- .github/workflows/deploy.yml | 5 +-- Makefile | 25 ++++++------- ...desec-certificats-dns01-et-frontal-sni.md} | 36 +++++++++++-------- docs/architecture/10-infra.md | 8 ++--- infra/README.md | 8 ++--- infra/proxy/README.md | 6 ++-- .../environments/vm-eni/variables.tf | 4 +-- scripts/provision-host.sh | 30 ++++++++++++---- 8 files changed, 74 insertions(+), 48 deletions(-) rename docs/adr/{0018-noms-duckdns-certificats-dns01-et-frontal-sni.md => 0018-noms-desec-certificats-dns01-et-frontal-sni.md} (69%) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 14e392c..9f733d3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -28,7 +28,7 @@ jobs: timeout-minutes: 30 environment: name: ${{ github.ref_name == 'main' && 'prod' || github.ref_name == 'dev' && 'rec' || 'dev' }} - 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' }} + url: ${{ github.ref_name == 'main' && 'https://enervision-g3.dedyn.io' || github.ref_name == 'dev' && 'https://rec.enervision-g3.dedyn.io' || 'https://dev.enervision-g3.dedyn.io' }} env: ENVIRONNEMENT: ${{ github.ref_name == 'main' && 'prod' || github.ref_name == 'dev' && 'rec' || 'dev' }} PORT_HTTPS: ${{ github.ref_name == 'main' && '10443' || github.ref_name == 'dev' && '8443' || '9443' }} @@ -54,7 +54,8 @@ jobs: echo "::endgroup::" echo "::group::Reconstruit et redémarre la stack" - if [ -r /srv/enervision/duckdns.token ]; then make tls-duckdns; fi + # Un `.env` pas encore réaligné par provision-host.sh porte encore un nom en `.local`. + if [ -r ../desec.token ] && grep -q '^PUBLIC_HOST=.*\.dedyn\.io$' .env; then make tls-desec; fi make stack-up if [ "${ENVIRONNEMENT}" = prod ]; then make front-up; fi echo "::endgroup::" diff --git a/Makefile b/Makefile index dfd883d..a46b071 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 tls-duckdns front-up stack-up stack-down stack-logs \ + tls-selfsigned tls-acme tls-renew tls-desec 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 @@ -236,20 +236,21 @@ tls-renew: ## Renouvelle les certificats Let's Encrypt et recharge le proxy $(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`. +# par l'API deSEC (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 \ +DESEC_TOKEN_FILE ?= $(abspath $(CURDIR)/../desec.token) +acme-sh = docker run --rm --user "$$(id -u):$$(id -g)" -e DEDYN_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 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-desec: ## Certificat Let's Encrypt par DNS-01 deSEC, renouvelé seulement à échéance + @case "$(PUBLIC_HOST)" in *.dedyn.io) ;; *) echo "PUBLIC_HOST=$(PUBLIC_HOST) n'est pas un nom deSEC"; exit 1 ;; esac + @test -r "$(DESEC_TOKEN_FILE)" || { echo "Jeton deSEC illisible : $(DESEC_TOKEN_FILE)"; exit 1; } + @mkdir -p infra/proxy/acme && chmod 700 infra/proxy/acme + @DEDYN_TOKEN="$$(tr -d '[:space:]' < "$(DESEC_TOKEN_FILE)")"; export DEDYN_TOKEN; \ + $(acme-sh) --issue --server letsencrypt --dns dns_desec -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 \ diff --git a/docs/adr/0018-noms-duckdns-certificats-dns01-et-frontal-sni.md b/docs/adr/0018-noms-desec-certificats-dns01-et-frontal-sni.md similarity index 69% rename from docs/adr/0018-noms-duckdns-certificats-dns01-et-frontal-sni.md rename to docs/adr/0018-noms-desec-certificats-dns01-et-frontal-sni.md index 56f30a7..bf15e4f 100644 --- a/docs/adr/0018-noms-duckdns-certificats-dns01-et-frontal-sni.md +++ b/docs/adr/0018-noms-desec-certificats-dns01-et-frontal-sni.md @@ -1,4 +1,4 @@ -# 0018 - Noms DuckDNS, certificats Let's Encrypt par DNS-01 et frontal SNI sans port +# 0018 - Noms deSEC, certificats Let's Encrypt par DNS-01 et frontal SNI sans port - Statut : accepté - Date : 2026-09-23 @@ -13,21 +13,24 @@ rien de présentable à un jury, et rien d'utilisable par quelqu'un qui n'a pas 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. +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 deSEC, 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.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 noms publics qui visent l'IP privée.** `enervision-g3.dedyn.io`, zone gratuite de deSEC, +porte la prod, et un enregistrement joker `*` porte `rec.` et `dev.`. `provision-host.sh` publie +ces deux enregistrements par l'API deSEC : 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 DuckDNS, qui pose -l'enregistrement TXT : Let's Encrypt n'a jamais à joindre la VM. `make tls-duckdns` (acme.sh +**Des certificats Let's Encrypt par défi DNS-01.** Le défi passe par l'API deSEC, qui pose +l'enregistrement TXT : Let's Encrypt n'a jamais à joindre la VM. `make tls-desec` (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. +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, @@ -49,6 +52,8 @@ clone de la prod, en retard sur `main`, sans dépendre de son `.env.example`. - **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. - **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. @@ -67,9 +72,12 @@ clone de la prod, en retard sur `main`, sans dépendre de son `.env.example`. 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 +- Le jeton deSEC vit dans `/srv/enervision/desec.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. +- deSEC 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 deSEC 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 a5546d0..1fb5b6f 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -238,7 +238,7 @@ 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) ; noms, -certificats et frontal sans port dans l'[ADR 0018](../adr/0018-noms-duckdns-certificats-dns01-et-frontal-sni.md). +certificats et frontal sans port dans l'[ADR 0018](../adr/0018-noms-desec-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 @@ -248,20 +248,20 @@ 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-g3.duckdns.org` | `https://rec.enervision-g3.duckdns.org` | `https://enervision-g3.duckdns.org` | +| URL | `https://dev.enervision-g3.dedyn.io` | `https://rec.enervision-g3.dedyn.io` | `https://enervision-g3.dedyn.io` | | 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 sont publics chez DuckDNS et visent l'IP privée de la VM : rien à déclarer sur +Les trois noms sont publics chez deSEC 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é à +garde son certificat Let's Encrypt, obtenu par défi DNS-01 (`make tls-desec`) 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 diff --git a/infra/README.md b/infra/README.md index 7df569b..7d0ee54 100644 --- a/infra/README.md +++ b/infra/README.md @@ -38,10 +38,10 @@ 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 `/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`) +Noms et certificats (ADR 0018) : avant l'apply, le jeton deSEC de `domaine` doit se trouver dans +`/desec.token` (600, proprietaire). L'apply fait alors pointer le domaine et son joker +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 diff --git a/infra/proxy/README.md b/infra/proxy/README.md index c865d51..b3ec6eb 100644 --- a/infra/proxy/README.md +++ b/infra/proxy/README.md @@ -78,12 +78,12 @@ Renouvellement, à passer en tâche planifiée sur la machine : ### 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, +La VM n'a qu'une IP privée : le défi HTTP-01 y est impossible. Ses trois noms sont chez deSEC, 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)). +([ADR 0018](../../docs/adr/0018-noms-desec-certificats-dns01-et-frontal-sni.md)). ```bash -make tls-duckdns # PUBLIC_HOST lu dans .env, jeton dans ../duckdns.token (600) +make tls-desec # PUBLIC_HOST lu dans .env, jeton dans ../desec.token (600) ``` La cible est rejouable : acme.sh ne renouvelle qu'à trente jours de l'échéance, installe le diff --git a/infra/terraform/environments/vm-eni/variables.tf b/infra/terraform/environments/vm-eni/variables.tf index 55ebea2..d8479a9 100644 --- a/infra/terraform/environments/vm-eni/variables.tf +++ b/infra/terraform/environments/vm-eni/variables.tf @@ -45,8 +45,8 @@ variable "depot_url" { 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 /duckdns.token sur la machine." - default = "enervision-g3.duckdns.org" + description = "Domaine deSEC de la prod, rec. et dev. en sous-domaines (ADR 0018). Son jeton doit se trouver dans /desec.token sur la machine : provision-host.sh y fait pointer le domaine et son joker vers la machine." + default = "enervision-g3.dedyn.io" } variable "adresse_publique" { diff --git a/scripts/provision-host.sh b/scripts/provision-host.sh index 7808457..baae538 100755 --- a/scripts/provision-host.sh +++ b/scripts/provision-host.sh @@ -15,10 +15,10 @@ set -euo pipefail DEPOT="${REPO_URL:-https://github.com/ineszang/ProjetPiscine_EnerVision.git}" RACINE="${RACINE:-/srv/enervision}" -DOMAINE="${DOMAINE:-enervision-g3.duckdns.org}" +DOMAINE="${DOMAINE:-enervision-g3.dedyn.io}" ADRESSE="${PUBLIC_IP:-$(hostname -I | awk '{print $1}')}" PROPRIETAIRE="${PROPRIETAIRE:-${SUDO_USER:-}}" -JETON_DUCKDNS="$RACINE/duckdns.token" +JETON_DESEC="$RACINE/desec.token" COMPOSE_MINIMALE="2.24.4" erreur() { echo "erreur : $*" >&2; exit 1; } @@ -130,19 +130,34 @@ preparer() { | grep -q " does match"; then (cd "$dossier" && PUBLIC_HOST="$hote" PUBLIC_IP="$ADRESSE" ./scripts/tls-selfsigned.sh --force) fi - if [[ -r "$JETON_DUCKDNS" && "$hote" == *.duckdns.org ]]; then - make -C "$dossier" --no-print-directory tls-duckdns PUBLIC_HOST="$hote" \ + if [[ -r "$JETON_DESEC" && "$hote" == *.dedyn.io ]]; then + make -C "$dossier" --no-print-directory tls-desec 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" } +# Le jeton passe par `curl --config -`, donc par l'entrée standard et jamais par `argv`. +publier_dns() { + [[ -r "$JETON_DESEC" ]] || { echo "pas de jeton $JETON_DESEC : ni DNS ni Let's Encrypt"; return 0; } + local enregistrements + enregistrements="[{\"subname\": \"\", \"type\": \"A\", \"ttl\": 3600, \"records\": [\"$ADRESSE\"]}, + {\"subname\": \"*\", \"type\": \"A\", \"ttl\": 3600, \"records\": [\"$ADRESSE\"]}]" + if printf 'header = "Authorization: Token %s"\n' "$(tr -d '[:space:]' < "$JETON_DESEC")" \ + | curl -fsS --max-time 20 --config - -X PATCH -H "Content-Type: application/json" \ + --data "$enregistrements" "https://desec.io/api/v1/domains/$DOMAINE/rrsets/" >/dev/null; then + echo "DNS : $DOMAINE et *.$DOMAINE visent $ADRESSE" + else + echo "DNS : deSEC refuse la mise à jour de $DOMAINE, enregistrements inchangés" >&2 + fi +} + 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 +23 4 * * * $PROPRIETAIRE for e in prod rec dev; do make -C $RACINE/\$e --no-print-directory tls-desec; done 2>&1 | logger -t enervision-tls CRON chmod 644 /etc/cron.d/enervision-tls echo "renouvellement planifié : /etc/cron.d/enervision-tls" @@ -150,6 +165,7 @@ CRON verifier_outils mkdir -p "$RACINE" +publier_dns # 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. @@ -176,6 +192,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. -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é. +Noms et certificats : le jeton deSEC de $DOMAINE doit se trouver dans $JETON_DESEC (600, +propriétaire du runner). Sans lui, ni enregistrement DNS ni Let's Encrypt : auto-signé. FIN From 22a88e193cd53cab214ca31f27808626a765ada9 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 23 Sep 2026 11:47:07 +0200 Subject: [PATCH 3/5] =?UTF-8?q?fix(deploy):=20passe=20=C3=A0=20dynv6=20et?= =?UTF-8?q?=20rend=20le=20d=C3=A9fi=20DNS-01=20ind=C3=A9pendant=20du=20fou?= =?UTF-8?q?rnisseur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deSEC n'ouvre plus de nouveaux domaines dedyn.io, et duckdns.org est filtré par l'école. dynv6 répond depuis les postes et depuis la VM. - Zone enervision-g3.dynv6.net ; provision-host.sh pointe la zone, rec et dev vers la VM par l'API dynv6 (bloc Python, idempotent). - make tls-dns01 remplace tls-desec : DNS01_API et DNS01_JETON_VAR nomment le greffon acme.sh, le jeton vit dans ../dns.token quel que soit le fournisseur. Un domaine acheté ne demandera que ces variables. - deploy.yml ne demande un certificat qu'à un .env qui ne porte plus de nom en .local. - ADR 0018 renommé noms-publics : deSEC et DuckDNS en alternatives. --- .github/workflows/deploy.yml | 4 +- Makefile | 20 ++++--- ...blics-certificats-dns01-et-frontal-sni.md} | 26 +++++---- docs/architecture/10-infra.md | 8 +-- infra/README.md | 6 +- infra/proxy/README.md | 9 +-- .../environments/vm-eni/variables.tf | 4 +- scripts/provision-host.sh | 55 +++++++++++++------ 8 files changed, 80 insertions(+), 52 deletions(-) rename docs/adr/{0018-noms-desec-certificats-dns01-et-frontal-sni.md => 0018-noms-publics-certificats-dns01-et-frontal-sni.md} (80%) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9f733d3..1e24499 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -28,7 +28,7 @@ jobs: timeout-minutes: 30 environment: name: ${{ github.ref_name == 'main' && 'prod' || github.ref_name == 'dev' && 'rec' || 'dev' }} - url: ${{ github.ref_name == 'main' && 'https://enervision-g3.dedyn.io' || github.ref_name == 'dev' && 'https://rec.enervision-g3.dedyn.io' || 'https://dev.enervision-g3.dedyn.io' }} + 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' && '10443' || github.ref_name == 'dev' && '8443' || '9443' }} @@ -55,7 +55,7 @@ jobs: 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 ../desec.token ] && grep -q '^PUBLIC_HOST=.*\.dedyn\.io$' .env; then make tls-desec; fi + 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::" diff --git a/Makefile b/Makefile index a46b071..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 tls-desec front-up 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 @@ -236,20 +236,22 @@ tls-renew: ## Renouvelle les certificats Let's Encrypt et recharge le proxy $(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 deSEC (ADR 0018). Le jeton transite par l'environnement, jamais par `argv`. +# 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 -DESEC_TOKEN_FILE ?= $(abspath $(CURDIR)/../desec.token) -acme-sh = docker run --rm --user "$$(id -u):$$(id -g)" -e DEDYN_TOKEN -e AUTO_UPGRADE=0 \ +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-desec: ## Certificat Let's Encrypt par DNS-01 deSEC, renouvelé seulement à échéance - @case "$(PUBLIC_HOST)" in *.dedyn.io) ;; *) echo "PUBLIC_HOST=$(PUBLIC_HOST) n'est pas un nom deSEC"; exit 1 ;; esac - @test -r "$(DESEC_TOKEN_FILE)" || { echo "Jeton deSEC illisible : $(DESEC_TOKEN_FILE)"; exit 1; } +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 - @DEDYN_TOKEN="$$(tr -d '[:space:]' < "$(DESEC_TOKEN_FILE)")"; export DEDYN_TOKEN; \ - $(acme-sh) --issue --server letsencrypt --dns dns_desec -d "$(PUBLIC_HOST)"; \ + @$(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 diff --git a/docs/adr/0018-noms-desec-certificats-dns01-et-frontal-sni.md b/docs/adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md similarity index 80% rename from docs/adr/0018-noms-desec-certificats-dns01-et-frontal-sni.md rename to docs/adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md index bf15e4f..448e980 100644 --- a/docs/adr/0018-noms-desec-certificats-dns01-et-frontal-sni.md +++ b/docs/adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md @@ -1,4 +1,4 @@ -# 0018 - Noms deSEC, certificats Let's Encrypt par DNS-01 et frontal SNI sans port +# 0018 - Noms publics, certificats Let's Encrypt par DNS-01 et frontal SNI sans port - Statut : accepté - Date : 2026-09-23 @@ -15,19 +15,19 @@ 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 deSEC, mais le filtrage de l'école bloque duckdns.org, site +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.dedyn.io`, zone gratuite de deSEC, -porte la prod, et un enregistrement joker `*` porte `rec.` et `dev.`. `provision-host.sh` publie -ces deux enregistrements par l'API deSEC : le DNS est décrit par le code comme le reste. Tout +**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 deSEC, qui pose -l'enregistrement TXT : Let's Encrypt n'a jamais à joindre la VM. `make tls-desec` (acme.sh +**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. @@ -43,6 +43,10 @@ utilisateur bloquerait la connexion de tous. Chaque proxy de stack reçoit donc é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 @@ -54,6 +58,8 @@ clone de la prod, en retard sur `main`, sans dépendre de son `.env.example`. 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. @@ -72,12 +78,12 @@ clone de la prod, en retard sur `main`, sans dépendre de son `.env.example`. 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 deSEC vit dans `/srv/enervision/desec.token`, jamais dans git, GitHub ni le state +- 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. -- deSEC devient une dépendance : s'il tombe, les noms cessent de résoudre et les +- 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 deSEC arrêterait les renouvellements, pas les +- 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 1fb5b6f..0750c8e 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -238,7 +238,7 @@ 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) ; noms, -certificats et frontal sans port dans l'[ADR 0018](../adr/0018-noms-desec-certificats-dns01-et-frontal-sni.md). +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 @@ -248,20 +248,20 @@ 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-g3.dedyn.io` | `https://rec.enervision-g3.dedyn.io` | `https://enervision-g3.dedyn.io` | +| 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 sont publics chez deSEC et visent l'IP privée de la VM : rien à déclarer sur +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-desec`) et renouvelé à +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 diff --git a/infra/README.md b/infra/README.md index 7d0ee54..506009f 100644 --- a/infra/README.md +++ b/infra/README.md @@ -38,9 +38,9 @@ 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, le jeton deSEC de `domaine` doit se trouver dans -`/desec.token` (600, proprietaire). L'apply fait alors pointer le domaine et son joker -vers la machine, obtient un certificat Let's Encrypt par environnement et planifie leur +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`. diff --git a/infra/proxy/README.md b/infra/proxy/README.md index b3ec6eb..0630576 100644 --- a/infra/proxy/README.md +++ b/infra/proxy/README.md @@ -78,15 +78,16 @@ Renouvellement, à passer en tâche planifiée sur la machine : ### 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 deSEC, +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-desec-certificats-dns01-et-frontal-sni.md)). +([ADR 0018](../../docs/adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md)). ```bash -make tls-desec # PUBLIC_HOST lu dans .env, jeton dans ../desec.token (600) +make tls-dns01 # PUBLIC_HOST lu dans .env, jeton dans ../dns.token (600) ``` -La cible est rejouable : acme.sh ne renouvelle qu'à trente jours de l'échéance, installe le +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. diff --git a/infra/terraform/environments/vm-eni/variables.tf b/infra/terraform/environments/vm-eni/variables.tf index d8479a9..50e60f3 100644 --- a/infra/terraform/environments/vm-eni/variables.tf +++ b/infra/terraform/environments/vm-eni/variables.tf @@ -45,8 +45,8 @@ variable "depot_url" { variable "domaine" { type = string - description = "Domaine deSEC de la prod, rec. et dev. en sous-domaines (ADR 0018). Son jeton doit se trouver dans /desec.token sur la machine : provision-host.sh y fait pointer le domaine et son joker vers la machine." - default = "enervision-g3.dedyn.io" + 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" { diff --git a/scripts/provision-host.sh b/scripts/provision-host.sh index baae538..1d44ce1 100755 --- a/scripts/provision-host.sh +++ b/scripts/provision-host.sh @@ -15,10 +15,10 @@ set -euo pipefail DEPOT="${REPO_URL:-https://github.com/ineszang/ProjetPiscine_EnerVision.git}" RACINE="${RACINE:-/srv/enervision}" -DOMAINE="${DOMAINE:-enervision-g3.dedyn.io}" +DOMAINE="${DOMAINE:-enervision-g3.dynv6.net}" ADRESSE="${PUBLIC_IP:-$(hostname -I | awk '{print $1}')}" PROPRIETAIRE="${PROPRIETAIRE:-${SUDO_USER:-}}" -JETON_DESEC="$RACINE/desec.token" +JETON_DNS="$RACINE/dns.token" COMPOSE_MINIMALE="2.24.4" erreur() { echo "erreur : $*" >&2; exit 1; } @@ -130,26 +130,45 @@ preparer() { | grep -q " does match"; then (cd "$dossier" && PUBLIC_HOST="$hote" PUBLIC_IP="$ADRESSE" ./scripts/tls-selfsigned.sh --force) fi - if [[ -r "$JETON_DESEC" && "$hote" == *.dedyn.io ]]; then - make -C "$dossier" --no-print-directory tls-desec PUBLIC_HOST="$hote" \ + 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" } -# Le jeton passe par `curl --config -`, donc par l'entrée standard et jamais par `argv`. +# 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_DESEC" ]] || { echo "pas de jeton $JETON_DESEC : ni DNS ni Let's Encrypt"; return 0; } - local enregistrements - enregistrements="[{\"subname\": \"\", \"type\": \"A\", \"ttl\": 3600, \"records\": [\"$ADRESSE\"]}, - {\"subname\": \"*\", \"type\": \"A\", \"ttl\": 3600, \"records\": [\"$ADRESSE\"]}]" - if printf 'header = "Authorization: Token %s"\n' "$(tr -d '[:space:]' < "$JETON_DESEC")" \ - | curl -fsS --max-time 20 --config - -X PATCH -H "Content-Type: application/json" \ - --data "$enregistrements" "https://desec.io/api/v1/domains/$DOMAINE/rrsets/" >/dev/null; then - echo "DNS : $DOMAINE et *.$DOMAINE visent $ADRESSE" - else - echo "DNS : deSEC refuse la mise à jour de $DOMAINE, enregistrements inchangés" >&2 - fi + [[ -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, 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']}", + "Content-Type": "application/json", "Accept": "application/json"}) + with urllib.request.urlopen(requete, timeout=20) as reponse: + contenu = reponse.read() + return json.loads(contenu) if contenu else None + +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}) +print(f"DNS : {domaine}, {', '.join(sous_noms)} visent {adresse}") +PY } planifier_renouvellement() { @@ -157,7 +176,7 @@ planifier_renouvellement() { cat > /etc/cron.d/enervision-tls <&1 | logger -t enervision-tls +23 4 * * * $PROPRIETAIRE for e in prod rec dev; do make -C $RACINE/\$e --no-print-directory tls-dns01; done 2>&1 | logger -t enervision-tls CRON chmod 644 /etc/cron.d/enervision-tls echo "renouvellement planifié : /etc/cron.d/enervision-tls" @@ -192,6 +211,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. -Noms et certificats : le jeton deSEC de $DOMAINE doit se trouver dans $JETON_DESEC (600, +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 From 4d88604a0785695dcd05bbc7c35ea10ebc1eb264 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 23 Sep 2026 11:56:33 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix(deploy):=20rejoue=20la=20synchronisatio?= =?UTF-8?q?n=20dynv6=20quand=20l'API=20ne=20r=C3=A9pond=20pas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'API dynv6 laisse par intermittence une écriture sans réponse, parfois appliquée malgré tout. La synchronisation est rejouée jusqu'à trois fois et relit l'état avant chaque écriture : une création aboutie malgré le délai n'est jamais dupliquée. Délai par appel porté à 60 s. Validé contre le vrai dynv6 depuis la VM : zone, rec et dev visent 10.101.200.37, et un certificat de test Let's Encrypt a été émis par DNS-01 pour dev.enervision-g3.dynv6.net. --- scripts/provision-host.sh | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/scripts/provision-host.sh b/scripts/provision-host.sh index 1d44ce1..70e01ba 100755 --- a/scripts/provision-host.sh +++ b/scripts/provision-host.sh @@ -143,7 +143,7 @@ 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, urllib.request +import json, os, sys, time, urllib.request domaine, adresse, *sous_noms = sys.argv[1:] @@ -151,22 +151,34 @@ 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']}", + 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=20) as reponse: + with urllib.request.urlopen(requete, timeout=60) as reponse: contenu = reponse.read() return json.loads(contenu) if contenu else None -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}) +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 } From dbcd5c424074e0103c607e063567dac6cd4d48eb Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 23 Sep 2026 12:35:20 +0200 Subject: [PATCH 5/5] fix(deploy): passe la prod sur prod.enervision-g3.dynv6.net MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dynv6 sert mal un TXT _acme-challenge à la racine de la zone : l'API ne le liste ni ne le supprime, et un seul de ses trois serveurs le renvoie. Le défi DNS-01 de la prod échouait donc à chaque essai, alors que rec. et dev. passaient. La prod rejoint ses voisines en sous-domaine, ce qui aligne aussi les trois noms sur les environnements. - provision-host.sh : hôte prod.$DOMAINE, enregistrement A prod publié. - Makefile : --dnssleep 90, le temps que les trois serveurs de dynv6 servent le TXT avant la validation multi-réseaux de Let's Encrypt. - deploy.yml, ADR 0018, 10-infra.md, infra/README.md, Terraform. --- .github/workflows/deploy.yml | 2 +- Makefile | 6 +++--- ...8-noms-publics-certificats-dns01-et-frontal-sni.md | 11 +++++++---- docs/architecture/10-infra.md | 2 +- infra/README.md | 2 +- infra/terraform/environments/vm-eni/variables.tf | 2 +- scripts/provision-host.sh | 8 ++++---- 7 files changed, 18 insertions(+), 15 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1e24499..b5f057b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -28,7 +28,7 @@ jobs: timeout-minutes: 30 environment: name: ${{ github.ref_name == 'main' && 'prod' || github.ref_name == 'dev' && 'rec' || 'dev' }} - 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' }} + url: ${{ github.ref_name == 'main' && 'https://prod.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' && '10443' || github.ref_name == 'dev' && '8443' || '9443' }} diff --git a/Makefile b/Makefile index 1cb5669..9d1e036 100644 --- a/Makefile +++ b/Makefile @@ -244,14 +244,14 @@ 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. +# acme.sh sort en 2 quand le certificat n'est pas à renouveler, et recopie le jeton dans +# acme/account.conf, d'où le chmod. `--dnssleep` : Let's Encrypt valide depuis plusieurs réseaux. 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)"; \ + $(acme-sh) --issue --server letsencrypt --dns $(DNS01_API) --dnssleep 90 -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 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 index 448e980..27b6928 100644 --- a/docs/adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md +++ b/docs/adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md @@ -20,16 +20,19 @@ 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 +**Des noms publics qui visent l'IP privée.** Dans `enervision-g3.dynv6.net`, zone gratuite de +dynv6, trois enregistrements A portent `prod.`, `rec.` et `dev.`. `provision-host.sh` les publie +par l'API dynv6 : le DNS est décrit par le code comme le reste. La prod n'est pas à la racine de +la zone : dynv6 y sert mal un TXT `_acme-challenge`, que l'API ne liste ni ne supprime et qu'un +seul de ses trois serveurs renvoie (constaté le 23/09), si bien que son défi DNS-01 échoue. 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 +déploiement et chaque nuit par cron. `--dnssleep 90` laisse aux trois serveurs de dynv6 le temps +de servir le TXT avant que Let's Encrypt ne le cherche depuis plusieurs réseaux. 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 diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 0750c8e..8cea7bd 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -248,7 +248,7 @@ 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-g3.dynv6.net` | `https://rec.enervision-g3.dynv6.net` | `https://enervision-g3.dynv6.net` | +| URL | `https://dev.enervision-g3.dynv6.net` | `https://rec.enervision-g3.dynv6.net` | `https://prod.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` | diff --git a/infra/README.md b/infra/README.md index 506009f..6f548ce 100644 --- a/infra/README.md +++ b/infra/README.md @@ -40,7 +40,7 @@ sur `dev` et sur `main`, et a chaque lancement manuel d'une autre branche pour ` 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 +zone, `prod`, `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`. diff --git a/infra/terraform/environments/vm-eni/variables.tf b/infra/terraform/environments/vm-eni/variables.tf index 50e60f3..41e46a0 100644 --- a/infra/terraform/environments/vm-eni/variables.tf +++ b/infra/terraform/environments/vm-eni/variables.tf @@ -45,7 +45,7 @@ variable "depot_url" { 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." + description = "Zone dynv6 des trois environnements, 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 trois sous-domaines vers la machine." default = "enervision-g3.dynv6.net" } diff --git a/scripts/provision-host.sh b/scripts/provision-host.sh index 70e01ba..dcd28c8 100755 --- a/scripts/provision-host.sh +++ b/scripts/provision-host.sh @@ -141,7 +141,7 @@ preparer() { # 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' \ + DNS_TOKEN="$(tr -d '[:space:]' < "$JETON_DNS")" python3 - "$DOMAINE" "$ADRESSE" prod rec dev <<'PY' \ || echo "DNS : dynv6 refuse la mise à jour de $DOMAINE, enregistrements inchangés" >&2 import json, os, sys, time, urllib.request @@ -198,10 +198,10 @@ verifier_outils mkdir -p "$RACINE" publier_dns -# 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. +# Supervision active en prod seulement (ADR 0016). La prod vit sur `prod.` et non à la racine : +# dynv6 ne sert pas de façon fiable un TXT `_acme-challenge` à la racine de la zone (ADR 0018). # 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 prod main "prod.$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