From 44713e4ca0285ddaaedfd37294c1cc6b59602035 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:50 +0200 Subject: [PATCH 1/4] feat(infra): provisionnement Hetzner, k3s et secrets SOPS Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- infra/prod/.gitignore | 43 +++ infra/prod/.sops.yaml | 40 ++ infra/prod/Makefile | 140 +++++++ infra/prod/README.md | 152 ++++++++ infra/prod/cloudflare/README.md | 171 +++++++++ infra/prod/data-node/Dockerfile.postgres | 29 ++ infra/prod/data-node/backup/pg-backup.sh | 134 +++++++ infra/prod/data-node/backup/pg-restore.sh | 192 ++++++++++ .../backup/xpeditis-backup-verify.service | 26 ++ .../backup/xpeditis-backup-verify.timer | 12 + .../data-node/backup/xpeditis-backup.service | 29 ++ .../data-node/backup/xpeditis-backup.timer | 16 + infra/prod/data-node/conf/pg_hba.conf | 30 ++ infra/prod/data-node/conf/postgresql.conf | 108 ++++++ infra/prod/data-node/conf/redis.conf | 85 +++++ infra/prod/data-node/docker-compose.data.yml | 187 ++++++++++ infra/prod/env/data-node.env.example | 69 ++++ infra/prod/env/github-secrets.md | 69 ++++ infra/prod/k8s/base/00-namespaces.yaml | 30 ++ infra/prod/k8s/base/01-limits.yaml | 59 +++ infra/prod/k8s/base/02-configmap-backend.yaml | 99 +++++ infra/prod/k8s/base/03-secrets.template.yaml | 165 ++++++++ infra/prod/k8s/base/04-backend.yaml | 216 +++++++++++ infra/prod/k8s/base/05-frontend.yaml | 163 ++++++++ infra/prod/k8s/base/06-log-exporter.yaml | 73 ++++ infra/prod/k8s/base/07-migration-job.yaml | 76 ++++ .../prod/k8s/base/08-traefik-middlewares.yaml | 148 ++++++++ infra/prod/k8s/base/09-ingress.yaml | 128 +++++++ infra/prod/k8s/base/10-network-policies.yaml | 239 ++++++++++++ infra/prod/k8s/base/11-certificate.yaml | 59 +++ infra/prod/k8s/cluster/cluster-issuer.yaml | 56 +++ infra/prod/k8s/monitoring/01-loki.yaml | 172 +++++++++ infra/prod/k8s/monitoring/02-promtail.yaml | 185 +++++++++ infra/prod/k8s/monitoring/03-prometheus.yaml | 351 ++++++++++++++++++ .../prod/k8s/monitoring/04-node-exporter.yaml | 95 +++++ .../prod/k8s/monitoring/05-alertmanager.yaml | 150 ++++++++ infra/prod/k8s/monitoring/06-grafana.yaml | 219 +++++++++++ infra/prod/scripts/00-bootstrap-common.sh | 227 +++++++++++ infra/prod/scripts/01-setup-data-node.sh | 170 +++++++++ infra/prod/scripts/02-setup-k3s-server.sh | 245 ++++++++++++ .../prod/scripts/03-install-cluster-addons.sh | 86 +++++ infra/prod/scripts/deploy-monitoring.sh | 76 ++++ infra/prod/scripts/deploy.sh | 126 +++++++ infra/prod/scripts/harden-seed-data.sh | 151 ++++++++ infra/prod/scripts/preflight-check.sh | 187 ++++++++++ infra/prod/scripts/refresh-cloudflare-ips.sh | 88 +++++ infra/prod/scripts/secrets-apply.sh | 50 +++ infra/prod/scripts/smoke-test.sh | 81 ++++ infra/prod/scripts/ssh-deploy-wrapper.sh | 56 +++ infra/prod/terraform/cloud-init.yaml.tftpl | 76 ++++ infra/prod/terraform/firewall.tf | 157 ++++++++ infra/prod/terraform/network.tf | 52 +++ infra/prod/terraform/outputs.tf | 64 ++++ infra/prod/terraform/servers.tf | 116 ++++++ infra/prod/terraform/terraform.tfvars.example | 37 ++ infra/prod/terraform/variables.tf | 120 ++++++ infra/prod/terraform/versions.tf | 33 ++ 57 files changed, 6413 insertions(+) create mode 100644 infra/prod/.gitignore create mode 100644 infra/prod/.sops.yaml create mode 100644 infra/prod/Makefile create mode 100644 infra/prod/README.md create mode 100644 infra/prod/cloudflare/README.md create mode 100644 infra/prod/data-node/Dockerfile.postgres create mode 100755 infra/prod/data-node/backup/pg-backup.sh create mode 100755 infra/prod/data-node/backup/pg-restore.sh create mode 100644 infra/prod/data-node/backup/xpeditis-backup-verify.service create mode 100644 infra/prod/data-node/backup/xpeditis-backup-verify.timer create mode 100644 infra/prod/data-node/backup/xpeditis-backup.service create mode 100644 infra/prod/data-node/backup/xpeditis-backup.timer create mode 100644 infra/prod/data-node/conf/pg_hba.conf create mode 100644 infra/prod/data-node/conf/postgresql.conf create mode 100644 infra/prod/data-node/conf/redis.conf create mode 100644 infra/prod/data-node/docker-compose.data.yml create mode 100644 infra/prod/env/data-node.env.example create mode 100644 infra/prod/env/github-secrets.md create mode 100644 infra/prod/k8s/base/00-namespaces.yaml create mode 100644 infra/prod/k8s/base/01-limits.yaml create mode 100644 infra/prod/k8s/base/02-configmap-backend.yaml create mode 100644 infra/prod/k8s/base/03-secrets.template.yaml create mode 100644 infra/prod/k8s/base/04-backend.yaml create mode 100644 infra/prod/k8s/base/05-frontend.yaml create mode 100644 infra/prod/k8s/base/06-log-exporter.yaml create mode 100644 infra/prod/k8s/base/07-migration-job.yaml create mode 100644 infra/prod/k8s/base/08-traefik-middlewares.yaml create mode 100644 infra/prod/k8s/base/09-ingress.yaml create mode 100644 infra/prod/k8s/base/10-network-policies.yaml create mode 100644 infra/prod/k8s/base/11-certificate.yaml create mode 100644 infra/prod/k8s/cluster/cluster-issuer.yaml create mode 100644 infra/prod/k8s/monitoring/01-loki.yaml create mode 100644 infra/prod/k8s/monitoring/02-promtail.yaml create mode 100644 infra/prod/k8s/monitoring/03-prometheus.yaml create mode 100644 infra/prod/k8s/monitoring/04-node-exporter.yaml create mode 100644 infra/prod/k8s/monitoring/05-alertmanager.yaml create mode 100644 infra/prod/k8s/monitoring/06-grafana.yaml create mode 100755 infra/prod/scripts/00-bootstrap-common.sh create mode 100755 infra/prod/scripts/01-setup-data-node.sh create mode 100755 infra/prod/scripts/02-setup-k3s-server.sh create mode 100755 infra/prod/scripts/03-install-cluster-addons.sh create mode 100755 infra/prod/scripts/deploy-monitoring.sh create mode 100755 infra/prod/scripts/deploy.sh create mode 100755 infra/prod/scripts/harden-seed-data.sh create mode 100755 infra/prod/scripts/preflight-check.sh create mode 100755 infra/prod/scripts/refresh-cloudflare-ips.sh create mode 100755 infra/prod/scripts/secrets-apply.sh create mode 100755 infra/prod/scripts/smoke-test.sh create mode 100755 infra/prod/scripts/ssh-deploy-wrapper.sh create mode 100644 infra/prod/terraform/cloud-init.yaml.tftpl create mode 100644 infra/prod/terraform/firewall.tf create mode 100644 infra/prod/terraform/network.tf create mode 100644 infra/prod/terraform/outputs.tf create mode 100644 infra/prod/terraform/servers.tf create mode 100644 infra/prod/terraform/terraform.tfvars.example create mode 100644 infra/prod/terraform/variables.tf create mode 100644 infra/prod/terraform/versions.tf diff --git a/infra/prod/.gitignore b/infra/prod/.gitignore new file mode 100644 index 0000000..0910068 --- /dev/null +++ b/infra/prod/.gitignore @@ -0,0 +1,43 @@ +# === Garde-fou : rien de dechiffre ne doit entrer dans Git =================== +# Tout secret est versionne UNIQUEMENT sous forme chiffree SOPS (*.sops.yaml). + +# Fichiers d'environnement en clair +*.env +.env +.env.* +!*.env.example +!.env.example + +# Secrets Kubernetes dechiffres (sortie de `sops -d`) +*.dec.yaml +*.decrypted.yaml +secrets-plain.yaml + +# Cles +*.key +*.pem +*.age +age.key +age-key.txt +id_ed25519* +id_rsa* + +# Kubeconfig +kubeconfig* +*.kubeconfig + +# Terraform +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +*.tfvars +!*.tfvars.example +crash.log +tfplan* + +# Dumps et backups locaux +*.dump +*.sql.gz +*.tar.zst +backups/ diff --git a/infra/prod/.sops.yaml b/infra/prod/.sops.yaml new file mode 100644 index 0000000..84ad59c --- /dev/null +++ b/infra/prod/.sops.yaml @@ -0,0 +1,40 @@ +# ============================================================================= +# Regles de chiffrement SOPS (age) +# ============================================================================= +# +# Un seul mecanisme de secrets pour toute la prod : SOPS + age. +# Il couvre a la fois les Secrets Kubernetes (noeud app) ET les fichiers .env +# du noeud de donnees (Docker Compose) -- ce qu'un controleur type Sealed +# Secrets ne saurait pas faire. +# +# Generation de la cle (UNE SEULE FOIS, cf. docs/mise-en-prod/06-secrets-sops.md) : +# age-keygen -o ~/.config/sops/age/keys.txt +# grep 'public key' ~/.config/sops/age/keys.txt +# +# Puis remplacer AGE_RECIPIENT_PRIMARY ci-dessous par la cle publique (age1...). +# La cle PRIVEE ne quitte jamais : votre poste + le coffre-fort (1Password / +# Bitwarden) + le secret GitHub Actions SOPS_AGE_KEY. +# +# Ajoutez une 2e recipient (cle de secours, stockee hors ligne, sur papier ou +# YubiKey) : sans elle, perdre le poste = perdre tous les secrets de prod. + +creation_rules: + # Secrets Kubernetes : on ne chiffre QUE les valeurs sous `data`/`stringData`, + # les metadonnees restent lisibles pour pouvoir relire un diff en revue. + - path_regex: k8s/.*\.sops\.yaml$ + encrypted_regex: '^(data|stringData)$' + age: >- + AGE_RECIPIENT_PRIMARY, + AGE_RECIPIENT_BACKUP + + # Fichiers d'environnement du noeud de donnees (docker compose) : tout chiffre. + - path_regex: (data-node|env)/.*\.sops\.(yaml|env)$ + age: >- + AGE_RECIPIENT_PRIMARY, + AGE_RECIPIENT_BACKUP + + # Filet de securite : tout autre .sops.yaml du dossier prod. + - path_regex: \.sops\.yaml$ + age: >- + AGE_RECIPIENT_PRIMARY, + AGE_RECIPIENT_BACKUP diff --git a/infra/prod/Makefile b/infra/prod/Makefile new file mode 100644 index 0000000..16e6081 --- /dev/null +++ b/infra/prod/Makefile @@ -0,0 +1,140 @@ +# ============================================================================= +# Xpeditis - production Hetzner +# ============================================================================= +# Toutes les cibles s'executent depuis infra/prod/. +# make help +# +# Les cibles qui touchent la production affichent ce qu'elles vont faire et +# demandent confirmation. Aucune ne s'execute par accident. + +SHELL := /bin/bash +.DEFAULT_GOAL := help + +KUBECONFIG ?= $(HOME)/.kube/xpeditis-prod.yaml +NAMESPACE ?= xpeditis-prod +REGISTRY ?= rg.fr-par.scw.cloud/weworkstudio +export KUBECONFIG + +BOLD := \033[1m +RESET := \033[0m + +.PHONY: help +help: ## Affiche cette aide + @printf '$(BOLD)Xpeditis - production$(RESET)\n\n' + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' + @printf '\nKUBECONFIG : $(KUBECONFIG)\n' + +# --- Infrastructure --------------------------------------------------------- + +.PHONY: tf-init +tf-init: ## Terraform : initialisation + cd terraform && terraform init + +.PHONY: tf-plan +tf-plan: ## Terraform : previsualise les changements d'infrastructure + cd terraform && terraform plan + +.PHONY: tf-apply +tf-apply: ## Terraform : applique (cree/modifie les serveurs) + @printf '$(BOLD)Cette commande modifie l infrastructure de PRODUCTION.$(RESET)\n' + @read -p 'Continuer ? [oui/non] ' r; [ "$$r" = oui ] + cd terraform && terraform apply + +.PHONY: tf-output +tf-output: ## Terraform : affiche les IP et les enregistrements DNS a creer + cd terraform && terraform output + +.PHONY: cloudflare-ips +cloudflare-ips: ## Compare les rangs IP Cloudflare avec firewall.tf + bash scripts/refresh-cloudflare-ips.sh + +# --- Secrets ---------------------------------------------------------------- + +.PHONY: secrets-edit +secrets-edit: ## Edite les secrets chiffres (SOPS ouvre votre editeur) + sops k8s/base/03-secrets.sops.yaml + +.PHONY: secrets-apply +secrets-apply: ## Applique les secrets sur le cluster + bash scripts/secrets-apply.sh + +.PHONY: secrets-check +secrets-check: ## Verifie qu'aucun secret en clair n'est pret a etre commite + @echo '>>> Fichiers suspects dans infra/prod :' + @! git ls-files --others --cached --exclude-standard . \ + | grep -E '\.(env|key|pem)$$|secrets\.yaml$$|tfvars$$' \ + | grep -v '\.example$$' \ + | grep -v '\.sops\.' \ + || (echo 'ARRET : des fichiers sensibles sont suivis ou non ignores.'; exit 1) + @echo 'Aucun fichier sensible detecte.' + +# --- Deploiement ------------------------------------------------------------ + +.PHONY: deploy +deploy: ## Deploie une version (make deploy TAG=prod-a1b2c3d) + @[ -n "$(TAG)" ] || (echo 'Usage: make deploy TAG=prod-a1b2c3d'; exit 1) + bash scripts/deploy.sh $(TAG) + +.PHONY: deploy-monitoring +deploy-monitoring: ## Deploie ou met a jour la pile d'observabilite + bash scripts/deploy-monitoring.sh + +.PHONY: rollback +rollback: ## Revient a la version precedente (backend + frontend) + @printf '$(BOLD)Retour arriere de la PRODUCTION.$(RESET)\n' + @read -p 'Continuer ? [oui/non] ' r; [ "$$r" = oui ] + kubectl -n $(NAMESPACE) rollout undo deploy/xpeditis-backend + kubectl -n $(NAMESPACE) rollout undo deploy/xpeditis-frontend + kubectl -n $(NAMESPACE) rollout status deploy/xpeditis-backend + kubectl -n $(NAMESPACE) rollout status deploy/xpeditis-frontend + +.PHONY: restart +restart: ## Redemarre les pods applicatifs (prise en compte des secrets) + kubectl -n $(NAMESPACE) rollout restart deploy/xpeditis-backend deploy/xpeditis-frontend + +# --- Controles -------------------------------------------------------------- + +.PHONY: preflight +preflight: ## Controle go / no-go avant ouverture au public + bash scripts/preflight-check.sh + +.PHONY: smoke +smoke: ## Tests de fumee sur les URLs publiques + bash scripts/smoke-test.sh + +.PHONY: status +status: ## Vue d'ensemble de la production + @printf '\n$(BOLD)Noeuds$(RESET)\n'; kubectl get nodes -o wide + @printf '\n$(BOLD)Application$(RESET)\n'; kubectl -n $(NAMESPACE) get pods,deploy,hpa + @printf '\n$(BOLD)Ingress$(RESET)\n'; kubectl -n $(NAMESPACE) get ingress + @printf '\n$(BOLD)Certificats$(RESET)\n'; kubectl get certificate -A + @printf '\n$(BOLD)Observabilite$(RESET)\n'; kubectl -n monitoring get pods + +.PHONY: logs +logs: ## Journaux du backend en direct + kubectl -n $(NAMESPACE) logs -l app.kubernetes.io/name=xpeditis-backend -f --tail=100 --max-log-requests=6 + +.PHONY: logs-frontend +logs-frontend: ## Journaux du frontend en direct + kubectl -n $(NAMESPACE) logs -l app.kubernetes.io/name=xpeditis-frontend -f --tail=100 --max-log-requests=6 + +.PHONY: events +events: ## Derniers evenements Kubernetes (diagnostic) + kubectl -n $(NAMESPACE) get events --sort-by=.lastTimestamp | tail -40 + +# --- Validation locale ------------------------------------------------------ + +.PHONY: validate +validate: ## Valide les manifests sans rien appliquer + @echo '>>> Validation cote serveur (dry-run)' + @for f in k8s/base/*.yaml k8s/monitoring/*.yaml k8s/cluster/*.yaml; do \ + case "$$f" in *secrets.template.yaml|*migration-job.yaml) continue;; esac; \ + printf ' %s\n' "$$f"; \ + kubectl apply --dry-run=server -f "$$f" >/dev/null || exit 1; \ + done + @echo '>>> Terraform' + @cd terraform && terraform validate + @echo '>>> Scripts shell' + @command -v shellcheck >/dev/null && shellcheck -S warning scripts/*.sh data-node/backup/*.sh || echo ' shellcheck absent, ignore' + @echo 'Validation terminee.' diff --git a/infra/prod/README.md b/infra/prod/README.md new file mode 100644 index 0000000..6f95465 --- /dev/null +++ b/infra/prod/README.md @@ -0,0 +1,152 @@ +# infra/prod — Production Hetzner + +Tout ce qui est nécessaire pour déployer et exploiter Xpeditis en production. + +> **La procédure pas à pas est dans [`docs/mise-en-prod/`](../../docs/mise-en-prod/README.md).** +> Ce README décrit *ce que contient le dossier* ; la doc décrit *quoi faire, dans quel ordre*. + +--- + +## Architecture cible + +``` + Internet + | + Cloudflare (DNS + WAF + anti-DDoS) + | seules les IP Cloudflare passent le firewall + v + ┌─────────────────────────────────────────────┐ + │ app-01 CPX41 8 vCPU / 16 Go fsn1 │ + │ │ + │ k3s (mono-nœud) │ + │ Traefik ──┬── api.xpeditis.com → backend │ + │ ├── app / www / apex → frontend│ + │ └── grafana.xpeditis.com │ + │ backend NestJS x2 (HPA 2→4) │ + │ frontend Next.js x2 │ + │ log-exporter │ + │ monitoring : Loki, Promtail, Prometheus, │ + │ Alertmanager, Grafana │ + └──────────────────┬──────────────────────────┘ + │ réseau privé 10.10.1.0/24 + │ TLS obligatoire (hostssl) + v + ┌─────────────────────────────────────────────┐ + │ db-01 CPX31 4 vCPU / 8 Go fsn1 │ + │ │ + │ PostgreSQL 15 + WAL-G (Docker Compose) │ + │ Redis 7 │ + │ postgres-exporter │ + │ volume dédié 50 Go │ + └──────────────────┬──────────────────────────┘ + │ + ┌───────────┴────────────┐ + v v + Hetzner Object Storage Hetzner Storage Box + (documents + WAL-G) (dumps chiffrés age) +``` + +**Pourquoi la base hors de Kubernetes** — PostgreSQL en StatefulSet ajoute des +volumes persistants, un ordre de démarrage et des montées de version délicates, +sans aucun gain à cette échelle. Hors cluster, les sauvegardes, la restauration +à un instant T et les tests de restauration sont triviaux, et le nœud +applicatif reste entièrement reconstructible sans toucher aux données. + +--- + +## Contenu du dossier + +| Chemin | Rôle | +|---|---| +| `terraform/` | Serveurs, réseau privé, firewalls, volume, clé SSH. La seule source de vérité de l'infrastructure. | +| `scripts/00-bootstrap-common.sh` | Durcissement système commun (SSH, UFW, fail2ban, auditd, sysctl, mises à jour auto). | +| `scripts/01-setup-data-node.sh` | Installe db-01 : volume, Docker, certificat TLS Postgres, timers de sauvegarde. | +| `scripts/02-setup-k3s-server.sh` | Installe k3s durci (secrets chiffrés au repos, audit API, Traefik configuré). | +| `scripts/03-install-cluster-addons.sh` | cert-manager, namespaces, accès registre, ClusterIssuer. | +| `scripts/secrets-apply.sh` | Déchiffre SOPS → applique sur le cluster, sans passer par le disque. | +| `scripts/harden-seed-data.sh` | Secours et audit : neutralise les comptes de démonstration (`admin@xpeditis.com` / `Password123!`) sur une base migrée avant l'ajout de la garde `NODE_ENV`. Le cas nominal est traité par les migrations. | +| `scripts/deploy.sh` | Déploiement complet : migrations → images → attente → tests → retour arrière. | +| `scripts/ssh-deploy-wrapper.sh` | Restreint la clé SSH de la CI à quatre commandes. Une clé volée ne donne pas un shell. | +| `scripts/deploy-monitoring.sh` | Pile d'observabilité. | +| `scripts/smoke-test.sh` | Vérifie ce qu'un utilisateur constate réellement, de l'extérieur. | +| `scripts/preflight-check.sh` | Contrôle go / no-go avant ouverture au public. | +| `scripts/refresh-cloudflare-ips.sh` | Met à jour les rangs IP Cloudflare (firewall + Traefik). | +| `data-node/` | Docker Compose, `postgresql.conf`, `pg_hba.conf`, `redis.conf`, sauvegardes WAL-G. | +| `k8s/base/` | Namespaces, quotas, ConfigMap, gabarit de secrets, déploiements, Ingress, middlewares, politiques réseau, certificats. | +| `k8s/cluster/` | ClusterIssuer Let's Encrypt (DNS-01 Cloudflare). | +| `k8s/monitoring/` | Loki, Promtail, Prometheus, Alertmanager, Grafana. | +| `env/` | Gabarits de variables d'environnement et liste des secrets GitHub. | +| `cloudflare/` | Enregistrements DNS et règles WAF à créer côté Cloudflare. | +| `Makefile` | Raccourcis d'exploitation (`make help`). | + +--- + +## Démarrage rapide + +```bash +cd infra/prod +make help + +# Infrastructure +cp terraform/terraform.tfvars.example terraform/terraform.tfvars +$EDITOR terraform/terraform.tfvars +make tf-init && make tf-plan && make tf-apply +make tf-output # IP + enregistrements DNS à créer + +# ... provisioning des serveurs : voir docs/mise-en-prod/03 et 04 ... + +# Secrets +cp k8s/base/03-secrets.template.yaml /tmp/secrets.yaml +$EDITOR /tmp/secrets.yaml +sops -e /tmp/secrets.yaml > k8s/base/03-secrets.sops.yaml && shred -u /tmp/secrets.yaml +make secrets-apply + +# Déploiement +make deploy TAG=prod-a1b2c3d +make deploy-monitoring + +# Avant d'ouvrir au public +make preflight +``` + +--- + +## Règles de sécurité non négociables + +1. **Aucun secret en clair dans Git.** Uniquement des fichiers `*.sops.yaml` + chiffrés avec age. `make secrets-check` refuse le contraire. +2. **La base de données n'est jamais joignable depuis Internet.** Réseau privé, + bind explicite sur l'IP privée, UFW, et `pg_hba` en `hostssl` seulement. +3. **Personne ne contourne Cloudflare.** Le firewall Hetzner n'accepte 80/443 + que depuis les rangs Cloudflare. +4. **SSH et l'API Kubernetes ne sont ouverts qu'à vos IP d'administration.** + Jamais `0.0.0.0/0` — Terraform refuse cette valeur. +5. **Une sauvegarde non restaurée n'est pas une sauvegarde.** Le test de + restauration tourne toutes les semaines et doit être passé avant l'ouverture. +6. **Les migrations passent par un Job**, jamais en concurrence entre replicas. +7. **L'image frontend est reconstruite pour la production**, jamais promue + depuis la preprod : `NEXT_PUBLIC_API_URL` est figée au build. +8. **Aucun mot de passe d'administrateur n'est stocké nulle part.** + `SeedTestUsers` ne s'exécute plus en production, une migration de secours + neutralise ses comptes s'ils existent, et le premier administrateur est créé + sans mot de passe utilisable — vous définissez le vôtre via « mot de passe + oublié ». + +--- + +## Coûts (référence : `Xpeditis_Previsions_Couts.xlsx`, feuille « Hetzner ») + +| Poste | € HT / mois | +|---|---| +| app-01 CPX41 | 29 | +| db-01 CPX31 | 15 | +| Volume 50 Go | 2,40 | +| Sauvegardes Hetzner (20 %) | 8,80 | +| Storage Box BX11 (1 To) | 3,90 | +| Object Storage (documents + WAL-G, < 1 To) | ~6 | +| IPv4 supplémentaire | 1 | +| Cloudflare Free | 0 | +| **Total infrastructure** | **≈ 66 €** | + +Les services tiers (Brevo, Stripe, Sentry, Pappers, assurance) s'ajoutent : +voir la feuille « Synthèse comparatif » du fichier de prévisions. diff --git a/infra/prod/cloudflare/README.md b/infra/prod/cloudflare/README.md new file mode 100644 index 0000000..f50de9f --- /dev/null +++ b/infra/prod/cloudflare/README.md @@ -0,0 +1,171 @@ +# Configuration Cloudflare — production + +Cloudflare est la première ligne de défense : DNS, anti-DDoS, WAF, cache. +Le firewall Hetzner n'accepte 80/443 **que** depuis les rangs Cloudflare, donc +si le proxy est désactivé sur un enregistrement, ce domaine devient +inaccessible. C'est voulu : personne ne doit pouvoir taper l'IP d'origine. + +Le plan Free suffit en phase 1 (≈ 100 utilisateurs). Le plan Pro (20 €/mois, +prévu au budget à partir de 1 000 utilisateurs) ajoute le WAF managé et +l'analyse de bots. + +--- + +## 1. Enregistrements DNS + +Toutes les valeurs `` viennent de `make tf-output`. + +| Type | Nom | Contenu | Proxy | TTL | +|---|---|---|---|---| +| A | `xpeditis.com` | `` | **Proxifié** | Auto | +| A | `www` | `` | **Proxifié** | Auto | +| A | `app` | `` | **Proxifié** | Auto | +| A | `api` | `` | **Proxifié** | Auto | +| A | `grafana` | `` | **Proxifié** | Auto | +| AAAA | idem si IPv6 activée | `` | **Proxifié** | Auto | + +**Aucun enregistrement ne pointe vers db-01.** Son IP publique ne sert qu'au +SSH d'administration et ne doit apparaître nulle part en DNS. + +### Messagerie (obligatoire pour que les e-mails arrivent) + +| Type | Nom | Contenu | +|---|---|---| +| TXT | `xpeditis.com` | `v=spf1 include:spf.brevo.com -all` | +| TXT | `mail._domainkey` | clé DKIM fournie par Brevo | +| TXT | `_dmarc` | `v=DMARC1; p=quarantine; rua=mailto:dmarc@xpeditis.com; pct=100` | + +Sans SPF/DKIM/DMARC, les confirmations de réservation et les liens magiques +transporteurs partent en spam — un défaut de production silencieux et coûteux. +Commencez à `p=quarantine`, passez à `p=reject` après deux semaines de rapports +propres. + +--- + +## 2. Réglages SSL/TLS + +| Réglage | Valeur | Pourquoi | +|---|---|---| +| Mode de chiffrement | **Full (strict)** | Cloudflare vérifie le certificat Let's Encrypt du serveur. En « Flexible », le trafic Cloudflare → origine serait en clair. | +| Always Use HTTPS | Activé | | +| Minimum TLS Version | **1.2** | TLS 1.0/1.1 sont cassés et refusés par la plupart des référentiels de conformité. | +| TLS 1.3 | Activé | | +| Automatic HTTPS Rewrites | Activé | | +| HSTS | **Activé après vérification** | Voir l'avertissement ci-dessous. | + +> **HSTS est irréversible pendant un an.** Ne l'activez qu'une fois certain que +> *tous* les sous-domaines servent bien du HTTPS valide. Traefik pose déjà +> l'en-tête (`k8s/base/08-traefik-middlewares.yaml`) ; l'activer aussi côté +> Cloudflare ajoute la protection avant même que la requête n'atteigne le +> serveur. + +--- + +## 3. Règles WAF + +### 3.1 Bloquer l'accès direct aux chemins d'administration + +``` +(http.host eq "api.xpeditis.com" and starts_with(http.request.uri.path, "/api/docs")) +``` +→ Action : **Block** + +La documentation Swagger est déjà désactivée en production par `main.ts`, mais +une règle de bordure protège même en cas d'erreur de configuration. + +### 3.2 Limiter les tentatives de connexion + +Rate limiting (Security → WAF → Rate limiting rules) : + +| Champ | Valeur | +|---|---| +| Expression | `starts_with(http.request.uri.path, "/api/v1/auth/login")` | +| Requêtes | 10 | +| Période | 1 minute | +| Par | Adresse IP | +| Action | Block, 10 minutes | + +Cette limite s'ajoute à celle de Traefik (`rate-limit-auth`) et à celle de +NestJS (`CustomThrottlerGuard`). Trois couches indépendantes : une erreur de +configuration sur l'une ne laisse pas la porte ouverte. + +### 3.3 Protéger le webhook Stripe + +``` +(http.host eq "api.xpeditis.com" and starts_with(http.request.uri.path, "/api/v1/subscriptions/webhook") and not ip.src in {IP_STRIPE}) +``` +→ Action : **Block** + +Les rangs d'IP Stripe sont publiés sur +`https://stripe.com/files/ips/ips_webhooks.txt`. La signature du webhook est +déjà vérifiée côté applicatif ; ceci évite simplement que du bruit atteigne +l'application. + +### 3.4 Filtrage géographique — à décider consciemment + +Xpeditis sert des transitaires européens. Bloquer tout sauf l'Europe réduit +massivement le bruit, mais coupe aussi les transitaires asiatiques légitimes +(les connecteurs transporteurs partent du serveur, ils ne sont pas concernés). + +**Recommandation** : ne pas bloquer par pays au lancement. Activer plutôt +« Bot Fight Mode » (gratuit) et surveiller les journaux un mois avant de +décider. + +--- + +## 4. Cache + +| Chemin | Règle | +|---|---| +| `api.xpeditis.com/*` | **Bypass cache** — une réponse d'API mise en cache servirait les données d'un utilisateur à un autre. | +| `app.xpeditis.com/_next/static/*` | Cache Everything, Edge TTL 1 an (contenu au nom versionné). | +| `app.xpeditis.com/*` | Standard (Next.js gère ses propres en-têtes). | + +> La règle de bypass sur l'API est **critique**. Un cache mal placé sur une +> route authentifiée est une fuite de données, pas un problème de performance. + +--- + +## 5. Jeton API pour cert-manager + +`My Profile → API Tokens → Create Token → Edit zone DNS` + +| Permission | Portée | +|---|---| +| Zone / DNS / Edit | Zone **xpeditis.com** uniquement | +| Zone / Zone / Read | Zone **xpeditis.com** uniquement | + +N'utilisez **jamais** la clé globale du compte : elle permettrait à +cert-manager — ou à quiconque lirait le Secret — de rediriger tout votre trafic. + +Le jeton alimente le Secret `cert-manager/cloudflare-api-token` +(`k8s/base/03-secrets.template.yaml`). + +--- + +## 6. Accès à Grafana + +Deux options, au choix : + +- **Liste d'IP** (par défaut) — middleware Traefik `admin-ip-allowlist`, + à renseigner dans `k8s/base/08-traefik-middlewares.yaml`. +- **Cloudflare Access** (Zero Trust, gratuit jusqu'à 50 utilisateurs) — + authentification par e-mail à usage unique devant `grafana.xpeditis.com`. + Préférable si votre IP est dynamique. + +--- + +## 7. Vérification + +```bash +# Le proxy est-il bien actif ? (l'IP retournée doit être une IP Cloudflare) +dig +short app.xpeditis.com + +# L'origine est-elle injoignable en direct ? +curl -sS --max-time 5 --connect-to app.xpeditis.com:443::443 \ + https://app.xpeditis.com/ # doit échouer par timeout + +# SPF / DKIM / DMARC +dig +short TXT xpeditis.com +dig +short TXT _dmarc.xpeditis.com +``` diff --git a/infra/prod/data-node/Dockerfile.postgres b/infra/prod/data-node/Dockerfile.postgres new file mode 100644 index 0000000..b7bf75d --- /dev/null +++ b/infra/prod/data-node/Dockerfile.postgres @@ -0,0 +1,29 @@ +# ============================================================================= +# PostgreSQL 15 + WAL-G +# ============================================================================= +# Image postgres officielle enrichie de WAL-G, qui assure : +# - l'archivage continu des WAL vers Hetzner Object Storage (archive_command) +# - les sauvegardes physiques completes (basebackup) +# - la restauration a un instant T (PITR) +# +# On epingle une version precise de WAL-G : une mise a jour silencieuse de +# l'outil de sauvegarde est exactement ce qu'on ne veut pas decouvrir un jour +# de restauration. + +FROM postgres:15-bookworm + +ARG WALG_VERSION=v3.0.5 +ARG TARGETARCH=amd64 + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates curl; \ + curl -fsSL -o /tmp/wal-g.tar.gz \ + "https://github.com/wal-g/wal-g/releases/download/${WALG_VERSION}/wal-g-pg-ubuntu-22.04-${TARGETARCH}.tar.gz"; \ + tar -xzf /tmp/wal-g.tar.gz -C /tmp; \ + mv "/tmp/wal-g-pg-ubuntu-22.04-${TARGETARCH}" /usr/local/bin/wal-g; \ + chmod 0755 /usr/local/bin/wal-g; \ + rm -rf /tmp/wal-g.tar.gz /var/lib/apt/lists/*; \ + wal-g --version + +# Les scripts de sauvegarde / restauration sont montes depuis backup/. diff --git a/infra/prod/data-node/backup/pg-backup.sh b/infra/prod/data-node/backup/pg-backup.sh new file mode 100755 index 0000000..f70064d --- /dev/null +++ b/infra/prod/data-node/backup/pg-backup.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# ============================================================================= +# Sauvegarde PostgreSQL - production Xpeditis +# ============================================================================= +# Deux mecanismes complementaires, volontairement redondants : +# +# 1. WAL-G basebackup + archivage WAL continu -> Hetzner Object Storage +# Objectif : restauration a un instant T (PITR). RPO <= 5 min. +# C'est la sauvegarde qui compte en cas de corruption ou d'erreur humaine +# ("j'ai supprime les bookings de mars"). +# +# 2. pg_dump logique chiffre -> Hetzner Storage Box (autre service, autre +# credential, autre protocole). +# Objectif : survivre a une compromission du compte Object Storage, a un +# bug WAL-G, ou a une montee de version majeure de PostgreSQL. +# +# Regle des 3-2-1 : 3 copies (volume + Object Storage + Storage Box), 2 supports +# distincts, 1 hors du perimetre de la VM. Cf. 12-sauvegardes-restauration.md. +# +# Execution : timer systemd `xpeditis-backup.timer`, tous les jours a 02h30. +set -euo pipefail + +COMPOSE_DIR="${COMPOSE_DIR:-/opt/xpeditis/data-node}" +ENV_FILE="${ENV_FILE:-${COMPOSE_DIR}/.env.data}" +DUMP_DIR="${DUMP_DIR:-/var/lib/xpeditis/dumps}" +LOG_TAG="xpeditis-backup" + +# shellcheck disable=SC1090 +set -a; source "$ENV_FILE"; set +a + +log() { logger -t "$LOG_TAG" -- "$*"; printf '[%s] %s\n' "$(date -Is)" "$*"; } +fail() { log "ECHEC: $*"; notify "ECHEC sauvegarde PostgreSQL" "$*"; exit 1; } + +notify() { + # Alerte Discord (meme webhook que la CI/CD). Une sauvegarde qui echoue en + # silence est pire que pas de sauvegarde du tout. + local title="$1" body="$2" + [[ -n "${DISCORD_WEBHOOK_URL:-}" ]] || return 0 + curl -sf -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg t "$title" --arg d "$body" \ + '{embeds:[{title:$t,description:$d,color:15158332}]}')" \ + "$DISCORD_WEBHOOK_URL" >/dev/null || true +} + +dc() { docker compose -f "${COMPOSE_DIR}/docker-compose.data.yml" --env-file "$ENV_FILE" "$@"; } + +# --- 0. Preconditions -------------------------------------------------------- +command -v age >/dev/null || fail "age n'est pas installe (chiffrement des dumps)" +[[ -n "${BACKUP_AGE_RECIPIENT:-}" ]] || fail "BACKUP_AGE_RECIPIENT absent de .env.data" +mkdir -p "$DUMP_DIR" + +dc ps --status running --services | grep -qx postgres \ + || fail "le conteneur postgres n'est pas demarre" + +# --- 1. Sauvegarde physique WAL-G (base de la PITR) -------------------------- +log "WAL-G basebackup : demarrage" +if ! dc exec -T -u postgres postgres wal-g backup-push /var/lib/postgresql/data/pgdata; then + fail "wal-g backup-push a echoue" +fi +log "WAL-G basebackup : termine" + +# --- 2. Retention WAL-G ------------------------------------------------------ +# 7 sauvegardes completes conservees (soit ~1 semaine de PITR a raison d'une +# par jour), les WAL anterieurs sont purges avec elles. +log "WAL-G : purge des sauvegardes au-dela de 7 retentions" +dc exec -T -u postgres postgres wal-g delete retain FULL 7 --confirm \ + || log "AVERTISSEMENT: la purge WAL-G a echoue (non bloquant)" + +# --- 3. Dump logique chiffre ------------------------------------------------- +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +DUMP_NAME="xpeditis-${STAMP}.dump" +DUMP_PATH="${DUMP_DIR}/${DUMP_NAME}" + +log "pg_dump logique : demarrage" +# -Fc = format custom : compresse, restaurable table par table avec pg_restore. +if ! dc exec -T -u postgres postgres \ + pg_dump -Fc -Z6 --no-owner --no-privileges \ + -d "$POSTGRES_DB" > "$DUMP_PATH"; then + rm -f "$DUMP_PATH" + fail "pg_dump a echoue" +fi + +DUMP_SIZE=$(stat -c%s "$DUMP_PATH") +# Un dump anormalement petit signale une base vide ou une erreur silencieuse. +if (( DUMP_SIZE < 51200 )); then + rm -f "$DUMP_PATH" + fail "dump suspect : ${DUMP_SIZE} octets (< 50 Ko)" +fi +log "pg_dump : ${DUMP_SIZE} octets" + +log "Chiffrement age" +age -r "$BACKUP_AGE_RECIPIENT" -o "${DUMP_PATH}.age" "$DUMP_PATH" \ + || fail "chiffrement age echoue" +# Le dump en clair ne survit pas a la minute : seule la version chiffree part +# sur le reseau et reste sur disque. +shred -u "$DUMP_PATH" 2>/dev/null || rm -f "$DUMP_PATH" +sha256sum "${DUMP_PATH}.age" > "${DUMP_PATH}.age.sha256" + +# --- 4. Copie vers la Storage Box ------------------------------------------- +if [[ -n "${STORAGEBOX_HOST:-}" ]]; then + log "Envoi vers la Storage Box ${STORAGEBOX_HOST}" + rsync -a --protect-args \ + -e "ssh -p ${STORAGEBOX_PORT:-23} -i ${STORAGEBOX_SSH_KEY:-/root/.ssh/storagebox} -o StrictHostKeyChecking=yes" \ + "${DUMP_PATH}.age" "${DUMP_PATH}.age.sha256" \ + "${STORAGEBOX_USER}@${STORAGEBOX_HOST}:./xpeditis-prod/dumps/" \ + || fail "rsync vers la Storage Box a echoue" +else + log "AVERTISSEMENT: STORAGEBOX_HOST non configure, pas de seconde copie hors VM" +fi + +# --- 5. Retention locale ----------------------------------------------------- +# 7 jours en local : de quoi restaurer vite sans re-telecharger. La retention +# longue (30 jours + 12 mensuels) vit sur la Storage Box. +find "$DUMP_DIR" -name 'xpeditis-*.dump.age*' -mtime +7 -delete +log "Retention locale appliquee (7 jours)" + +# --- 6. Compte rendu --------------------------------------------------------- +LATEST=$(dc exec -T -u postgres postgres wal-g backup-list --detail 2>/dev/null | tail -n 1 || echo "?") +log "Sauvegarde terminee. Derniere sauvegarde WAL-G: ${LATEST}" + +# Battement de coeur externe : c'est le SILENCE qui declenche l'alerte. Une +# supervision hebergee sur cette meme machine ne dirait rien si la machine +# etait eteinte -- exactement le cas ou l'alerte compte. +if [[ -n "${BACKUP_HEARTBEAT_URL:-}" ]]; then + curl -fsS --max-time 10 --retry 3 "$BACKUP_HEARTBEAT_URL" >/dev/null \ + || log "AVERTISSEMENT: le battement de coeur n'a pas pu etre envoye" +fi + +if [[ -n "${DISCORD_WEBHOOK_URL:-}" ]]; then + curl -sf -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg d "Dump: ${DUMP_NAME}.age ($(numfmt --to=iec "$DUMP_SIZE"))" \ + '{embeds:[{title:"Sauvegarde PostgreSQL OK",description:$d,color:3066993}]}')" \ + "$DISCORD_WEBHOOK_URL" >/dev/null || true +fi diff --git a/infra/prod/data-node/backup/pg-restore.sh b/infra/prod/data-node/backup/pg-restore.sh new file mode 100755 index 0000000..0f41289 --- /dev/null +++ b/infra/prod/data-node/backup/pg-restore.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# ============================================================================= +# Restauration PostgreSQL - production Xpeditis +# ============================================================================= +# Trois modes, du moins au plus destructeur : +# +# verify Restaure dans une base jetable et controle +# la coherence. AUCUN impact sur la prod. +# C'est ce que lance le timer hebdomadaire. +# +# logical Restaure un dump logique dans une base +# nommee (par defaut une base de secours). +# +# pitr Restauration a un instant T via WAL-G. +# DETRUIT le repertoire de donnees courant. +# Reserve aux incidents majeurs. +# +# Lire docs/mise-en-prod/12-sauvegardes-restauration.md AVANT d'utiliser `pitr`. +set -euo pipefail + +COMPOSE_DIR="${COMPOSE_DIR:-/opt/xpeditis/data-node}" +ENV_FILE="${ENV_FILE:-${COMPOSE_DIR}/.env.data}" +PGDATA_HOST="${PGDATA_HOST:-/var/lib/xpeditis/pgdata}" + +# shellcheck disable=SC1090 +set -a; source "$ENV_FILE"; set +a + +log() { printf '\n[%s] %s\n' "$(date -Is)" "$*"; } +fail() { printf '\nECHEC: %s\n' "$*" >&2; exit 1; } +dc() { docker compose -f "${COMPOSE_DIR}/docker-compose.data.yml" --env-file "$ENV_FILE" "$@"; } + +confirm() { + local prompt="$1" expected="$2" answer + printf '\n%s\nTapez exactement "%s" pour confirmer : ' "$prompt" "$expected" + read -r answer + [[ "$answer" == "$expected" ]] || fail "confirmation refusee" +} + +decrypt_dump() { + local encrypted="$1" out="$2" + [[ -f "$encrypted" ]] || fail "fichier introuvable : $encrypted" + [[ -f "${BACKUP_AGE_KEY_FILE:-/root/.config/xpeditis/backup-age.key}" ]] \ + || fail "cle de dechiffrement absente (BACKUP_AGE_KEY_FILE)" + age -d -i "${BACKUP_AGE_KEY_FILE:-/root/.config/xpeditis/backup-age.key}" \ + -o "$out" "$encrypted" || fail "dechiffrement age echoue" +} + +MODE="${1:-}" + +case "$MODE" in + +# --------------------------------------------------------------------------- +verify) + ENCRYPTED="${2:-$(ls -1t /var/lib/xpeditis/dumps/xpeditis-*.dump.age 2>/dev/null | head -1)}" + [[ -n "$ENCRYPTED" ]] || fail "aucun dump trouve" + SCRATCH_DB="xpeditis_restore_check" + TMP_DUMP="/tmp/xpeditis-verify-$$.dump" + + log "Verification de : $ENCRYPTED" + decrypt_dump "$ENCRYPTED" "$TMP_DUMP" + trap 'rm -f "$TMP_DUMP"' EXIT + + log "Creation de la base jetable ${SCRATCH_DB}" + dc exec -T -u postgres postgres psql -q -c "DROP DATABASE IF EXISTS ${SCRATCH_DB};" + dc exec -T -u postgres postgres psql -q -c "CREATE DATABASE ${SCRATCH_DB};" + + log "pg_restore dans la base jetable" + dc exec -T -u postgres postgres pg_restore \ + --no-owner --no-privileges --exit-on-error -d "$SCRATCH_DB" < "$TMP_DUMP" \ + || fail "pg_restore a echoue : LA SAUVEGARDE EST INEXPLOITABLE" + + log "Controles de coherence" + # Une sauvegarde qui se restaure mais ne contient rien ne vaut rien : on + # verifie que les tables metier critiques sont peuplees. + dc exec -T -u postgres postgres psql -d "$SCRATCH_DB" -v ON_ERROR_STOP=1 <<'SQL' +\pset pager off +SELECT 'tables' AS objet, count(*) AS n FROM information_schema.tables WHERE table_schema='public'; +SELECT 'users' AS objet, count(*) AS n FROM users; +SELECT 'organizations' AS objet, count(*) AS n FROM organizations; +SELECT 'csv_bookings' AS objet, count(*) AS n FROM csv_bookings; +SELECT 'migrations' AS objet, count(*) AS n FROM migrations; +DO $$ +DECLARE t int; +BEGIN + SELECT count(*) INTO t FROM information_schema.tables WHERE table_schema='public'; + IF t < 10 THEN + RAISE EXCEPTION 'Sauvegarde suspecte : seulement % tables restaurees', t; + END IF; +END $$; +SQL + + log "Nettoyage" + dc exec -T -u postgres postgres psql -q -c "DROP DATABASE ${SCRATCH_DB};" + log "SAUVEGARDE VALIDE : $ENCRYPTED" + ;; + +# --------------------------------------------------------------------------- +logical) + ENCRYPTED="${2:?usage: $0 logical [base_cible]}" + TARGET_DB="${3:-${POSTGRES_DB}_restored}" + TMP_DUMP="/tmp/xpeditis-restore-$$.dump" + + confirm "Restauration logique de $ENCRYPTED dans la base '${TARGET_DB}'. La base cible sera SUPPRIMEE si elle existe." "RESTAURER ${TARGET_DB}" + + decrypt_dump "$ENCRYPTED" "$TMP_DUMP" + trap 'rm -f "$TMP_DUMP"' EXIT + + dc exec -T -u postgres postgres psql -q -c "DROP DATABASE IF EXISTS ${TARGET_DB};" + dc exec -T -u postgres postgres psql -q -c "CREATE DATABASE ${TARGET_DB};" + dc exec -T -u postgres postgres pg_restore \ + --no-owner --no-privileges --exit-on-error -j 2 -d "$TARGET_DB" < "$TMP_DUMP" \ + || fail "pg_restore a echoue" + + log "Restauration terminee dans '${TARGET_DB}'." + log "La base de production n'a PAS ete touchee. Pour basculer :" + log " 1. arreter le backend : kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0" + log " 2. renommer les bases : ALTER DATABASE ${POSTGRES_DB} RENAME TO ${POSTGRES_DB}_old;" + log " ALTER DATABASE ${TARGET_DB} RENAME TO ${POSTGRES_DB};" + log " 3. relancer le backend : kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2" + ;; + +# --------------------------------------------------------------------------- +pitr) + TARGET_TIME="${2:?usage: $0 pitr \"YYYY-MM-DD HH:MM:SS\"}" + + cat <> \$D/postgresql.auto.conf < [base] Restauration logique dans une base a part + $0 pitr "YYYY-MM-DD HH:MM:SS" Restauration a un instant T (DESTRUCTIF) + +Lister les sauvegardes disponibles : + ls -lht /var/lib/xpeditis/dumps/ + docker compose -f ${COMPOSE_DIR}/docker-compose.data.yml exec -u postgres postgres wal-g backup-list --detail +USAGE + exit 1 + ;; +esac diff --git a/infra/prod/data-node/backup/xpeditis-backup-verify.service b/infra/prod/data-node/backup/xpeditis-backup-verify.service new file mode 100644 index 0000000..0dde8af --- /dev/null +++ b/infra/prod/data-node/backup/xpeditis-backup-verify.service @@ -0,0 +1,26 @@ +[Unit] +Description=Verification de restaurabilite des sauvegardes Xpeditis +Documentation=file:///opt/xpeditis/data-node/backup/pg-restore.sh +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +User=root +Environment=COMPOSE_DIR=/opt/xpeditis/data-node +Environment=ENV_FILE=/opt/xpeditis/data-node/.env.data +# `verify` restaure le dernier dump dans une base jetable puis la supprime. +# Une sauvegarde jamais restauree n'est pas une sauvegarde. +ExecStart=/opt/xpeditis/data-node/backup/pg-restore.sh verify +TimeoutStartSec=2h + +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=xpeditis-backup-verify + +[Install] +WantedBy=multi-user.target diff --git a/infra/prod/data-node/backup/xpeditis-backup-verify.timer b/infra/prod/data-node/backup/xpeditis-backup-verify.timer new file mode 100644 index 0000000..9920cf5 --- /dev/null +++ b/infra/prod/data-node/backup/xpeditis-backup-verify.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Verification hebdomadaire des sauvegardes Xpeditis + +[Timer] +# Dimanche 04h00, apres la sauvegarde de la nuit. +OnCalendar=Sun *-*-* 04:00:00 +RandomizedDelaySec=600 +Persistent=true +Unit=xpeditis-backup-verify.service + +[Install] +WantedBy=timers.target diff --git a/infra/prod/data-node/backup/xpeditis-backup.service b/infra/prod/data-node/backup/xpeditis-backup.service new file mode 100644 index 0000000..8514e77 --- /dev/null +++ b/infra/prod/data-node/backup/xpeditis-backup.service @@ -0,0 +1,29 @@ +[Unit] +Description=Sauvegarde PostgreSQL Xpeditis (WAL-G + dump logique chiffre) +Documentation=file:///opt/xpeditis/data-node/backup/pg-backup.sh +After=docker.service network-online.target +Requires=docker.service + +[Service] +Type=oneshot +User=root +Environment=COMPOSE_DIR=/opt/xpeditis/data-node +Environment=ENV_FILE=/opt/xpeditis/data-node/.env.data +ExecStart=/opt/xpeditis/data-node/backup/pg-backup.sh +TimeoutStartSec=3h + +# Le script ecrit dans /var/lib/xpeditis/dumps et pilote Docker : on ne peut pas +# aller beaucoup plus loin dans le confinement sans le casser. +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectKernelTunables=true +ProtectControlGroups=true +RestrictSUIDSGID=true + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=xpeditis-backup + +[Install] +WantedBy=multi-user.target diff --git a/infra/prod/data-node/backup/xpeditis-backup.timer b/infra/prod/data-node/backup/xpeditis-backup.timer new file mode 100644 index 0000000..8541964 --- /dev/null +++ b/infra/prod/data-node/backup/xpeditis-backup.timer @@ -0,0 +1,16 @@ +[Unit] +Description=Sauvegarde PostgreSQL Xpeditis - quotidienne +Documentation=file:///opt/xpeditis/data-node/backup/pg-backup.sh + +[Timer] +# 02h30 heure de Paris : creux d'activite des transitaires europeens, et avant +# la fenetre de redemarrage automatique des mises a jour de securite (04h30). +OnCalendar=*-*-* 02:30:00 +# Etale le declenchement pour ne pas taper l'Object Storage a la seconde pres. +RandomizedDelaySec=300 +# Rattrape la sauvegarde si le serveur etait eteint a l'heure prevue. +Persistent=true +Unit=xpeditis-backup.service + +[Install] +WantedBy=timers.target diff --git a/infra/prod/data-node/conf/pg_hba.conf b/infra/prod/data-node/conf/pg_hba.conf new file mode 100644 index 0000000..7b509a9 --- /dev/null +++ b/infra/prod/data-node/conf/pg_hba.conf @@ -0,0 +1,30 @@ +# ============================================================================= +# pg_hba.conf - production Xpeditis +# ============================================================================= +# Principe : `hostssl` uniquement. Aucune connexion reseau en clair n'est +# possible, meme depuis le reseau prive Hetzner. +# +# Consequence a connaitre : toute application se connectant a cette base DOIT +# negocier TLS. Cote Xpeditis cela signifie DATABASE_SSL=true, honore par +# data-source.ts, par le pool applicatif et par scripts/setup/startup.js. +# +# TYPE DATABASE USER ADDRESS METHOD + +# --- Socket local (administration dans le conteneur, wal-g, pg_dump) --------- +local all postgres peer +local all all scram-sha-256 + +# --- Noeud applicatif (k3s) -------------------------------------------------- +hostssl all all 10.10.1.10/32 scram-sha-256 + +# --- Reseau interne du compose (postgres-exporter) --------------------------- +hostssl all all 172.28.0.0/24 scram-sha-256 + +# --- Replication (standby futur, phase 2 du plan de charge) ------------------ +hostssl replication all 10.10.1.0/24 scram-sha-256 + +# --- Tout le reste est refuse explicitement --------------------------------- +# `reject` plutot qu'une absence de regle : le refus est immediat et journalise, +# ce qui rend les tentatives visibles dans Loki. +host all all 0.0.0.0/0 reject +host all all ::/0 reject diff --git a/infra/prod/data-node/conf/postgresql.conf b/infra/prod/data-node/conf/postgresql.conf new file mode 100644 index 0000000..5732927 --- /dev/null +++ b/infra/prod/data-node/conf/postgresql.conf @@ -0,0 +1,108 @@ +# ============================================================================= +# PostgreSQL 15 - production Xpeditis +# Cible : Hetzner CPX31 (4 vCPU / 8 Go), partage avec Redis (1,5 Go plafonne). +# Budget memoire retenu pour PostgreSQL : ~6 Go. +# ============================================================================= + +# --- Connexions -------------------------------------------------------------- +listen_addresses = '*' # le confinement reseau est assure par le bind + # Docker sur l'IP privee + UFW + pg_hba +port = 5432 +max_connections = 150 # 2 replicas backend x pool TypeORM + marge ops +superuser_reserved_connections = 5 + +# --- TLS --------------------------------------------------------------------- +# Obligatoire : pg_hba n'accepte que des lignes `hostssl`. Le reseau prive +# Hetzner n'est pas chiffre par l'hyperviseur, c'est nous qui le chiffrons. +ssl = on +ssl_cert_file = '/var/lib/xpeditis/certs/server.crt' +ssl_key_file = '/var/lib/xpeditis/certs/server.key' +ssl_min_protocol_version = 'TLSv1.2' +ssl_prefer_server_ciphers = on + +# --- Authentification -------------------------------------------------------- +password_encryption = scram-sha-256 + +# --- Memoire ----------------------------------------------------------------- +shared_buffers = 2GB +effective_cache_size = 5GB +maintenance_work_mem = 512MB +work_mem = 16MB # par operation de tri/hash, pas par connexion +huge_pages = try + +# --- Disque (SSD NVMe Hetzner) ---------------------------------------------- +random_page_cost = 1.1 +effective_io_concurrency = 200 +max_worker_processes = 4 +max_parallel_workers = 4 +max_parallel_workers_per_gather = 2 +max_parallel_maintenance_workers = 2 + +# --- WAL / archivage --------------------------------------------------------- +wal_level = replica # suffisant pour PITR + futur standby physique +wal_compression = on +max_wal_size = 4GB +min_wal_size = 1GB +checkpoint_completion_target = 0.9 +checkpoint_timeout = 15min + +archive_mode = on +archive_command = 'wal-g wal-push %p' +# Force la cloture d'un segment WAL toutes les 5 min meme sans activite : +# c'est ce qui plafonne le RPO a 5 minutes de donnees perdues au maximum. +archive_timeout = 300 + +# Conserve de quoi rattacher un standby sans repartir d'un basebackup. +max_wal_senders = 5 +wal_keep_size = 1GB +max_replication_slots = 5 + +# --- Autovacuum -------------------------------------------------------------- +# audit_logs et csv_rates subissent beaucoup d'ecritures : on vacuume plus tot +# que les valeurs par defaut, sinon les index gonflent et les recherches +# tarifaires ralentissent. +autovacuum = on +autovacuum_max_workers = 3 +autovacuum_naptime = 30s +autovacuum_vacuum_scale_factor = 0.05 +autovacuum_analyze_scale_factor = 0.02 +autovacuum_vacuum_cost_limit = 1000 + +# --- Delais de garde --------------------------------------------------------- +statement_timeout = 300000 # 5 min : borne les requetes folles + # tout en laissant passer les + # imports CSV de grilles +idle_in_transaction_session_timeout = 300000 # 5 min : libere les verrous oublies +lock_timeout = 30000 +tcp_keepalives_idle = 60 +tcp_keepalives_interval = 10 +tcp_keepalives_count = 6 + +# --- Observabilite ----------------------------------------------------------- +shared_preload_libraries = 'pg_stat_statements' +pg_stat_statements.max = 5000 +pg_stat_statements.track = top +track_io_timing = on +track_functions = pl + +logging_collector = off # les logs partent sur stdout -> Docker -> Loki +log_destination = 'stderr' +log_min_duration_statement = 500 # trace toute requete > 500 ms +log_line_prefix = '%m [%p] %q%u@%d %a ' +log_checkpoints = on +log_connections = on +log_disconnections = on +log_lock_waits = on +log_temp_files = 0 +log_autovacuum_min_duration = 1000 +log_statement = 'ddl' # trace les changements de schema (migrations) +log_timezone = 'Europe/Paris' + +# --- Localisation ------------------------------------------------------------ +timezone = 'Europe/Paris' +datestyle = 'iso, mdy' +lc_messages = 'C' +lc_monetary = 'C' +lc_numeric = 'C' +lc_time = 'C' +default_text_search_config = 'pg_catalog.french' diff --git a/infra/prod/data-node/conf/redis.conf b/infra/prod/data-node/conf/redis.conf new file mode 100644 index 0000000..2cc8858 --- /dev/null +++ b/infra/prod/data-node/conf/redis.conf @@ -0,0 +1,85 @@ +# ============================================================================= +# Redis 7 - production Xpeditis +# ============================================================================= +# Le mot de passe n'est PAS ici : il est injecte par --requirepass depuis +# .env.data (cf. docker-compose.data.yml). Ce fichier est versionne, pas lui. + +# --- Reseau ------------------------------------------------------------------ +# Ecoute sur toutes les interfaces DU CONTENEUR ; l'exposition reelle est +# limitee par Docker (bind sur l'IP privee) puis par UFW. +bind 0.0.0.0 +protected-mode yes +port 6379 +tcp-backlog 511 +tcp-keepalive 300 +timeout 300 + +# --- Persistance ------------------------------------------------------------- +# Redis ne sert pas qu'a cacher les cotations : il porte aussi des donnees de +# session et de revocation de jetons. Un redemarrage ne doit pas les perdre, +# sinon des jetons revoques redeviendraient valides. +dir /data +appendonly yes +appendfilename "appendonly.aof" +appendfsync everysec +no-appendfsync-on-rewrite no +auto-aof-rewrite-percentage 100 +auto-aof-rewrite-min-size 64mb +aof-use-rdb-preamble yes + +save 900 1 +save 300 10 +save 60 10000 +dbfilename dump.rdb +rdbcompression yes +rdbchecksum yes +stop-writes-on-bgsave-error yes + +# --- Memoire ----------------------------------------------------------------- +# Estimation Excel : 0,5 Go a 100 utilisateurs. On plafonne a 1 Go, soit 2x de +# marge, sous la limite conteneur de 1,5 Go. +# +# volatile-lru et non allkeys-lru : seules les cles portant un TTL peuvent etre +# evincees. Les cotations tarifaires (TTL 15 min) sont donc sacrifiables, ce qui +# est le comportement voulu. Surveiller quand meme used_memory : une eviction +# subie sur une cle de revocation prolongerait la validite d'un jeton revoque. +# Alerte Grafana a 75 % (cf. k8s/monitoring/). +maxmemory 1gb +maxmemory-policy volatile-lru +maxmemory-samples 5 + +lazyfree-lazy-eviction yes +lazyfree-lazy-expire yes +lazyfree-lazy-server-del yes + +# --- Durcissement ------------------------------------------------------------ +# Les commandes destructrices ou de reconnaissance sont RENOMMEES, pas +# supprimees : une injection cote application ne peut plus les atteindre, mais +# un operateur qui connait le nom garde une porte de sortie en incident. +# +# Consequence connue : RedisCacheAdapter.clear() (redis-cache.adapter.ts:133) +# appelle FLUSHDB et echouera donc en production. C'est volontaire -- cette +# methode n'a aucun appelant dans le code applicatif et vider le cache de prod +# depuis une requete HTTP ne doit jamais etre possible. Pour vider le cache +# manuellement, utiliser le nom renomme ci-dessous depuis db-01. +# +# Ces noms sont publics dans Git : ils protegent d'une injection, pas d'un +# attaquant ayant deja le mot de passe Redis ET l'acces reseau. Si vous voulez +# une vraie defense, remplacez-les par des chaines aleatoires et notez-les dans +# le coffre-fort. +rename-command FLUSHALL XPD_FLUSHALL_9c1f +rename-command FLUSHDB XPD_FLUSHDB_9c1f +rename-command KEYS XPD_KEYS_9c1f +rename-command CONFIG XPD_CONFIG_9c1f +rename-command DEBUG "" +rename-command MODULE "" + +# --- Journalisation ---------------------------------------------------------- +logfile "" +loglevel notice + +# --- Divers ------------------------------------------------------------------ +databases 16 +slowlog-log-slower-than 10000 +slowlog-max-len 128 +latency-monitor-threshold 100 diff --git a/infra/prod/data-node/docker-compose.data.yml b/infra/prod/data-node/docker-compose.data.yml new file mode 100644 index 0000000..4cba49b --- /dev/null +++ b/infra/prod/data-node/docker-compose.data.yml @@ -0,0 +1,187 @@ +# ============================================================================= +# Noeud de donnees Xpeditis - production +# ============================================================================= +# Deploye sur db-01 uniquement, dans /opt/xpeditis/data-node. +# +# docker compose -f docker-compose.data.yml --env-file .env.data up -d +# +# Regles non negociables appliquees ici : +# - Aucun port publie sur 0.0.0.0 : bind explicite sur l'IP privee. +# - Aucun mot de passe en dur : tout vient de .env.data (chiffre SOPS en Git). +# - PostgreSQL exige TLS (hostssl) pour toute connexion venant du reseau. +# - Les conteneurs ne peuvent pas escalader leurs privileges. + +name: xpeditis-data + +services: + postgres: + build: + context: . + dockerfile: Dockerfile.postgres + image: xpeditis/postgres-walg:15 + container_name: xpeditis-postgres + restart: unless-stopped + stop_grace_period: 60s + + # Bind sur l'IP privee : injoignable depuis Internet, meme si UFW tombe. + ports: + - "${PRIVATE_IP}:5432:5432" + + environment: + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + PGDATA: /var/lib/postgresql/data/pgdata + # scram-sha-256 pour tous les mots de passe (jamais md5). + POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256 --data-checksums" + TZ: Europe/Paris + + # --- WAL-G : archivage continu vers Hetzner Object Storage -------------- + WALG_S3_PREFIX: "${WALG_S3_PREFIX}" + AWS_ACCESS_KEY_ID: "${WALG_ACCESS_KEY_ID}" + AWS_SECRET_ACCESS_KEY: "${WALG_SECRET_ACCESS_KEY}" + AWS_ENDPOINT: "${WALG_S3_ENDPOINT}" + AWS_REGION: "${WALG_S3_REGION}" + AWS_S3_FORCE_PATH_STYLE: "true" + # Chiffrement cote client : meme si le bucket fuite, les sauvegardes + # restent illisibles sans la cle privee libsodium. + WALG_LIBSODIUM_KEY: "${WALG_LIBSODIUM_KEY}" + WALG_COMPRESSION_METHOD: brotli + WALG_DELTA_MAX_STEPS: "6" + WALG_UPLOAD_CONCURRENCY: "2" + PGHOST: /var/run/postgresql + + volumes: + - /var/lib/xpeditis/pgdata:/var/lib/postgresql/data + - ./conf/postgresql.conf:/etc/postgresql/postgresql.conf:ro + - ./conf/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro + - /var/lib/xpeditis/certs:/var/lib/xpeditis/certs:ro + - ./backup:/opt/backup:ro + - /var/lib/xpeditis/dumps:/var/lib/xpeditis/dumps + + command: + - postgres + - -c + - config_file=/etc/postgresql/postgresql.conf + - -c + - hba_file=/etc/postgresql/pg_hba.conf + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -h /var/run/postgresql"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 30s + + networks: + - internal + + security_opt: + - no-new-privileges:true + + shm_size: 512mb + + deploy: + resources: + limits: + cpus: "3.0" + memory: 6g + + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + + redis: + image: redis:7.4-alpine + container_name: xpeditis-redis + restart: unless-stopped + stop_grace_period: 30s + + ports: + - "${PRIVATE_IP}:6379:6379" + + # requirepass passe en ligne de commande : redis.conf ne sait pas lire + # de variable d'environnement, et on refuse d'ecrire le mot de passe + # dans un fichier versionne. + command: + - redis-server + - /usr/local/etc/redis/redis.conf + - --requirepass + - "${REDIS_PASSWORD}" + + volumes: + - ./conf/redis.conf:/usr/local/etc/redis/redis.conf:ro + - /var/lib/xpeditis/redis:/data + + healthcheck: + test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" --no-auth-warning ping | grep -q PONG"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s + environment: + REDIS_PASSWORD: "${REDIS_PASSWORD}" + TZ: Europe/Paris + + networks: + - internal + + security_opt: + - no-new-privileges:true + + sysctls: + # Evite les pertes de connexion sous charge sur le backlog TCP. + net.core.somaxconn: 1024 + + deploy: + resources: + limits: + cpus: "1.0" + memory: 1500m + + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + + # Exportateur Prometheus PostgreSQL : alimente les tableaux de bord Grafana + # heberges sur app-01 (scrape via le reseau prive). + postgres-exporter: + image: prometheuscommunity/postgres-exporter:v0.15.0 + container_name: xpeditis-postgres-exporter + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + ports: + - "${PRIVATE_IP}:9187:9187" + environment: + # Connexion par le reseau interne du compose (jamais par l'IP publiee), + # en TLS obligatoire comme toute autre connexion reseau. + DATA_SOURCE_NAME: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=require" + networks: + - internal + security_opt: + - no-new-privileges:true + deploy: + resources: + limits: + cpus: "0.25" + memory: 128m + logging: + driver: json-file + options: + max-size: "10m" + max-file: "2" + +# Sous-reseau fixe : il est reference explicitement dans pg_hba.conf, il ne doit +# donc pas changer d'un `docker compose up` a l'autre. +networks: + internal: + driver: bridge + ipam: + config: + - subnet: 172.28.0.0/24 diff --git a/infra/prod/env/data-node.env.example b/infra/prod/env/data-node.env.example new file mode 100644 index 0000000..09f984e --- /dev/null +++ b/infra/prod/env/data-node.env.example @@ -0,0 +1,69 @@ +# ============================================================================= +# Noeud de donnees (db-01) - variables d'environnement +# ============================================================================= +# Sur le serveur : /opt/xpeditis/data-node/.env.data (chmod 600, root:root) +# Dans Git : infra/prod/data-node/data-node.sops.env (chiffre SOPS) +# +# sops -e --input-type dotenv --output-type dotenv \ +# /opt/xpeditis/data-node/.env.data > data-node/data-node.sops.env +# sops -d --input-type dotenv --output-type dotenv \ +# data-node/data-node.sops.env > /opt/xpeditis/data-node/.env.data +# +# Generer un secret : openssl rand -base64 32 | tr -d '\n/+=' | head -c 40 +# Ne JAMAIS reutiliser un secret de preprod : ceux presents dans le depot sont +# compromis (cf. docs/mise-en-prod/13-securite-durcissement.md, "Rotation +# obligatoire avant la mise en production"). + +# --- Reseau ------------------------------------------------------------------ +# IP privee de db-01 : seule interface sur laquelle Postgres et Redis sont +# publies. Doit correspondre a var.db_private_ip cote Terraform. +PRIVATE_IP=10.10.1.20 + +# --- PostgreSQL -------------------------------------------------------------- +POSTGRES_DB=xpeditis_prod +POSTGRES_USER=xpeditis +POSTGRES_PASSWORD=REMPLACER_40_CARACTERES_ALEATOIRES + +# --- Redis ------------------------------------------------------------------- +REDIS_PASSWORD=REMPLACER_40_CARACTERES_ALEATOIRES + +# --- WAL-G : archivage continu vers Hetzner Object Storage ------------------- +# Bucket DEDIE aux sauvegardes, distinct du bucket des documents applicatifs, +# avec son propre jeu de cles S3. Si les cles applicatives fuitent, les +# sauvegardes restent hors de portee. +WALG_S3_PREFIX=s3://xpeditis-prod-pgbackup +WALG_S3_ENDPOINT=https://fsn1.your-objectstorage.com +WALG_S3_REGION=fsn1 +WALG_ACCESS_KEY_ID=REMPLACER +WALG_SECRET_ACCESS_KEY=REMPLACER + +# Chiffrement cote client des sauvegardes physiques. +# WAL-G attend une cle de 32 octets encodee en HEXADECIMAL (64 caracteres) : +# openssl rand -hex 32 +# Sans cette cle, aucune restauration n'est possible : conservez-la dans le +# coffre-fort ET hors ligne. +WALG_LIBSODIUM_KEY=REMPLACER_64_CARACTERES_HEXADECIMAUX + +# --- Chiffrement des dumps logiques (age) ------------------------------------ +# Cle publique age destinataire des dumps quotidiens. +# age-keygen -o backup-age.key && grep 'public key' backup-age.key +BACKUP_AGE_RECIPIENT=age1REMPLACER +# Chemin de la cle PRIVEE sur db-01, lue par pg-restore.sh. +BACKUP_AGE_KEY_FILE=/root/.config/xpeditis/backup-age.key + +# --- Storage Box Hetzner (seconde copie des dumps) --------------------------- +# Souscrite separement (BX11 ~4 EUR/mois). Cf. 12-sauvegardes-restauration.md. +STORAGEBOX_HOST=uXXXXXX.your-storagebox.de +STORAGEBOX_USER=uXXXXXX +STORAGEBOX_PORT=23 +STORAGEBOX_SSH_KEY=/root/.ssh/storagebox + +# --- Alertes ----------------------------------------------------------------- +# Meme webhook que la CI/CD : une sauvegarde en echec doit se voir. +DISCORD_WEBHOOK_URL= + +# Battement de coeur externe appele a chaque sauvegarde reussie +# (BetterStack Heartbeats ou healthchecks.io, gratuit). Le service alerte quand +# l'appel n'arrive PAS -- c'est ce qui detecte un serveur eteint ou un timer +# systemd desactive, ce qu'une supervision interne ne verrait jamais. +BACKUP_HEARTBEAT_URL= diff --git a/infra/prod/env/github-secrets.md b/infra/prod/env/github-secrets.md new file mode 100644 index 0000000..e63ff10 --- /dev/null +++ b/infra/prod/env/github-secrets.md @@ -0,0 +1,69 @@ +# Secrets et variables GitHub Actions — production + +Dépôt → **Settings → Environments → `production`**. + +Utiliser un *Environment* et non des secrets de dépôt permet d'exiger une +approbation manuelle avant tout déploiement, et empêche une branche non +protégée d'accéder à ces valeurs. + +> **Activez « Required reviewers » sur l'environnement `production`.** +> Sans cela, n'importe quel `push` sur `main` déploie sans validation humaine. + +--- + +## Secrets + +| Nom | Contenu | Comment l'obtenir | +|---|---|---| +| `REGISTRY_TOKEN` | Jeton du registre Scaleway (lecture + écriture) | Console Scaleway → Container Registry → jetons. Déjà utilisé par la preprod. | +| `HCLOUD_TOKEN_CICD` | Jeton API Hetzner Cloud | Console Hetzner → Security → API tokens. **Read & Write** (nécessaire pour ouvrir/fermer le firewall CI). Jeton **distinct** de celui de Terraform, pour pouvoir le révoquer seul. | +| `PROD_SSH_HOST` | IP publique de app-01 | `make tf-output` | +| `PROD_SSH_USER` | `deploy` | | +| `PROD_SSH_KEY` | Clé privée SSH **dédiée à la CI** | `ssh-keygen -t ed25519 -a 100 -C "github-actions-prod" -f ci_deploy` puis ajouter la clé publique dans `~deploy/.ssh/authorized_keys` sur app-01. Ne réutilisez pas votre clé personnelle. | +| `PROD_SSH_KNOWN_HOSTS` | Empreinte du serveur | `ssh-keyscan -H ` — évite qu'un détournement DNS/BGP redirige le déploiement vers une machine tierce. | +| `NEXT_PUBLIC_API_URL_PROD` | `https://api.xpeditis.com` | Figé dans l'image frontend au build. | +| `NEXT_PUBLIC_APP_URL_PROD` | `https://app.xpeditis.com` | idem | +| `DISCORD_WEBHOOK_URL` | Webhook du canal de déploiement | Déjà utilisé par la preprod. | + +--- + +## Variables (non sensibles) + +| Nom | Valeur | +|---|---| +| `PROD_API_URL` | `https://api.xpeditis.com` | +| `PROD_APP_URL` | `https://app.xpeditis.com` | +| `HCLOUD_CICD_FIREWALL` | `xpeditis-prod-fw-cicd` | + +--- + +## Restreindre la clé SSH de la CI + +La clé de déploiement n'a besoin que de lancer un script. Limitez-la dans +`~deploy/.ssh/authorized_keys` sur app-01 : + +``` +restrict,pty,command="/opt/xpeditis/infra-prod/scripts/ssh-deploy-wrapper.sh" ssh-ed25519 AAAA... github-actions-prod +``` + +`restrict` désactive le transfert de ports, d'agent et X11. Le `command=` force +l'exécution du wrapper quelle que soit la commande demandée : une clé volée ne +donne pas un shell. + +> `rsync` a besoin d'exécuter sa propre commande. Le wrapper l'autorise +> explicitement en inspectant `SSH_ORIGINAL_COMMAND` — voir +> `scripts/ssh-deploy-wrapper.sh`. + +--- + +## Ce qui ne doit PAS être un secret GitHub + +- **La clé privée age / SOPS.** Elle déchiffre *tous* les secrets de production. + Les secrets sont appliqués depuis votre poste (`make secrets-apply`), pas + depuis la CI. Un dépôt compromis ne doit pas donner les mots de passe de la + base. +- **Le `kubeconfig`.** L'API Kubernetes n'est ouverte qu'à vos IP + d'administration ; la CI passe par SSH et utilise le kubeconfig local du + serveur. +- **Le token Terraform.** Il peut détruire l'infrastructure. Il reste sur votre + poste. diff --git a/infra/prod/k8s/base/00-namespaces.yaml b/infra/prod/k8s/base/00-namespaces.yaml new file mode 100644 index 0000000..5a76345 --- /dev/null +++ b/infra/prod/k8s/base/00-namespaces.yaml @@ -0,0 +1,30 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: xpeditis-prod + labels: + app.kubernetes.io/part-of: xpeditis + environment: production + # Pod Security Admission en mode "restricted" : refuse tout pod privilegie, + # tout conteneur root, toute capability. C'est une barriere du cluster, + # independante de ce que contiennent les manifests applicatifs. + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/warn: restricted +--- +apiVersion: v1 +kind: Namespace +metadata: + name: monitoring + labels: + app.kubernetes.io/part-of: xpeditis + environment: production + # "baseline" et non "restricted" : Promtail doit lire les journaux des + # conteneurs de l'hote (hostPath), ce que "restricted" interdit. + # Le compromis est assume et confine a ce seul namespace. + pod-security.kubernetes.io/enforce: baseline + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/warn: restricted diff --git a/infra/prod/k8s/base/01-limits.yaml b/infra/prod/k8s/base/01-limits.yaml new file mode 100644 index 0000000..7b63da0 --- /dev/null +++ b/infra/prod/k8s/base/01-limits.yaml @@ -0,0 +1,59 @@ +# ============================================================================= +# Garde-fous de consommation +# ============================================================================= +# Le noeud applicatif est un CPX41 (8 vCPU / 16 Go) partage entre l'application, +# l'ingress et la supervision. Sans quota, une fuite memoire du backend fait +# tomber Traefik et Grafana avec lui : plus d'application ET plus de moyen de +# comprendre pourquoi. + +--- +apiVersion: v1 +kind: ResourceQuota +metadata: + name: xpeditis-prod-quota + namespace: xpeditis-prod +spec: + hard: + requests.cpu: "3" + requests.memory: 5Gi + limits.cpu: "7" + limits.memory: 9Gi + pods: "20" + persistentvolumeclaims: "4" + services.loadbalancers: "0" # aucun LB supplementaire facture par erreur +--- +apiVersion: v1 +kind: LimitRange +metadata: + name: xpeditis-prod-defaults + namespace: xpeditis-prod +spec: + limits: + - type: Container + # Un conteneur deploye sans requests/limits (debug, job ponctuel) herite + # de valeurs raisonnables au lieu de pouvoir tout consommer. + default: + cpu: 500m + memory: 512Mi + defaultRequest: + cpu: 100m + memory: 128Mi + max: + cpu: "4" + memory: 4Gi + min: + cpu: 10m + memory: 32Mi +--- +apiVersion: v1 +kind: ResourceQuota +metadata: + name: monitoring-quota + namespace: monitoring +spec: + hard: + requests.cpu: "1" + requests.memory: 1536Mi + limits.cpu: "3" + limits.memory: 3Gi + pods: "10" diff --git a/infra/prod/k8s/base/02-configmap-backend.yaml b/infra/prod/k8s/base/02-configmap-backend.yaml new file mode 100644 index 0000000..a46ebec --- /dev/null +++ b/infra/prod/k8s/base/02-configmap-backend.yaml @@ -0,0 +1,99 @@ +# ============================================================================= +# Configuration NON SECRETE du backend +# ============================================================================= +# Seules figurent ici les variables reellement lues par le code +# (verifie dans app.module.ts, security.config.ts et les adaptateurs). +# Les identifiants et cles vivent dans le Secret xpeditis-backend-secrets. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: xpeditis-backend-config + namespace: xpeditis-prod + labels: + app.kubernetes.io/name: xpeditis-backend + app.kubernetes.io/part-of: xpeditis +data: + NODE_ENV: "production" + PORT: "4000" + API_PREFIX: "api/v1" + # Force la sortie pino en JSON : c'est ce que Promtail sait decouper en + # champs (level, context, reqId) pour Loki. + LOG_FORMAT: "json" + + # --- URLs publiques -------------------------------------------------------- + APP_URL: "https://app.xpeditis.com" + FRONTEND_URL: "https://app.xpeditis.com" + BACKEND_URL: "https://api.xpeditis.com" + + # Liste blanche CORS. `credentials: true` interdit le joker `*` : chaque + # origine autorisee doit etre enumeree (security.config.ts). + CORS_ORIGIN: "https://app.xpeditis.com,https://www.xpeditis.com,https://xpeditis.com" + + # --- Cookies d'authentification ------------------------------------------- + # Le point initial rend le cookie valide pour tous les sous-domaines : l'API + # (api.xpeditis.com) le pose, le middleware Next.js (app.xpeditis.com) le lit. + # Sans cela, la connexion reussit mais l'utilisateur est renvoye sur /login. + COOKIE_DOMAIN: ".xpeditis.com" + # app et api partagent le meme site : 'lax' suffit et reste le plus sur. + # Ne passer a 'none' que si le front devient reellement cross-site (et 'none' + # impose Secure). + COOKIE_SAMESITE: "lax" + + # --- PostgreSQL (db-01, reseau prive) -------------------------------------- + DATABASE_HOST: "10.10.1.20" + DATABASE_PORT: "5432" + DATABASE_NAME: "xpeditis_prod" + # pg_hba n'accepte que des lignes `hostssl` : sans cette valeur a "true", + # le backend ne peut tout simplement pas se connecter. + DATABASE_SSL: "true" + DATABASE_LOGGING: "false" + + # --- Redis (db-01, reseau prive) ------------------------------------------- + REDIS_HOST: "10.10.1.20" + REDIS_PORT: "6379" + REDIS_DB: "0" + + # --- JWT ------------------------------------------------------------------- + JWT_ACCESS_EXPIRATION: "15m" + JWT_REFRESH_EXPIRATION: "7d" + + # --- SMTP (Brevo) ---------------------------------------------------------- + SMTP_HOST: "smtp-relay.brevo.com" + SMTP_PORT: "587" + SMTP_SECURE: "false" + SMTP_FROM: "noreply@xpeditis.com" + + # --- Stockage objet (Hetzner Object Storage, compatible S3) ---------------- + # Aucun changement de code : le SDK AWS parle a Hetzner via AWS_S3_ENDPOINT. + AWS_REGION: "fsn1" + AWS_S3_ENDPOINT: "https://fsn1.your-objectstorage.com" + AWS_S3_BUCKET: "xpeditis-prod-documents" + + # --- Collecteur de logs applicatifs --------------------------------------- + LOG_EXPORTER_URL: "http://xpeditis-log-exporter:3200" + + # --- Premier administrateur ------------------------------------------------ + # Lu par la migration 1756000000001-BootstrapAdminFromEnv, qui remplace le + # compte de demonstration admin@xpeditis.com / Password123!. + # + # Renseigne SEUL (sans BOOTSTRAP_ADMIN_PASSWORD_HASH dans le Secret), il cree + # un compte ADMIN actif SANS mot de passe utilisable : vous definissez le + # votre via « mot de passe oublie ». Aucun secret n'existe alors nulle part. + # + # La migration ne fait rien s'il existe deja un administrateur actif : elle ne + # peut donc pas en creer un second lors d'un deploiement ulterieur. + # + # Cette adresse doit etre RELEVABLE : c'est par elle que passe le lien de + # definition du mot de passe. + BOOTSTRAP_ADMIN_EMAIL: "ops@xpeditis.com" + BOOTSTRAP_ADMIN_FIRST_NAME: "Admin" + BOOTSTRAP_ADMIN_LAST_NAME: "Xpeditis" + + # Organisation de rattachement (users.organization_id est NOT NULL). + # Creee si elle n'existe pas ; a completer ensuite dans l'interface. + BOOTSTRAP_ADMIN_ORG_NAME: "Xpeditis" + BOOTSTRAP_ADMIN_ORG_STREET: "A completer" + BOOTSTRAP_ADMIN_ORG_CITY: "A completer" + BOOTSTRAP_ADMIN_ORG_POSTAL_CODE: "00000" + BOOTSTRAP_ADMIN_ORG_COUNTRY: "FR" diff --git a/infra/prod/k8s/base/03-secrets.template.yaml b/infra/prod/k8s/base/03-secrets.template.yaml new file mode 100644 index 0000000..a83841d --- /dev/null +++ b/infra/prod/k8s/base/03-secrets.template.yaml @@ -0,0 +1,165 @@ +# ============================================================================= +# GABARIT de secrets -- NE JAMAIS COMMITTER CE FICHIER RENSEIGNE +# ============================================================================= +# Mode d'emploi : +# +# 1. cp 03-secrets.template.yaml /tmp/secrets.yaml +# 2. renseigner chaque REMPLACER (voir 06-secrets-sops.md) +# 3. sops -e /tmp/secrets.yaml > k8s/base/03-secrets.sops.yaml +# 4. shred -u /tmp/secrets.yaml +# 5. git add k8s/base/03-secrets.sops.yaml <-- celui-la est chiffre, il se commit +# +# Application sur le cluster : bash scripts/secrets-apply.sh +# +# Generation d'une valeur aleatoire : +# openssl rand -base64 48 | tr -d '\n' +# +# RAPPEL : aucune valeur de preprod ne doit etre reprise. Les secrets presents +# dans infra/preprod/docker-stack.preprod.yml sont dans l'historique Git, donc +# compromis (cf. 13-securite-durcissement.md). + +--- +apiVersion: v1 +kind: Secret +metadata: + name: xpeditis-backend-secrets + namespace: xpeditis-prod + labels: + app.kubernetes.io/name: xpeditis-backend + app.kubernetes.io/part-of: xpeditis +type: Opaque +stringData: + # --- Base de donnees ------------------------------------------------------- + DATABASE_USER: "xpeditis" + DATABASE_PASSWORD: "REMPLACER" # identique a POSTGRES_PASSWORD de db-01 + + # --- Redis ----------------------------------------------------------------- + REDIS_PASSWORD: "REMPLACER" # identique a REDIS_PASSWORD de db-01 + + # --- JWT ------------------------------------------------------------------- + # Minimum 32 caracteres (valide par Joi au demarrage). Le changer invalide + # toutes les sessions en cours : a faire en dehors des heures ouvrees. + JWT_SECRET: "REMPLACER_64_CARACTERES_MINIMUM" + + # Derive les mots de passe des documents transporteurs. S'il est absent, le + # code retombe sur JWT_SECRET -- et une rotation du JWT rendrait alors + # illisibles tous les documents deja emis. On le definit donc explicitement, + # et on ne le change JAMAIS sans plan de migration. + DOCUMENT_PASSWORD_SECRET: "REMPLACER_32_CARACTERES_MINIMUM" + + # --- SMTP (Brevo) ---------------------------------------------------------- + # Creez une cle SMTP dediee a la production dans Brevo. Celle de la preprod + # est publiee dans le depot : elle doit etre revoquee. + SMTP_USER: "REMPLACER" + SMTP_PASS: "REMPLACER" + + # --- Stockage objet Hetzner (documents applicatifs) ------------------------ + # Jeu de cles DISTINCT de celui utilise par WAL-G pour les sauvegardes : + # une fuite cote application ne doit pas donner acces aux sauvegardes. + AWS_ACCESS_KEY_ID: "REMPLACER" + AWS_SECRET_ACCESS_KEY: "REMPLACER" + + # --- Stripe ---------------------------------------------------------------- + # ATTENTION : cles LIVE en production (sk_live_..., whsec_...). + STRIPE_SECRET_KEY: "REMPLACER_sk_live" + STRIPE_WEBHOOK_SECRET: "REMPLACER_whsec" + + # Noms exacts attendus par le code (app.module.ts / subscriptions). + # Le stack de preprod utilise STARTER/PRO/ENTERPRISE : ces noms-la ne sont lus + # par personne, les identifiants de tarif y sont donc silencieusement absents. + # Ne pas reproduire l'erreur ici. + STRIPE_SILVER_MONTHLY_PRICE_ID: "REMPLACER_price_" + STRIPE_SILVER_YEARLY_PRICE_ID: "REMPLACER_price_" + STRIPE_GOLD_MONTHLY_PRICE_ID: "REMPLACER_price_" + STRIPE_GOLD_YEARLY_PRICE_ID: "REMPLACER_price_" + STRIPE_PLATINIUM_MONTHLY_PRICE_ID: "REMPLACER_price_" + STRIPE_PLATINIUM_YEARLY_PRICE_ID: "REMPLACER_price_" + + # --- Premier administrateur (FACULTATIF) ----------------------------------- + # LAISSEZ VIDE dans le cas nominal. + # + # Vide + BOOTSTRAP_ADMIN_EMAIL renseigne dans le ConfigMap : le compte ADMIN + # est cree sans mot de passe utilisable, et vous definissez le votre via + # « mot de passe oublie ». Aucun secret n'existe nulle part -- rien a faire + # fuiter, et la reception du courriel prouve au passage que SMTP fonctionne. + # + # Ne renseignez ce champ que si vous devez pouvoir vous connecter AVANT que + # l'envoi de courriels ne soit operationnel. Dans ce cas : + # cd apps/backend && node scripts/setup/generate-admin-hash.js + # Le hash reste attaquable hors ligne : changez le mot de passe des la + # premiere connexion, puis RETIREZ cette valeur et reappliquez le Secret. + # + # Un mot de passe en clair place ici fait echouer la migration : la valeur + # doit commencer par "$argon2". + BOOTSTRAP_ADMIN_PASSWORD_HASH: "" + + # --- Pappers (registre SIRET) ---------------------------------------------- + # Facultatif : sans cle, la verification SIRET est simplement ignoree + # (pappers-siret.adapter.ts emet un avertissement au demarrage). + PAPPERS_API_KEY: "REMPLACER" + + # --- Documentation Swagger ------------------------------------------------- + # Laisser les deux valeurs VIDES en production : main.ts desactive alors + # completement /api/docs. Ne les renseigner que si vous avez vraiment besoin + # d'exposer la documentation, auquel cas elle passe derriere Basic Auth. + SWAGGER_USERNAME: "" + SWAGGER_PASSWORD: "" + + # --- Connecteurs transporteurs (facultatifs) ------------------------------- + # A renseigner uniquement quand les contrats API sont signes. Un connecteur + # sans identifiants est ignore, il ne fait pas planter le demarrage. + MAERSK_API_KEY: "" + MAERSK_API_BASE_URL: "" + MSC_API_KEY: "" + MSC_API_URL: "" + CMACGM_CLIENT_ID: "" + CMACGM_CLIENT_SECRET: "" + CMACGM_API_URL: "" + HAPAG_API_KEY: "" + HAPAG_API_URL: "" + ONE_USERNAME: "" + ONE_PASSWORD: "" + ONE_API_URL: "" + +--- +# Token API Cloudflare utilise par cert-manager pour le defi DNS-01. +# Permissions minimales : Zone / DNS / Edit sur la seule zone xpeditis.com. +apiVersion: v1 +kind: Secret +metadata: + name: cloudflare-api-token + namespace: cert-manager + labels: + app.kubernetes.io/part-of: xpeditis +type: Opaque +stringData: + api-token: "REMPLACER" + +--- +# Acces a Grafana. Mot de passe long : l'interface est exposee sur Internet, +# meme si elle est filtree par IP en amont. +apiVersion: v1 +kind: Secret +metadata: + name: grafana-admin + namespace: monitoring + labels: + app.kubernetes.io/part-of: xpeditis +type: Opaque +stringData: + admin-user: "REMPLACER" + admin-password: "REMPLACER" + +--- +# Webhook Discord utilise par Alertmanager. Dans un Secret et non dans le +# ConfigMap : une URL de webhook permet de publier n'importe quoi dans le canal. +apiVersion: v1 +kind: Secret +metadata: + name: alertmanager-secrets + namespace: monitoring + labels: + app.kubernetes.io/part-of: xpeditis +type: Opaque +stringData: + discord-webhook: "https://discord.com/api/webhooks/REMPLACER" diff --git a/infra/prod/k8s/base/04-backend.yaml b/infra/prod/k8s/base/04-backend.yaml new file mode 100644 index 0000000..996e7b3 --- /dev/null +++ b/infra/prod/k8s/base/04-backend.yaml @@ -0,0 +1,216 @@ +# ============================================================================= +# Backend NestJS +# ============================================================================= +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: xpeditis-backend + namespace: xpeditis-prod + labels: + app.kubernetes.io/name: xpeditis-backend + app.kubernetes.io/component: api + app.kubernetes.io/part-of: xpeditis +spec: + replicas: 2 + revisionHistoryLimit: 5 + strategy: + type: RollingUpdate + rollingUpdate: + # Aucun pod retire avant qu'un remplacant ne soit pret : zero coupure. + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app.kubernetes.io/name: xpeditis-backend + template: + metadata: + labels: + app.kubernetes.io/name: xpeditis-backend + app.kubernetes.io/component: api + app.kubernetes.io/part-of: xpeditis + annotations: + # Force le redemarrage des pods quand la configuration change : sans + # cela, modifier le ConfigMap ne produit aucun effet visible. + # La valeur est recalculee par scripts/deploy.sh. + xpeditis.com/config-checksum: "PLACEHOLDER" + spec: + imagePullSecrets: + - name: regcred + # 2 replicas sur 1 noeud : la contrainte est "preferred", sinon le second + # pod resterait indefiniment en Pending. Elle deviendra effective le jour + # ou un second noeud sera ajoute (phase 2 du plan de charge). + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: xpeditis-backend + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + seccompProfile: + type: RuntimeDefault + # 60 s : laisse le temps aux requetes en cours et aux connexions + # WebSocket de se fermer proprement. + terminationGracePeriodSeconds: 60 + containers: + - name: backend + # Le tag est remplace au deploiement (kubectl set image). + image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 4000 + protocol: TCP + envFrom: + - configMapRef: + name: xpeditis-backend-config + - secretRef: + name: xpeditis-backend-secrets + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + + # Le demarrage inclut l'attente de PostgreSQL puis la verification + # des migrations : la sonde de demarrage laisse jusqu'a 150 s avant + # de declarer le pod perdu, sans penaliser les redemarrages rapides. + startupProbe: + httpGet: + path: /api/v1/health/live + port: http + initialDelaySeconds: 10 + periodSeconds: 5 + failureThreshold: 30 + timeoutSeconds: 3 + + livenessProbe: + httpGet: + path: /api/v1/health/live + port: http + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + + # LIMITE CONNUE : /health/ready renvoie toujours "ready" sans tester + # PostgreSQL ni Redis (health.controller.ts). Un pod incapable de + # joindre la base sera donc declare pret. Correctif recommande apres + # la mise en ligne : @nestjs/terminus avec des indicateurs reels. + readinessProbe: + httpGet: + path: /api/v1/health/ready + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + + resources: + requests: + cpu: 300m + memory: 512Mi + limits: + cpu: 1500m + memory: 1536Mi + + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + # NON active volontairement : le conteneur ecrit dans /app/logs et + # dans /app/src/infrastructure/storage/csv-storage/rates (chemin + # resolu au runtime par le chargeur de grilles CSV). Monter des + # emptyDir par-dessus masquerait les fichiers livres dans l'image. + readOnlyRootFilesystem: false + + lifecycle: + preStop: + exec: + # Laisse Traefik retirer le pod de son pool avant que le + # processus ne commence a refuser des connexions. + command: ["sh", "-c", "sleep 10"] + +--- +apiVersion: v1 +kind: Service +metadata: + name: xpeditis-backend + namespace: xpeditis-prod + labels: + app.kubernetes.io/name: xpeditis-backend + annotations: + # Sessions collantes : obligatoires pour Socket.IO. La negociation + # long-polling echoue si deux requetes d'un meme handshake atterrissent sur + # des replicas differents. + # + # ATTENTION -- limite non resolue par ce reglage : le gateway + # (notifications.gateway.ts) garde la carte userId -> sockets EN MEMOIRE et + # n'utilise pas @socket.io/redis-adapter. Une notification emise par le + # replica A n'atteint donc pas un utilisateur connecte au replica B. + # Cf. docs/mise-en-prod/15-exploitation-incidents.md, "Points de vigilance". + traefik.ingress.kubernetes.io/service.sticky.cookie: "true" + traefik.ingress.kubernetes.io/service.sticky.cookie.name: "xpd_be" + traefik.ingress.kubernetes.io/service.sticky.cookie.secure: "true" + traefik.ingress.kubernetes.io/service.sticky.cookie.httponly: "true" + traefik.ingress.kubernetes.io/service.sticky.cookie.samesite: "lax" +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: xpeditis-backend + ports: + - name: http + port: 4000 + targetPort: http + protocol: TCP + +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: xpeditis-backend + namespace: xpeditis-prod +spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: xpeditis-backend + +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: xpeditis-backend + namespace: xpeditis-prod +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: xpeditis-backend + minReplicas: 2 + # Plafond a 4 : au-dela, le CPX41 sature. Passer a 3 noeuds avant d'augmenter + # (cf. 15-exploitation-incidents.md, section montee en charge). + maxReplicas: 4 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 + behavior: + scaleUp: + stabilizationWindowSeconds: 60 + scaleDown: + # Descente lente : evite de retirer un pod juste avant un nouveau pic. + stabilizationWindowSeconds: 600 diff --git a/infra/prod/k8s/base/05-frontend.yaml b/infra/prod/k8s/base/05-frontend.yaml new file mode 100644 index 0000000..d7d9304 --- /dev/null +++ b/infra/prod/k8s/base/05-frontend.yaml @@ -0,0 +1,163 @@ +# ============================================================================= +# Frontend Next.js 14 (sortie standalone) +# ============================================================================= +# RAPPEL CRITIQUE : NEXT_PUBLIC_API_URL est fige au moment du BUILD de l'image +# (next.config.js, bloc `env`). Une image construite pour la preprod pointera +# toujours vers api.preprod.xpeditis.com, quelles que soient les variables +# injectees ici. L'image du frontend doit donc etre RECONSTRUITE pour la prod, +# jamais promue depuis la preprod. Le workflow cd-main.yml applique cette regle. +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: xpeditis-frontend + namespace: xpeditis-prod + labels: + app.kubernetes.io/name: xpeditis-frontend + app.kubernetes.io/component: web + app.kubernetes.io/part-of: xpeditis +spec: + replicas: 2 + revisionHistoryLimit: 5 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app.kubernetes.io/name: xpeditis-frontend + template: + metadata: + labels: + app.kubernetes.io/name: xpeditis-frontend + app.kubernetes.io/component: web + app.kubernetes.io/part-of: xpeditis + spec: + imagePullSecrets: + - name: regcred + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: xpeditis-frontend + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + seccompProfile: + type: RuntimeDefault + terminationGracePeriodSeconds: 30 + containers: + - name: frontend + image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-frontend:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3000 + protocol: TCP + env: + - name: NODE_ENV + value: "production" + - name: PORT + value: "3000" + - name: HOSTNAME + value: "0.0.0.0" + # Lues cote serveur uniquement (route handlers, server actions). + # Le code client, lui, a deja la valeur figee au build. + - name: NEXT_PUBLIC_API_URL + value: "https://api.xpeditis.com" + - name: NEXT_PUBLIC_APP_URL + value: "https://app.xpeditis.com" + - name: NEXT_TELEMETRY_DISABLED + value: "1" + + startupProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 5 + periodSeconds: 3 + failureThreshold: 30 + livenessProbe: + httpGet: + path: /api/health + port: http + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + timeoutSeconds: 3 + + resources: + requests: + cpu: 200m + memory: 384Mi + limits: + cpu: 1000m + memory: 1Gi + + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + # Le build standalone n'ecrit qu'en cache : on peut donc verrouiller + # la racine et n'ouvrir que les deux repertoires necessaires. + readOnlyRootFilesystem: true + + volumeMounts: + - name: next-cache + mountPath: /app/.next/cache + - name: tmp + mountPath: /tmp + + lifecycle: + preStop: + exec: + command: ["sh", "-c", "sleep 5"] + + volumes: + - name: next-cache + emptyDir: + sizeLimit: 512Mi + - name: tmp + emptyDir: + sizeLimit: 128Mi + +--- +apiVersion: v1 +kind: Service +metadata: + name: xpeditis-frontend + namespace: xpeditis-prod + labels: + app.kubernetes.io/name: xpeditis-frontend +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: xpeditis-frontend + ports: + - name: http + port: 3000 + targetPort: http + protocol: TCP + +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: xpeditis-frontend + namespace: xpeditis-prod +spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: xpeditis-frontend diff --git a/infra/prod/k8s/base/06-log-exporter.yaml b/infra/prod/k8s/base/06-log-exporter.yaml new file mode 100644 index 0000000..faf7b2f --- /dev/null +++ b/infra/prod/k8s/base/06-log-exporter.yaml @@ -0,0 +1,73 @@ +# ============================================================================= +# Log exporter -- API interne qui pousse des logs structures vers Loki +# ============================================================================= +# Jamais expose par l'Ingress : uniquement joignable depuis le namespace +# (le backend l'appelle via LOG_EXPORTER_URL). +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: xpeditis-log-exporter + namespace: xpeditis-prod + labels: + app.kubernetes.io/name: xpeditis-log-exporter + app.kubernetes.io/component: observability + app.kubernetes.io/part-of: xpeditis +spec: + replicas: 1 + revisionHistoryLimit: 3 + selector: + matchLabels: + app.kubernetes.io/name: xpeditis-log-exporter + template: + metadata: + labels: + app.kubernetes.io/name: xpeditis-log-exporter + app.kubernetes.io/part-of: xpeditis + spec: + imagePullSecrets: + - name: regcred + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + seccompProfile: + type: RuntimeDefault + containers: + - name: log-exporter + image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-log-exporter:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3200 + env: + - name: PORT + value: "3200" + - name: LOKI_URL + value: "http://loki.monitoring.svc.cluster.local:3100" + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 192Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: true +--- +apiVersion: v1 +kind: Service +metadata: + name: xpeditis-log-exporter + namespace: xpeditis-prod +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: xpeditis-log-exporter + ports: + - name: http + port: 3200 + targetPort: http diff --git a/infra/prod/k8s/base/07-migration-job.yaml b/infra/prod/k8s/base/07-migration-job.yaml new file mode 100644 index 0000000..0cb462d --- /dev/null +++ b/infra/prod/k8s/base/07-migration-job.yaml @@ -0,0 +1,76 @@ +# ============================================================================= +# Migrations de base de donnees -- Job execute AVANT chaque deploiement +# ============================================================================= +# Pourquoi un Job separe alors que l'image lance deja les migrations au +# demarrage (scripts/setup/startup.js) : +# +# Avec 2 replicas, deux pods appellent runMigrations() simultanement. TypeORM +# ne serialise pas les migrations entre processus : l'un des deux echoue et +# part en CrashLoopBackOff. En passant par un Job (parallelisme 1) execute +# avant le `kubectl set image`, les migrations sont deja appliquees quand les +# pods demarrent : startup.js constate "no pending migrations" et laisse la +# main a l'application. Le comportement de l'image reste inchange, il devient +# simplement un filet de securite. +# +# Le nom du Job porte le tag de l'image : chaque deploiement cree son propre +# Job, ce qui laisse une trace consultable (kubectl get jobs). +# Le tag et le nom sont substitues par scripts/deploy.sh / cd-main.yml. + +apiVersion: batch/v1 +kind: Job +metadata: + name: xpeditis-migrate-__IMAGE_TAG__ + namespace: xpeditis-prod + labels: + app.kubernetes.io/name: xpeditis-migrate + app.kubernetes.io/part-of: xpeditis +spec: + # Les Jobs termines s'effacent au bout d'une heure : pas d'accumulation. + ttlSecondsAfterFinished: 3600 + backoffLimit: 2 + activeDeadlineSeconds: 900 + parallelism: 1 + completions: 1 + template: + metadata: + labels: + app.kubernetes.io/name: xpeditis-migrate + spec: + restartPolicy: Never + imagePullSecrets: + - name: regcred + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + seccompProfile: + type: RuntimeDefault + containers: + - name: migrate + image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:__IMAGE_TAG__ + imagePullPolicy: IfNotPresent + # CLI TypeORM sur la source de donnees compilee. Elle honore + # DATABASE_SSL, contrairement au client de secours de startup.js. + command: + - node + - ./node_modules/typeorm/cli.js + - migration:run + - -d + - dist/infrastructure/persistence/typeorm/data-source.js + envFrom: + - configMapRef: + name: xpeditis-backend-config + - secretRef: + name: xpeditis-backend-secrets + resources: + requests: + cpu: 200m + memory: 384Mi + limits: + cpu: 1000m + memory: 1Gi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: false diff --git a/infra/prod/k8s/base/08-traefik-middlewares.yaml b/infra/prod/k8s/base/08-traefik-middlewares.yaml new file mode 100644 index 0000000..9189964 --- /dev/null +++ b/infra/prod/k8s/base/08-traefik-middlewares.yaml @@ -0,0 +1,148 @@ +# ============================================================================= +# Middlewares Traefik +# ============================================================================= +# Reference dans les Ingress via l'annotation : +# traefik.ingress.kubernetes.io/router.middlewares: +# xpeditis-prod-@kubernetescrd +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: security-headers + namespace: xpeditis-prod +spec: + headers: + # HSTS : 1 an, sous-domaines inclus, eligible au preload. + # A n'activer qu'une fois certain que TOUS les sous-domaines sont en HTTPS, + # car la decision est irreversible cote navigateur pendant un an. + stsSeconds: 31536000 + stsIncludeSubdomains: true + stsPreload: true + forceSTSHeader: true + + contentTypeNosniff: true + browserXssFilter: true + referrerPolicy: "strict-origin-when-cross-origin" + + customResponseHeaders: + # DENY et non SAMEORIGIN : l'application n'a aucun usage legitime d'une + # iframe, et c'est la meilleure protection contre le clickjacking. + X-Frame-Options: "DENY" + Permissions-Policy: "camera=(), microphone=(), geolocation=(), payment=(self), interest-cohort=()" + Cross-Origin-Opener-Policy: "same-origin" + Cross-Origin-Resource-Policy: "same-site" + # Masque la stack technique. + Server: "" + X-Powered-By: "" + +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: rate-limit + namespace: xpeditis-prod +spec: + rateLimit: + # Limite de bordure, volontairement large : elle protege l'infrastructure. + # La limite metier fine reste celle de CustomThrottlerGuard cote NestJS. + average: 50 + period: 1s + burst: 100 + sourceCriterion: + ipStrategy: + # 1 = on prend l'avant-derniere IP de X-Forwarded-For, c'est-a-dire + # celle que Cloudflare a inscrite : la vraie IP du visiteur. + # Sans cela toutes les requetes seraient comptees sur l'IP Cloudflare + # et un seul abuseur ferait tomber la limite pour tout le monde. + depth: 1 + +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: rate-limit-auth + namespace: xpeditis-prod +spec: + rateLimit: + # Beaucoup plus stricte sur /api/v1/auth : connexion, inscription, + # reinitialisation de mot de passe, liens magiques transporteurs. + # 5 req/s en pointe suffit largement a un usage humain. + average: 5 + period: 1s + burst: 10 + sourceCriterion: + ipStrategy: + depth: 1 + +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: compress + namespace: xpeditis-prod +spec: + compress: + minResponseBodyBytes: 1024 + excludedContentTypes: + - image/png + - image/jpeg + - image/webp + - application/pdf + +--- +# Chaine appliquee au trafic public standard. +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: public-chain + namespace: xpeditis-prod +spec: + chain: + middlewares: + - name: security-headers + - name: rate-limit + - name: compress + +--- +# Chaine appliquee aux routes d'authentification. +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: auth-chain + namespace: xpeditis-prod +spec: + chain: + middlewares: + - name: security-headers + - name: rate-limit-auth + - name: compress + +--- +# Restriction par IP, utilisee pour Grafana. Remplacez les valeurs par vos IP +# d'administration -- les memes que admin_ip_allowlist cote Terraform. +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: admin-ip-allowlist + namespace: monitoring +spec: + ipAllowList: + sourceRange: + - 203.0.113.7/32 + ipStrategy: + depth: 1 + +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: security-headers + namespace: monitoring +spec: + headers: + stsSeconds: 31536000 + stsIncludeSubdomains: true + contentTypeNosniff: true + referrerPolicy: "strict-origin-when-cross-origin" + customResponseHeaders: + X-Frame-Options: "DENY" diff --git a/infra/prod/k8s/base/09-ingress.yaml b/infra/prod/k8s/base/09-ingress.yaml new file mode 100644 index 0000000..9fc583d --- /dev/null +++ b/infra/prod/k8s/base/09-ingress.yaml @@ -0,0 +1,128 @@ +# ============================================================================= +# Exposition publique +# ============================================================================= +# Cartographie des domaines : +# +# xpeditis.com -> frontend (vitrine, pages publiques) +# www.xpeditis.com -> frontend +# app.xpeditis.com -> frontend (dashboard ; c'est l'origine des cookies) +# api.xpeditis.com -> backend NestJS +# grafana.xpeditis.com-> Grafana (voir k8s/monitoring/) +# +# Le certificat est un wildcard *.xpeditis.com + xpeditis.com, obtenu par +# cert-manager en DNS-01 Cloudflare (cf. 11-certificate.yaml). +# La redirection HTTP -> HTTPS est faite au niveau de l'entrypoint Traefik, +# aucun Ingress ne peut l'oublier. + +--- +# --- API : routes d'authentification (limitation stricte) -------------------- +# Ingress separe et plus specifique que celui de l'API : Traefik privilegie la +# regle au chemin le plus long, cette limite s'applique donc bien en priorite. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: xpeditis-api-auth + namespace: xpeditis-prod + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + traefik.ingress.kubernetes.io/router.middlewares: xpeditis-prod-auth-chain@kubernetescrd +spec: + ingressClassName: traefik + tls: + - hosts: + - api.xpeditis.com + secretName: xpeditis-wildcard-tls + rules: + - host: api.xpeditis.com + http: + paths: + - path: /api/v1/auth + pathType: Prefix + backend: + service: + name: xpeditis-backend + port: + name: http + +--- +# --- API : tout le reste ----------------------------------------------------- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: xpeditis-api + namespace: xpeditis-prod + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + traefik.ingress.kubernetes.io/router.middlewares: xpeditis-prod-public-chain@kubernetescrd +spec: + ingressClassName: traefik + tls: + - hosts: + - api.xpeditis.com + secretName: xpeditis-wildcard-tls + rules: + - host: api.xpeditis.com + http: + paths: + # Couvre l'API REST, le webhook Stripe et le handshake Socket.IO + # (/socket.io/...), Traefik gerant l'upgrade WebSocket nativement. + - path: / + pathType: Prefix + backend: + service: + name: xpeditis-backend + port: + name: http + +--- +# --- Frontend ---------------------------------------------------------------- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: xpeditis-app + namespace: xpeditis-prod + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + traefik.ingress.kubernetes.io/router.middlewares: xpeditis-prod-public-chain@kubernetescrd +spec: + ingressClassName: traefik + tls: + - hosts: + - xpeditis.com + - www.xpeditis.com + - app.xpeditis.com + secretName: xpeditis-wildcard-tls + rules: + - host: app.xpeditis.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: xpeditis-frontend + port: + name: http + - host: www.xpeditis.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: xpeditis-frontend + port: + name: http + - host: xpeditis.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: xpeditis-frontend + port: + name: http diff --git a/infra/prod/k8s/base/10-network-policies.yaml b/infra/prod/k8s/base/10-network-policies.yaml new file mode 100644 index 0000000..1092192 --- /dev/null +++ b/infra/prod/k8s/base/10-network-policies.yaml @@ -0,0 +1,239 @@ +# ============================================================================= +# Politiques reseau +# ============================================================================= +# k3s applique nativement les NetworkPolicy (controleur kube-router embarque). +# +# Objectif : qu'un pod compromis ne puisse ni etre joint par n'importe qui, ni +# se servir du cluster comme point de rebond vers le reseau prive Hetzner. +# Sans ces regles, tout pod peut joindre tout pod ET toute IP privee. + +--- +# --- Refus par defaut, entrant ET sortant ----------------------------------- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny + namespace: xpeditis-prod +spec: + podSelector: {} + policyTypes: [Ingress, Egress] + +--- +# --- Resolution DNS (indispensable, sinon plus rien ne fonctionne) ---------- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-dns + namespace: xpeditis-prod +spec: + podSelector: {} + policyTypes: [Egress] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + +--- +# --- Trafic entrant depuis l'ingress ---------------------------------------- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-ingress-from-traefik + namespace: xpeditis-prod +spec: + podSelector: + matchExpressions: + - key: app.kubernetes.io/name + operator: In + values: [xpeditis-backend, xpeditis-frontend] + policyTypes: [Ingress] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: TCP + port: 4000 + - protocol: TCP + port: 3000 + +--- +# --- Le backend ecrit dans le log-exporter ---------------------------------- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-backend-to-log-exporter + namespace: xpeditis-prod +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: xpeditis-log-exporter + policyTypes: [Ingress] + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: xpeditis-backend + ports: + - protocol: TCP + port: 3200 + +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-egress-to-log-exporter + namespace: xpeditis-prod +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: xpeditis-backend + policyTypes: [Egress] + egress: + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: xpeditis-log-exporter + ports: + - protocol: TCP + port: 3200 + +--- +# --- Le log-exporter pousse vers Loki --------------------------------------- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-log-exporter-to-loki + namespace: xpeditis-prod +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: xpeditis-log-exporter + policyTypes: [Egress] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: monitoring + ports: + - protocol: TCP + port: 3100 + +--- +# --- Acces a PostgreSQL et Redis sur db-01 ---------------------------------- +# Une seule IP, deux ports. Tout autre acces au reseau prive est refuse : un +# backend compromis ne peut pas scanner 10.10.0.0/16. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-egress-to-data-node + namespace: xpeditis-prod +spec: + podSelector: + matchExpressions: + - key: app.kubernetes.io/name + operator: In + values: [xpeditis-backend, xpeditis-migrate] + policyTypes: [Egress] + egress: + - to: + - ipBlock: + cidr: 10.10.1.20/32 + ports: + - protocol: TCP + port: 5432 + - protocol: TCP + port: 6379 + +--- +# --- Sorties Internet (Stripe, Brevo, Object Storage, Pappers, carriers) ---- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-egress-internet + namespace: xpeditis-prod +spec: + podSelector: + matchExpressions: + - key: app.kubernetes.io/name + operator: In + values: [xpeditis-backend, xpeditis-frontend, xpeditis-migrate] + policyTypes: [Egress] + egress: + - to: + - ipBlock: + cidr: 0.0.0.0/0 + # Tout l'espace prive est exclu : la sortie ne sert qu'a joindre + # des services publics, jamais l'infrastructure interne. + except: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + - 169.254.0.0/16 # metadonnees cloud + ports: + - protocol: TCP + port: 443 + - protocol: TCP + port: 80 + - protocol: TCP + port: 587 # SMTP soumission (Brevo) + +--- +# --- Namespace monitoring ---------------------------------------------------- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny + namespace: monitoring +spec: + podSelector: {} + policyTypes: [Ingress] + +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-loki-writes + namespace: monitoring +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: loki + policyTypes: [Ingress] + ingress: + # Promtail (meme namespace) et le log-exporter applicatif. + - from: + - podSelector: {} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: xpeditis-prod + ports: + - protocol: TCP + port: 3100 + +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-grafana-from-traefik + namespace: monitoring +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: grafana + policyTypes: [Ingress] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: TCP + port: 3000 diff --git a/infra/prod/k8s/base/11-certificate.yaml b/infra/prod/k8s/base/11-certificate.yaml new file mode 100644 index 0000000..a2e865d --- /dev/null +++ b/infra/prod/k8s/base/11-certificate.yaml @@ -0,0 +1,59 @@ +# ============================================================================= +# Certificats TLS (Let's Encrypt via cert-manager, defi DNS-01 Cloudflare) +# ============================================================================= +# Pourquoi DNS-01 et non HTTP-01 : +# - il fonctionne meme quand le proxy Cloudflare (nuage orange) est actif et +# que le firewall Hetzner n'accepte que les IP Cloudflare ; +# - il permet un certificat wildcard, donc un seul certificat pour tous les +# sous-domaines presents et futurs ; +# - il ne depend pas de la joignabilite du port 80. +# +# Un certificat est renouvele 30 jours avant expiration. Surveillez l'alerte +# "certificat expirant" (cf. k8s/monitoring/) : un renouvellement casse passe +# inapercu jusqu'au jour ou le site devient inaccessible. + +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: xpeditis-wildcard + namespace: xpeditis-prod +spec: + secretName: xpeditis-wildcard-tls + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer + commonName: xpeditis.com + dnsNames: + # Le wildcard ne couvre PAS l'apex : les deux doivent etre listes. + - xpeditis.com + - "*.xpeditis.com" + duration: 2160h # 90 jours + renewBefore: 720h # 30 jours + privateKey: + algorithm: ECDSA + size: 256 + # Nouvelle cle a chaque renouvellement : limite la fenetre d'exploitation + # d'une cle qui aurait fuite. + rotationPolicy: Always + +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: grafana-tls + namespace: monitoring +spec: + secretName: grafana-tls + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer + commonName: grafana.xpeditis.com + dnsNames: + - grafana.xpeditis.com + duration: 2160h + renewBefore: 720h + privateKey: + algorithm: ECDSA + size: 256 + rotationPolicy: Always diff --git a/infra/prod/k8s/cluster/cluster-issuer.yaml b/infra/prod/k8s/cluster/cluster-issuer.yaml new file mode 100644 index 0000000..bb1f735 --- /dev/null +++ b/infra/prod/k8s/cluster/cluster-issuer.yaml @@ -0,0 +1,56 @@ +# ============================================================================= +# Emetteurs ACME +# ============================================================================= +# Prerequis : le Secret cert-manager/cloudflare-api-token doit exister +# (bash scripts/secrets-apply.sh). +# +# Le token Cloudflare doit avoir la permission MINIMALE : +# Zone > DNS > Edit, limite a la seule zone xpeditis.com. +# Un token global de compte donnerait a cert-manager le pouvoir de rediriger +# tout votre trafic. + +--- +# Emetteur de TEST. Ses certificats ne sont pas reconnus par les navigateurs, +# mais il n'a pas de quota serre : utilisez-le pour valider la chaine DNS-01 +# AVANT de basculer sur l'emetteur de production. +# Let's Encrypt limite la production a 5 echecs/heure et 50 certificats/semaine +# par domaine : on ne debogue pas dessus. +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-staging +spec: + acme: + server: https://acme-staging-v02.api.letsencrypt.org/directory + email: ops@xpeditis.com + privateKeySecretRef: + name: letsencrypt-staging-account-key + solvers: + - dns01: + cloudflare: + apiTokenSecretRef: + name: cloudflare-api-token + key: api-token + +--- +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + # Adresse REELLE et surveillee : Let's Encrypt y envoie les avertissements + # d'expiration si le renouvellement automatique cesse de fonctionner. + email: ops@xpeditis.com + privateKeySecretRef: + name: letsencrypt-prod-account-key + solvers: + - dns01: + cloudflare: + apiTokenSecretRef: + name: cloudflare-api-token + key: api-token + selector: + dnsZones: + - xpeditis.com diff --git a/infra/prod/k8s/monitoring/01-loki.yaml b/infra/prod/k8s/monitoring/01-loki.yaml new file mode 100644 index 0000000..7777575 --- /dev/null +++ b/infra/prod/k8s/monitoring/01-loki.yaml @@ -0,0 +1,172 @@ +# ============================================================================= +# Loki -- agregation des journaux +# ============================================================================= +# Mode "single binary", stockage sur le disque local du noeud (local-path de +# k3s). Suffisant jusqu'a ~10 Go/mois de logs, ce qui couvre tres largement la +# phase 1. Au-dela, basculer le stockage sur Hetzner Object Storage. +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: loki-config + namespace: monitoring +data: + loki.yaml: | + auth_enabled: false + + server: + http_listen_port: 3100 + grpc_listen_port: 9096 + log_level: warn + + common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + + schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + + limits_config: + allow_structured_metadata: true + volume_enabled: true + # 31 jours : couvre l'analyse d'incident et reste sous le seuil ou les + # journaux applicatifs deviendraient une base de donnees personnelles + # a part entiere au sens du RGPD (cf. 16 dans la doc de mise en prod). + retention_period: 744h + reject_old_samples: true + reject_old_samples_max_age: 168h + ingestion_rate_mb: 8 + ingestion_burst_size_mb: 16 + max_entries_limit_per_query: 5000 + max_query_series: 500 + + compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + retention_delete_worker_count: 50 + delete_request_store: filesystem + + query_range: + results_cache: + cache: + embedded_cache: + enabled: true + max_size_mb: 100 + + analytics: + reporting_enabled: false +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: loki-data + namespace: monitoring +spec: + accessModes: [ReadWriteOnce] + storageClassName: local-path + resources: + requests: + storage: 20Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: loki + namespace: monitoring + labels: + app.kubernetes.io/name: loki +spec: + replicas: 1 + # Un seul volume RWO : on remplace avant de recreer, jamais l'inverse. + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: loki + template: + metadata: + labels: + app.kubernetes.io/name: loki + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: loki + image: grafana/loki:3.3.2 + args: ["-config.file=/etc/loki/loki.yaml"] + ports: + - name: http + containerPort: 3100 + readinessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 60 + periodSeconds: 30 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 1000m + memory: 1Gi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + volumeMounts: + - name: config + mountPath: /etc/loki + - name: data + mountPath: /loki + volumes: + - name: config + configMap: + name: loki-config + - name: data + persistentVolumeClaim: + claimName: loki-data +--- +apiVersion: v1 +kind: Service +metadata: + name: loki + namespace: monitoring + labels: + app.kubernetes.io/name: loki +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: loki + ports: + - name: http + port: 3100 + targetPort: http diff --git a/infra/prod/k8s/monitoring/02-promtail.yaml b/infra/prod/k8s/monitoring/02-promtail.yaml new file mode 100644 index 0000000..d705c63 --- /dev/null +++ b/infra/prod/k8s/monitoring/02-promtail.yaml @@ -0,0 +1,185 @@ +# ============================================================================= +# Promtail -- collecte des journaux de conteneurs +# ============================================================================= +# k3s utilise containerd, pas Docker : la decouverte se fait via l'API +# Kubernetes et la lecture de /var/log/pods, et non via le socket Docker comme +# en preprod. +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: promtail + namespace: monitoring +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: promtail +rules: + # Lecture seule, strictement ce qu'exige la decouverte de pods. + - apiGroups: [""] + resources: [nodes, nodes/proxy, services, endpoints, pods] + verbs: [get, list, watch] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: promtail +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: promtail +subjects: + - kind: ServiceAccount + name: promtail + namespace: monitoring +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: promtail-config + namespace: monitoring +data: + promtail.yaml: | + server: + http_listen_port: 9080 + log_level: warn + + positions: + filename: /run/promtail/positions.yaml + + clients: + - url: http://loki:3100/loki/api/v1/push + batchwait: 1s + batchsize: 1048576 + timeout: 10s + + scrape_configs: + - job_name: kubernetes-pods + kubernetes_sd_configs: + - role: pod + pipeline_stages: + # Format CRI de containerd : " ". + - cri: {} + + - drop: + older_than: 15m + drop_counter_reason: entry_too_old + + # Les sondes de sante representent l'essentiel du volume et n'ont + # aucune valeur d'analyse : on les jette avant ingestion. + - drop: + expression: '(GET /api/v1/health|GET /api/health|/ready|/metrics)' + drop_counter_reason: healthcheck + + # Journaux pino du backend (LOG_FORMAT=json). + - json: + expressions: + level: level + msg: msg + context: context + reqId: reqId + - template: + source: level + template: >- + {{ if eq .Value "10" }}trace{{ else if eq .Value "20" }}debug{{ else if eq .Value "30" }}info{{ else if eq .Value "40" }}warn{{ else if eq .Value "50" }}error{{ else if eq .Value "60" }}fatal{{ else }}{{ .Value }}{{ end }} + - labels: + level: + context: + + relabel_configs: + - source_labels: [__meta_kubernetes_pod_node_name] + target_label: node + - source_labels: [__meta_kubernetes_namespace] + target_label: namespace + - source_labels: [__meta_kubernetes_pod_name] + target_label: pod + - source_labels: [__meta_kubernetes_pod_container_name] + target_label: container + - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name] + target_label: service + # Chemin reel des journaux sur l'hote. + - source_labels: [__meta_kubernetes_pod_uid, __meta_kubernetes_pod_container_name] + target_label: __path__ + separator: / + replacement: /var/log/pods/*$1/*.log + # On ne collecte que les namespaces utiles : pas de bruit kube-system + # hormis l'ingress. + - source_labels: [__meta_kubernetes_namespace] + regex: (xpeditis-prod|monitoring|kube-system) + action: keep +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: promtail + namespace: monitoring + labels: + app.kubernetes.io/name: promtail +spec: + selector: + matchLabels: + app.kubernetes.io/name: promtail + template: + metadata: + labels: + app.kubernetes.io/name: promtail + spec: + serviceAccountName: promtail + securityContext: + # Les journaux de /var/log/pods appartiennent a root : Promtail doit + # pouvoir les lire. C'est la raison pour laquelle le namespace + # monitoring est en "baseline" et non "restricted". + runAsUser: 0 + seccompProfile: + type: RuntimeDefault + containers: + - name: promtail + image: grafana/promtail:3.3.2 + args: ["-config.file=/etc/promtail/promtail.yaml"] + env: + - name: HOSTNAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + ports: + - name: http + containerPort: 9080 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 300m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: config + mountPath: /etc/promtail + - name: positions + mountPath: /run/promtail + - name: pods + mountPath: /var/log/pods + readOnly: true + - name: containers + mountPath: /var/lib/rancher/k3s/agent/containerd + readOnly: true + volumes: + - name: config + configMap: + name: promtail-config + - name: positions + emptyDir: {} + - name: pods + hostPath: + path: /var/log/pods + - name: containers + hostPath: + path: /var/lib/rancher/k3s/agent/containerd + tolerations: + - effect: NoSchedule + operator: Exists diff --git a/infra/prod/k8s/monitoring/03-prometheus.yaml b/infra/prod/k8s/monitoring/03-prometheus.yaml new file mode 100644 index 0000000..806780b --- /dev/null +++ b/infra/prod/k8s/monitoring/03-prometheus.yaml @@ -0,0 +1,351 @@ +# ============================================================================= +# Prometheus -- metriques et regles d'alerte +# ============================================================================= +# Perimetre volontairement reduit : kubelet/cAdvisor, node-exporter, Traefik, +# cert-manager et postgres-exporter. Pas de kube-state-metrics ni d'operateur : +# a un noeud et une dizaine de pods, ils coutent plus de RAM qu'ils n'apportent. +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: prometheus + namespace: monitoring +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: prometheus +rules: + - apiGroups: [""] + resources: [nodes, nodes/metrics, nodes/proxy, services, endpoints, pods] + verbs: [get, list, watch] + - nonResourceURLs: ["/metrics", "/metrics/cadvisor"] + verbs: [get] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: prometheus +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: prometheus +subjects: + - kind: ServiceAccount + name: prometheus + namespace: monitoring +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: prometheus-config + namespace: monitoring +data: + prometheus.yml: | + global: + scrape_interval: 30s + evaluation_interval: 30s + external_labels: + cluster: xpeditis-prod + + rule_files: + - /etc/prometheus/rules/*.yml + + alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager:9093'] + + scrape_configs: + - job_name: prometheus + static_configs: + - targets: ['localhost:9090'] + + - job_name: node-exporter + kubernetes_sd_configs: + - role: endpoints + relabel_configs: + - source_labels: [__meta_kubernetes_service_name] + regex: node-exporter + action: keep + + # cAdvisor : consommation CPU/memoire par conteneur. + - job_name: kubelet-cadvisor + scheme: https + tls_config: + ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt + insecure_skip_verify: true + bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token + kubernetes_sd_configs: + - role: node + relabel_configs: + - action: labelmap + regex: __meta_kubernetes_node_label_(.+) + - target_label: __address__ + replacement: kubernetes.default.svc:443 + - source_labels: [__meta_kubernetes_node_name] + regex: (.+) + target_label: __metrics_path__ + replacement: /api/v1/nodes/${1}/proxy/metrics/cadvisor + + - job_name: traefik + kubernetes_sd_configs: + - role: pod + namespaces: + names: [kube-system] + relabel_configs: + - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name] + regex: traefik + action: keep + - source_labels: [__address__] + regex: '(.+?)(?::\d+)?' + target_label: __address__ + replacement: '${1}:9100' + + - job_name: cert-manager + kubernetes_sd_configs: + - role: pod + namespaces: + names: [cert-manager] + relabel_configs: + - source_labels: [__meta_kubernetes_pod_container_port_number] + regex: "9402" + action: keep + + # PostgreSQL : l'exportateur tourne sur db-01, hors du cluster. + - job_name: postgres + static_configs: + - targets: ['10.10.1.20:9187'] + labels: + instance: xpeditis-prod-db-01 + + - job_name: loki + static_configs: + - targets: ['loki:3100'] + + rules.yml: | + groups: + - name: xpeditis-disponibilite + rules: + - alert: BackendIndisponible + # Traefik ne voit plus aucune instance saine derriere le service : + # l'API est hors ligne pour les utilisateurs, quoi que dise k8s. + expr: | + sum by (service) (traefik_service_server_up{service=~"xpeditis-prod-xpeditis-backend.*"}) == 0 + for: 2m + labels: + severity: critique + annotations: + summary: "Aucune instance backend saine derriere l'ingress" + description: "L'API Xpeditis ne repond plus. Verifier : kubectl -n xpeditis-prod get pods" + + - alert: FrontendIndisponible + expr: | + sum by (service) (traefik_service_server_up{service=~"xpeditis-prod-xpeditis-frontend.*"}) == 0 + for: 2m + labels: + severity: critique + annotations: + summary: "Aucune instance frontend saine derriere l'ingress" + + - alert: TauxErreur5xxEleve + expr: | + sum(rate(traefik_service_requests_total{code=~"5.."}[5m])) + / clamp_min(sum(rate(traefik_service_requests_total[5m])), 0.001) > 0.05 + for: 5m + labels: + severity: critique + annotations: + summary: "Plus de 5% de reponses 5xx" + description: "Taux d'erreur serveur anormal sur les 5 dernieres minutes." + + - alert: LatenceElevee + expr: | + histogram_quantile(0.95, + sum(rate(traefik_service_request_duration_seconds_bucket[5m])) by (le, service)) > 2 + for: 10m + labels: + severity: avertissement + annotations: + summary: "P95 au-dessus de 2 s sur {{ $labels.service }}" + + - name: xpeditis-ressources + rules: + - alert: DisqueBientotPlein + # Un disque plein arrete PostgreSQL et k3s. Seuil a 15% restants + # pour laisser le temps d'intervenir. + expr: | + node_filesystem_avail_bytes{mountpoint="/",fstype!="tmpfs"} + / node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"} < 0.15 + for: 10m + labels: + severity: critique + annotations: + summary: "Moins de 15% d'espace disque libre sur {{ $labels.instance }}" + + - alert: MemoireNoeudSaturee + expr: | + (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.90 + for: 10m + labels: + severity: avertissement + annotations: + summary: "Memoire du noeud a plus de 90%" + + - alert: ConteneurRedemarreEnBoucle + expr: | + increase(container_start_time_seconds{namespace="xpeditis-prod"}[15m]) > 3 + for: 5m + labels: + severity: critique + annotations: + summary: "Le conteneur {{ $labels.container }} redemarre en boucle" + + - name: xpeditis-donnees + rules: + - alert: PostgresInjoignable + expr: pg_up == 0 + for: 2m + labels: + severity: critique + annotations: + summary: "PostgreSQL ne repond plus sur db-01" + description: "Verifier db-01 : docker compose ps, journalctl -u docker" + + - alert: ConnexionsPostgresSaturees + expr: | + sum(pg_stat_database_numbackends) / on() pg_settings_max_connections > 0.80 + for: 5m + labels: + severity: avertissement + annotations: + summary: "Plus de 80% des connexions PostgreSQL consommees" + + # NOTE -- la surveillance des sauvegardes ne passe PAS par Prometheus. + # Une alerte "la sauvegarde n'a pas tourne" evaluee par un composant + # heberge sur la meme machine ne se declenche pas quand la machine est + # eteinte, c'est-a-dire precisement quand elle serait utile. + # Elle repose donc sur un battement de coeur externe : pg-backup.sh + # appelle BACKUP_HEARTBEAT_URL a chaque succes, et le service externe + # (BetterStack / healthchecks.io) alerte en cas de silence. + # Cf. docs/mise-en-prod/12-sauvegardes-restauration.md. + + - name: xpeditis-tls + rules: + - alert: CertificatBientotExpire + expr: | + (certmanager_certificate_expiration_timestamp_seconds - time()) / 86400 < 15 + for: 1h + labels: + severity: critique + annotations: + summary: "Certificat {{ $labels.name }} expire dans moins de 15 jours" + description: "Le renouvellement automatique ne fonctionne plus. Verifier : kubectl describe certificate -A" +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: prometheus-data + namespace: monitoring +spec: + accessModes: [ReadWriteOnce] + storageClassName: local-path + resources: + requests: + storage: 15Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: prometheus + namespace: monitoring + labels: + app.kubernetes.io/name: prometheus +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: prometheus + template: + metadata: + labels: + app.kubernetes.io/name: prometheus + annotations: + # Redemarre Prometheus quand les regles changent. + checksum/config: "PLACEHOLDER" + spec: + serviceAccountName: prometheus + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + fsGroup: 65534 + seccompProfile: + type: RuntimeDefault + containers: + - name: prometheus + image: prom/prometheus:v3.1.0 + args: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + # 15 jours : suffisant pour l'analyse d'incident, tient dans 15 Go. + - --storage.tsdb.retention.time=15d + - --web.enable-lifecycle + - --web.listen-address=:9090 + ports: + - name: http + containerPort: 9090 + readinessProbe: + httpGet: + path: /-/ready + port: http + initialDelaySeconds: 20 + livenessProbe: + httpGet: + path: /-/healthy + port: http + initialDelaySeconds: 60 + periodSeconds: 30 + resources: + requests: + cpu: 150m + memory: 512Mi + limits: + cpu: 1000m + memory: 1536Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + volumeMounts: + - name: config + mountPath: /etc/prometheus/prometheus.yml + subPath: prometheus.yml + - name: config + mountPath: /etc/prometheus/rules/rules.yml + subPath: rules.yml + - name: data + mountPath: /prometheus + volumes: + - name: config + configMap: + name: prometheus-config + - name: data + persistentVolumeClaim: + claimName: prometheus-data +--- +apiVersion: v1 +kind: Service +metadata: + name: prometheus + namespace: monitoring +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: prometheus + ports: + - name: http + port: 9090 + targetPort: http diff --git a/infra/prod/k8s/monitoring/04-node-exporter.yaml b/infra/prod/k8s/monitoring/04-node-exporter.yaml new file mode 100644 index 0000000..83be8ad --- /dev/null +++ b/infra/prod/k8s/monitoring/04-node-exporter.yaml @@ -0,0 +1,95 @@ +# ============================================================================= +# node-exporter -- metriques systeme du noeud +# ============================================================================= +# Presence justifiee par une seule alerte, mais la plus importante de toutes : +# le disque plein. Un disque sature arrete PostgreSQL, k3s et les sauvegardes +# en meme temps, et c'est la panne la plus frequente d'un serveur laisse seul. +# +# Port 9101 et non 9100 : 9100 est deja l'entrypoint metrics de Traefik, et +# confondre les deux cibles rend le diagnostic penible. +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: node-exporter + namespace: monitoring + labels: + app.kubernetes.io/name: node-exporter +spec: + selector: + matchLabels: + app.kubernetes.io/name: node-exporter + template: + metadata: + labels: + app.kubernetes.io/name: node-exporter + spec: + hostNetwork: true + hostPID: true + securityContext: + runAsNonRoot: true + runAsUser: 65534 + seccompProfile: + type: RuntimeDefault + containers: + - name: node-exporter + image: prom/node-exporter:v1.8.2 + args: + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + - --path.rootfs=/host/root + - --web.listen-address=:9101 + - --collector.filesystem.mount-points-exclude=^/(dev|proc|sys|var/lib/docker/.+|var/lib/kubelet/.+|var/lib/rancher/.+)($|/) + ports: + - name: metrics + containerPort: 9101 + hostPort: 9101 + resources: + requests: + cpu: 20m + memory: 32Mi + limits: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: proc + mountPath: /host/proc + readOnly: true + - name: sys + mountPath: /host/sys + readOnly: true + - name: root + mountPath: /host/root + mountPropagation: HostToContainer + readOnly: true + volumes: + - name: proc + hostPath: { path: /proc } + - name: sys + hostPath: { path: /sys } + - name: root + hostPath: { path: / } + tolerations: + - effect: NoSchedule + operator: Exists +--- +apiVersion: v1 +kind: Service +metadata: + name: node-exporter + namespace: monitoring + labels: + app.kubernetes.io/name: node-exporter +spec: + clusterIP: None + selector: + app.kubernetes.io/name: node-exporter + ports: + - name: metrics + port: 9101 + targetPort: metrics diff --git a/infra/prod/k8s/monitoring/05-alertmanager.yaml b/infra/prod/k8s/monitoring/05-alertmanager.yaml new file mode 100644 index 0000000..2c6d9f6 --- /dev/null +++ b/infra/prod/k8s/monitoring/05-alertmanager.yaml @@ -0,0 +1,150 @@ +# ============================================================================= +# Alertmanager -- routage des alertes vers Discord +# ============================================================================= +# L'URL du webhook n'est PAS dans ce ConfigMap : elle est lue depuis un fichier +# monte depuis un Secret (webhook_url_file). Un ConfigMap est lisible par tout +# ce qui a un acces lecture au namespace, un webhook Discord permet de publier +# n'importe quoi dans votre canal d'exploitation. +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: alertmanager-config + namespace: monitoring +data: + alertmanager.yml: | + global: + resolve_timeout: 5m + + route: + receiver: discord + group_by: ['alertname', 'severity'] + group_wait: 30s + group_interval: 5m + # Une alerte critique non traitee est repetee toutes les heures : elle ne + # doit pas disparaitre du fil de discussion. + repeat_interval: 1h + routes: + - matchers: + - severity = "avertissement" + receiver: discord + repeat_interval: 12h + + inhibit_rules: + # Si le backend est totalement indisponible, inutile d'inonder le canal + # avec les alertes de latence et de taux d'erreur qui en decoulent. + - source_matchers: + - alertname = "BackendIndisponible" + target_matchers: + - severity =~ "avertissement|critique" + equal: ['cluster'] + + receivers: + - name: discord + discord_configs: + - webhook_url_file: /etc/alertmanager/secrets/discord-webhook + send_resolved: true + title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}' + message: |- + {{ range .Alerts }}{{ .Annotations.summary }} + {{ .Annotations.description }} + {{ end }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: alertmanager-data + namespace: monitoring +spec: + accessModes: [ReadWriteOnce] + storageClassName: local-path + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: alertmanager + namespace: monitoring + labels: + app.kubernetes.io/name: alertmanager +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: alertmanager + template: + metadata: + labels: + app.kubernetes.io/name: alertmanager + spec: + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + fsGroup: 65534 + seccompProfile: + type: RuntimeDefault + containers: + - name: alertmanager + image: prom/alertmanager:v0.28.0 + args: + - --config.file=/etc/alertmanager/alertmanager.yml + - --storage.path=/alertmanager + - --web.listen-address=:9093 + ports: + - name: http + containerPort: 9093 + readinessProbe: + httpGet: + path: /-/ready + port: http + initialDelaySeconds: 10 + resources: + requests: + cpu: 20m + memory: 64Mi + limits: + cpu: 200m + memory: 192Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: config + mountPath: /etc/alertmanager/alertmanager.yml + subPath: alertmanager.yml + - name: secrets + mountPath: /etc/alertmanager/secrets + readOnly: true + - name: data + mountPath: /alertmanager + volumes: + - name: config + configMap: + name: alertmanager-config + - name: secrets + secret: + secretName: alertmanager-secrets + - name: data + persistentVolumeClaim: + claimName: alertmanager-data +--- +apiVersion: v1 +kind: Service +metadata: + name: alertmanager + namespace: monitoring +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: alertmanager + ports: + - name: http + port: 9093 + targetPort: http diff --git a/infra/prod/k8s/monitoring/06-grafana.yaml b/infra/prod/k8s/monitoring/06-grafana.yaml new file mode 100644 index 0000000..60dfa24 --- /dev/null +++ b/infra/prod/k8s/monitoring/06-grafana.yaml @@ -0,0 +1,219 @@ +# ============================================================================= +# Grafana -- tableaux de bord (logs Loki + metriques Prometheus) +# ============================================================================= +# Expose sur grafana.xpeditis.com, protege par TROIS couches : +# 1. le firewall Hetzner (seules les IP Cloudflare atteignent le serveur) +# 2. le middleware Traefik admin-ip-allowlist (vos IP d'administration) +# 3. l'authentification Grafana (Secret grafana-admin) +# L'inscription et l'acces anonyme sont desactives. +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-provisioning + namespace: monitoring +data: + datasources.yaml: | + apiVersion: 1 + datasources: + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + isDefault: false + jsonData: + maxLines: 2000 + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + jsonData: + timeInterval: 30s + + dashboards.yaml: | + apiVersion: 1 + providers: + - name: xpeditis + orgId: 1 + folder: Xpeditis + type: file + disableDeletion: false + updateIntervalSeconds: 60 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: grafana-data + namespace: monitoring +spec: + accessModes: [ReadWriteOnce] + storageClassName: local-path + resources: + requests: + storage: 5Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grafana + namespace: monitoring + labels: + app.kubernetes.io/name: grafana +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: grafana + template: + metadata: + labels: + app.kubernetes.io/name: grafana + spec: + securityContext: + runAsNonRoot: true + runAsUser: 472 + runAsGroup: 472 + fsGroup: 472 + seccompProfile: + type: RuntimeDefault + containers: + - name: grafana + image: grafana/grafana:11.4.0 + ports: + - name: http + containerPort: 3000 + env: + - name: GF_SECURITY_ADMIN_USER + valueFrom: + secretKeyRef: + name: grafana-admin + key: admin-user + - name: GF_SECURITY_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: grafana-admin + key: admin-password + - name: GF_SERVER_ROOT_URL + value: "https://grafana.xpeditis.com" + - name: GF_USERS_ALLOW_SIGN_UP + value: "false" + - name: GF_AUTH_ANONYMOUS_ENABLED + value: "false" + # Empeche l'integration de Grafana dans une iframe tierce. + - name: GF_SECURITY_ALLOW_EMBEDDING + value: "false" + - name: GF_SECURITY_COOKIE_SECURE + value: "true" + - name: GF_SECURITY_COOKIE_SAMESITE + value: "strict" + - name: GF_SECURITY_STRICT_TRANSPORT_SECURITY + value: "true" + - name: GF_ANALYTICS_REPORTING_ENABLED + value: "false" + - name: GF_ANALYTICS_CHECK_FOR_UPDATES + value: "false" + # Les alertes sont evaluees par Prometheus et routees par + # Alertmanager : l'alerting Grafana ferait doublon. + - name: GF_UNIFIED_ALERTING_ENABLED + value: "false" + - name: GF_ALERTING_ENABLED + value: "false" + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 20 + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 60 + periodSeconds: 30 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + volumeMounts: + - name: provisioning-datasources + mountPath: /etc/grafana/provisioning/datasources + - name: provisioning-dashboards + mountPath: /etc/grafana/provisioning/dashboards + - name: dashboards + mountPath: /var/lib/grafana/dashboards + - name: data + mountPath: /var/lib/grafana + volumes: + - name: provisioning-datasources + configMap: + name: grafana-provisioning + items: + - key: datasources.yaml + path: datasources.yaml + - name: provisioning-dashboards + configMap: + name: grafana-provisioning + items: + - key: dashboards.yaml + path: dashboards.yaml + # Cree par scripts/deploy-monitoring.sh depuis infra/logging/grafana/. + - name: dashboards + configMap: + name: grafana-dashboards + optional: true + - name: data + persistentVolumeClaim: + claimName: grafana-data +--- +apiVersion: v1 +kind: Service +metadata: + name: grafana + namespace: monitoring +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: grafana + ports: + - name: http + port: 3000 + targetPort: http +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: grafana + namespace: monitoring + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + traefik.ingress.kubernetes.io/router.middlewares: monitoring-admin-ip-allowlist@kubernetescrd,monitoring-security-headers@kubernetescrd +spec: + ingressClassName: traefik + tls: + - hosts: + - grafana.xpeditis.com + secretName: grafana-tls + rules: + - host: grafana.xpeditis.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: grafana + port: + name: http diff --git a/infra/prod/scripts/00-bootstrap-common.sh b/infra/prod/scripts/00-bootstrap-common.sh new file mode 100755 index 0000000..5f34a0b --- /dev/null +++ b/infra/prod/scripts/00-bootstrap-common.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# ============================================================================= +# 00 - Durcissement commun aux deux noeuds (app-01 et db-01) +# ============================================================================= +# A executer EN PREMIER sur chaque serveur, en sudo : +# scp infra/prod/scripts/00-bootstrap-common.sh deploy@:/tmp/ +# ssh deploy@ 'sudo bash /tmp/00-bootstrap-common.sh ' +# +# = app | data +# +# Idempotent : peut etre relance sans risque. +set -euo pipefail + +ROLE="${1:-}" +if [[ "$ROLE" != "app" && "$ROLE" != "data" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +if [[ "$EUID" -ne 0 ]]; then + echo "Ce script doit tourner en root (sudo)." >&2 + exit 1 +fi + +log() { printf '\n>>> %s\n' "$*"; } + +# --- 1. Paquets de base ------------------------------------------------------ +log "Mise a jour du systeme" +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get upgrade -y -qq +apt-get install -y -qq \ + ufw fail2ban unattended-upgrades apt-listchanges \ + chrony auditd audispd-plugins \ + curl gnupg ca-certificates jq git rsync htop ncdu \ + needrestart + +# --- 2. Mises a jour de securite automatiques -------------------------------- +log "Activation des mises a jour de securite automatiques" +cat > /etc/apt/apt.conf.d/20auto-upgrades <<'CONF' +APT::Periodic::Update-Package-Lists "1"; +APT::Periodic::Unattended-Upgrade "1"; +APT::Periodic::AutocleanInterval "7"; +CONF + +cat > /etc/apt/apt.conf.d/50unattended-upgrades <<'CONF' +Unattended-Upgrade::Allowed-Origins { + "${distro_id}:${distro_codename}-security"; + "${distro_id}ESMApps:${distro_codename}-apps-security"; + "${distro_id}ESM:${distro_codename}-infra-security"; +}; +Unattended-Upgrade::Remove-Unused-Kernel-Packages "true"; +Unattended-Upgrade::Remove-Unused-Dependencies "true"; +// Redemarrage automatique la nuit SI un paquet noyau l'exige. +// Fenetre choisie hors des heures ouvrees des transitaires europeens. +Unattended-Upgrade::Automatic-Reboot "true"; +Unattended-Upgrade::Automatic-Reboot-WithUsers "false"; +Unattended-Upgrade::Automatic-Reboot-Time "04:30"; +Unattended-Upgrade::Mail ""; +CONF + +systemctl enable --now unattended-upgrades + +# --- 3. SSH ------------------------------------------------------------------ +log "Durcissement SSH" +cat > /etc/ssh/sshd_config.d/99-xpeditis-hardening.conf <<'CONF' +# Authentification par cle uniquement. +PermitRootLogin no +PasswordAuthentication no +KbdInteractiveAuthentication no +ChallengeResponseAuthentication no +PermitEmptyPasswords no +PubkeyAuthentication yes +AuthenticationMethods publickey + +# Reduction de surface. +X11Forwarding no +AllowAgentForwarding no +PermitTunnel no +GatewayPorts no + +# Anti brute-force / sessions fantomes. +MaxAuthTries 3 +MaxSessions 5 +LoginGraceTime 20 +ClientAliveInterval 300 +ClientAliveCountMax 2 + +# Cryptographie moderne uniquement. +KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512 +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com +Macs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com + +AllowUsers deploy +CONF + +# Retire les cles d'hote faibles si presentes. +rm -f /etc/ssh/ssh_host_dsa_key* /etc/ssh/ssh_host_ecdsa_key* || true + +sshd -t +systemctl restart ssh 2>/dev/null || systemctl restart sshd + +# --- 4. fail2ban ------------------------------------------------------------- +log "Configuration fail2ban" +cat > /etc/fail2ban/jail.d/xpeditis.local <<'CONF' +[DEFAULT] +bantime = 1h +findtime = 10m +maxretry = 4 +backend = systemd +# Ne jamais se bannir soi-meme depuis le reseau prive. +ignoreip = 127.0.0.1/8 ::1 10.10.0.0/16 + +[sshd] +enabled = true +mode = aggressive +maxretry = 3 +bantime = 24h +CONF + +systemctl enable --now fail2ban +systemctl restart fail2ban + +# --- 5. Parametres noyau ----------------------------------------------------- +log "Durcissement sysctl" +cat > /etc/sysctl.d/99-xpeditis-hardening.conf <<'CONF' +# Reseau +net.ipv4.conf.all.rp_filter = 1 +net.ipv4.conf.default.rp_filter = 1 +net.ipv4.conf.all.accept_redirects = 0 +net.ipv4.conf.all.send_redirects = 0 +net.ipv4.conf.all.accept_source_route = 0 +net.ipv4.conf.all.log_martians = 1 +net.ipv4.icmp_echo_ignore_broadcasts = 1 +net.ipv4.tcp_syncookies = 1 +net.ipv6.conf.all.accept_redirects = 0 +net.ipv6.conf.all.accept_source_route = 0 + +# Memoire / noyau +kernel.randomize_va_space = 2 +kernel.kptr_restrict = 2 +kernel.dmesg_restrict = 1 +kernel.yama.ptrace_scope = 1 +fs.protected_hardlinks = 1 +fs.protected_symlinks = 1 +fs.suid_dumpable = 0 + +# Capacite : k3s et PostgreSQL ouvrent beaucoup de descripteurs. +fs.file-max = 2097152 +fs.inotify.max_user_instances = 8192 +fs.inotify.max_user_watches = 524288 +CONF +sysctl --system >/dev/null + +# --- 6. Journalisation ------------------------------------------------------- +log "Limitation des journaux systemd (evite de saturer le disque)" +mkdir -p /etc/systemd/journald.conf.d +cat > /etc/systemd/journald.conf.d/99-xpeditis.conf <<'CONF' +[Journal] +SystemMaxUse=2G +SystemMaxFileSize=200M +MaxRetentionSec=30day +Compress=yes +CONF +systemctl restart systemd-journald + +# --- 7. Horloge -------------------------------------------------------------- +log "Synchronisation horaire (obligatoire : JWT, TLS, audit_logs)" +timedatectl set-timezone Europe/Paris +systemctl enable --now chrony + +# --- 8. Audit ---------------------------------------------------------------- +log "Regles auditd minimales" +cat > /etc/audit/rules.d/99-xpeditis.rules <<'CONF' +-w /etc/passwd -p wa -k identity +-w /etc/shadow -p wa -k identity +-w /etc/ssh/sshd_config -p wa -k sshd +-w /etc/ssh/sshd_config.d/ -p wa -k sshd +-w /etc/sudoers -p wa -k sudoers +-w /etc/sudoers.d/ -p wa -k sudoers +-w /var/log/auth.log -p wa -k authlog +-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=4294967295 -k rootcmd +CONF +augenrules --load >/dev/null 2>&1 || true +systemctl enable --now auditd + +# --- 9. Pare-feu local ------------------------------------------------------- +# Le firewall Hetzner Cloud ne filtre QUE les interfaces publiques. UFW prend +# en charge le reseau prive, ou transite le trafic PostgreSQL/Redis. +log "Configuration UFW (role: $ROLE)" +ufw --force reset >/dev/null +ufw default deny incoming +ufw default allow outgoing +ufw allow 22/tcp comment 'SSH' + +if [[ "$ROLE" == "app" ]]; then + ufw allow 80/tcp comment 'HTTP (redirection + ACME)' + ufw allow 443/tcp comment 'HTTPS' + ufw allow 6443/tcp comment 'API k3s' + # Reseau prive : le noeud app doit joindre db-01, pas l'inverse. + ufw allow from 10.10.0.0/16 to any port 10250 proto tcp comment 'kubelet metrics' +else + # Seul app-01 (IP privee) peut atteindre PostgreSQL et Redis. + APP_PRIVATE_IP="${APP_PRIVATE_IP:-10.10.1.10}" + ufw allow from "${APP_PRIVATE_IP}" to any port 5432 proto tcp comment 'PostgreSQL <- app-01' + ufw allow from "${APP_PRIVATE_IP}" to any port 6379 proto tcp comment 'Redis <- app-01' +fi + +ufw --force enable +ufw status verbose + +# --- 10. Verifications finales ---------------------------------------------- +log "Verifications" +echo " SSH root login : $(sshd -T 2>/dev/null | grep -i '^permitrootlogin' || echo '?')" +echo " Password auth : $(sshd -T 2>/dev/null | grep -i '^passwordauthentication' || echo '?')" +echo " fail2ban : $(systemctl is-active fail2ban)" +echo " unattended-upgr. : $(systemctl is-active unattended-upgrades)" +echo " auditd : $(systemctl is-active auditd)" +echo " chrony : $(systemctl is-active chrony)" + +log "Durcissement commun termine pour le role '$ROLE'." +echo "Etape suivante :" +if [[ "$ROLE" == "app" ]]; then + echo " sudo bash 02-setup-k3s-server.sh" +else + echo " sudo bash 01-setup-data-node.sh" +fi diff --git a/infra/prod/scripts/01-setup-data-node.sh b/infra/prod/scripts/01-setup-data-node.sh new file mode 100755 index 0000000..505dc3e --- /dev/null +++ b/infra/prod/scripts/01-setup-data-node.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# ============================================================================= +# 01 - Installation du noeud de donnees (db-01) +# ============================================================================= +# Prerequis : 00-bootstrap-common.sh data deja execute sur ce serveur. +# +# sudo bash 01-setup-data-node.sh +# +# Ce script : +# 1. monte le volume Hetzner sur /var/lib/xpeditis/pgdata +# 2. installe Docker Engine depuis le depot officiel +# 3. genere le certificat TLS interne de PostgreSQL +# 4. installe age (chiffrement des dumps) +# 5. installe les unites systemd de sauvegarde +# +# Il ne demarre PAS la base : il faut d'abord deposer .env.data (SOPS). +set -euo pipefail + +[[ "$EUID" -eq 0 ]] || { echo "Executer en root (sudo)." >&2; exit 1; } + +APP_PRIVATE_IP="${APP_PRIVATE_IP:-10.10.1.10}" +DB_PRIVATE_IP="${DB_PRIVATE_IP:-10.10.1.20}" +BASE_DIR=/opt/xpeditis/data-node +DATA_ROOT=/var/lib/xpeditis + +log() { printf '\n>>> %s\n' "$*"; } + +# --- 1. Volume de donnees ---------------------------------------------------- +log "Montage du volume PostgreSQL" +mkdir -p "${DATA_ROOT}"/{pgdata,redis,certs,dumps} + +# Hetzner expose les volumes sous /dev/disk/by-id/scsi-0HC_Volume_. +VOLUME_DEV="$(ls /dev/disk/by-id/scsi-0HC_Volume_* 2>/dev/null | head -1 || true)" + +if [[ -n "$VOLUME_DEV" ]]; then + if ! blkid "$VOLUME_DEV" >/dev/null 2>&1; then + log "Formatage de $VOLUME_DEV en ext4 (volume vierge)" + mkfs.ext4 -F -L xpeditis-pgdata "$VOLUME_DEV" + else + log "Volume deja formate, conservation des donnees existantes" + fi + + if ! grep -q "${DATA_ROOT}/pgdata" /etc/fstab; then + # `nofail` : si le volume est absent au boot, le serveur demarre quand meme + # et on diagnostique en SSH plutot que de se retrouver hors ligne. + echo "${VOLUME_DEV} ${DATA_ROOT}/pgdata ext4 discard,nofail,defaults 0 2" >> /etc/fstab + fi + mount -a + df -h "${DATA_ROOT}/pgdata" +else + echo "AVERTISSEMENT: aucun volume Hetzner detecte. Les donnees resteront sur le disque systeme." +fi + +# L'UID/GID 999 est celui de l'utilisateur postgres dans l'image officielle. +chown -R 999:999 "${DATA_ROOT}/pgdata" "${DATA_ROOT}/redis" "${DATA_ROOT}/dumps" +chmod 700 "${DATA_ROOT}/pgdata" + +# --- 2. Docker Engine -------------------------------------------------------- +if ! command -v docker >/dev/null; then + log "Installation de Docker Engine (depot officiel)" + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ +https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + > /etc/apt/sources.list.d/docker.list + apt-get update -qq + apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +fi + +log "Durcissement du demon Docker" +cat > /etc/docker/daemon.json <<'JSON' +{ + "log-driver": "json-file", + "log-opts": { "max-size": "50m", "max-file": "5" }, + "live-restore": true, + "userland-proxy": false, + "no-new-privileges": true, + "icc": false, + "default-address-pools": [ + { "base": "172.28.0.0/16", "size": 24 } + ] +} +JSON +systemctl enable --now docker +systemctl restart docker + +# --- 3. Certificat TLS de PostgreSQL ---------------------------------------- +# Certificat auto-signe : PostgreSQL n'est joignable que depuis app-01 sur un +# reseau prive, il n'y a pas de tiers a authentifier. Ce certificat sert a +# CHIFFRER le transport, pas a prouver une identite publique. +# Cote client, DATABASE_SSL=true avec rejectUnauthorized:false accepte ce +# certificat : c'est coherent, et documente dans 04-noeud-donnees.md. +if [[ ! -f "${DATA_ROOT}/certs/server.key" ]]; then + log "Generation du certificat TLS PostgreSQL (10 ans)" + openssl req -new -x509 -days 3650 -nodes \ + -newkey rsa:4096 \ + -out "${DATA_ROOT}/certs/server.crt" \ + -keyout "${DATA_ROOT}/certs/server.key" \ + -subj "/CN=xpeditis-prod-db-01/O=Xpeditis/C=FR" \ + -addext "subjectAltName=IP:${DB_PRIVATE_IP},DNS:postgres" + # PostgreSQL refuse de demarrer si la cle privee est lisible par d'autres. + chown 999:999 "${DATA_ROOT}/certs/server.key" "${DATA_ROOT}/certs/server.crt" + chmod 600 "${DATA_ROOT}/certs/server.key" + chmod 644 "${DATA_ROOT}/certs/server.crt" +else + log "Certificat TLS deja present, conserve" +fi + +# --- 4. age (chiffrement des dumps) ----------------------------------------- +if ! command -v age >/dev/null; then + log "Installation de age" + apt-get install -y -qq age +fi +mkdir -p /root/.config/xpeditis +chmod 700 /root/.config/xpeditis + +# --- 5. Arborescence applicative -------------------------------------------- +log "Preparation de ${BASE_DIR}" +mkdir -p "${BASE_DIR}"/{conf,backup} +chmod 750 "${BASE_DIR}" + +# --- 6. Unites systemd de sauvegarde ---------------------------------------- +if [[ -f "${BASE_DIR}/backup/xpeditis-backup.service" ]]; then + log "Installation des timers de sauvegarde" + install -m 0644 "${BASE_DIR}/backup/xpeditis-backup.service" /etc/systemd/system/ + install -m 0644 "${BASE_DIR}/backup/xpeditis-backup.timer" /etc/systemd/system/ + install -m 0644 "${BASE_DIR}/backup/xpeditis-backup-verify.service" /etc/systemd/system/ + install -m 0644 "${BASE_DIR}/backup/xpeditis-backup-verify.timer" /etc/systemd/system/ + chmod +x "${BASE_DIR}/backup/"*.sh + systemctl daemon-reload + systemctl enable --now xpeditis-backup.timer xpeditis-backup-verify.timer + systemctl list-timers 'xpeditis-*' --no-pager +else + echo "AVERTISSEMENT: ${BASE_DIR}/backup/ vide. Copiez infra/prod/data-node/ puis relancez ce script." +fi + +# --- 7. Rappel du filtrage reseau ------------------------------------------- +log "Regles UFW effectives" +ufw status numbered + +cat < 'sudo rsync -a /tmp/data-node/ ${BASE_DIR}/' + + 2. Dechiffrer et deposer les secrets : + sops -d --input-type dotenv --output-type dotenv \\ + infra/prod/data-node/data-node.sops.env > /tmp/.env.data + scp /tmp/.env.data deploy@:/tmp/ + ssh deploy@ 'sudo install -m600 -o root -g root /tmp/.env.data ${BASE_DIR}/.env.data && shred -u /tmp/.env.data' + + 3. Demarrer : + cd ${BASE_DIR} && sudo docker compose -f docker-compose.data.yml --env-file .env.data up -d --build + + 4. Premiere sauvegarde complete (obligatoire avant d'ouvrir au public) : + sudo systemctl start xpeditis-backup.service + sudo journalctl -u xpeditis-backup -f + + Verifier que rien n'est expose publiquement, depuis un autre reseau : + nmap -Pn -p 5432,6379 # doit repondre filtered +============================================================================= +NEXT diff --git a/infra/prod/scripts/02-setup-k3s-server.sh b/infra/prod/scripts/02-setup-k3s-server.sh new file mode 100755 index 0000000..6940438 --- /dev/null +++ b/infra/prod/scripts/02-setup-k3s-server.sh @@ -0,0 +1,245 @@ +#!/usr/bin/env bash +# ============================================================================= +# 02 - Installation du cluster k3s (app-01) +# ============================================================================= +# Prerequis : 00-bootstrap-common.sh app deja execute sur ce serveur. +# +# sudo K3S_VERSION=v1.31.5+k3s1 PUBLIC_IP= bash 02-setup-k3s-server.sh +# +# Choix structurants et pourquoi : +# --secrets-encryption les Secrets sont chiffres au repos dans la base +# d'etat de k3s. Sans ce drapeau, un acces disque +# (snapshot, vol de volume) livre tous les secrets. +# --protect-kernel-defaults refuse de demarrer si les sysctl attendus par +# kubelet ne sont pas poses : echec bruyant plutot +# que derive silencieuse. +# audit-log journal d'audit de l'API : indispensable pour +# repondre a "qui a supprime ce deploiement". +# Traefik est CONSERVE (celui livre par k3s) et reconfigure via +# HelmChartConfig : entrypoints, en-tetes de +# securite, IPs de confiance Cloudflare. +set -euo pipefail + +[[ "$EUID" -eq 0 ]] || { echo "Executer en root (sudo)." >&2; exit 1; } + +K3S_VERSION="${K3S_VERSION:-v1.31.5+k3s1}" +PUBLIC_IP="${PUBLIC_IP:-$(curl -s --max-time 5 https://ifconfig.me || true)}" +PRIVATE_IP="${PRIVATE_IP:-10.10.1.10}" +MANIFEST_DIR=/var/lib/rancher/k3s/server/manifests + +log() { printf '\n>>> %s\n' "$*"; } + +[[ -n "$PUBLIC_IP" ]] || { echo "PUBLIC_IP introuvable, passez-la en variable." >&2; exit 1; } + +# --- 1. Prerequis noyau ------------------------------------------------------ +log "Parametres noyau exiges par kubelet (--protect-kernel-defaults)" +cat > /etc/sysctl.d/90-kubelet.conf <<'CONF' +vm.panic_on_oom=0 +vm.overcommit_memory=1 +kernel.panic=10 +kernel.panic_on_oops=1 +CONF +sysctl --system >/dev/null + +log "Desactivation du swap (exige par kubelet)" +swapoff -a || true +sed -i '/\sswap\s/s/^/#/' /etc/fstab || true + +# --- 2. Politique d'audit de l'API Kubernetes ------------------------------- +log "Politique d'audit" +mkdir -p /var/lib/rancher/k3s/server /var/log/k3s +cat > /var/lib/rancher/k3s/server/audit-policy.yaml <<'YAML' +apiVersion: audit.k8s.io/v1 +kind: Policy +# Ne jamais journaliser le contenu des Secrets : le journal d'audit deviendrait +# lui-meme un coffre-fort en clair. +omitStages: + - RequestReceived +rules: + - level: None + resources: + - group: "" + resources: ["secrets", "configmaps"] + verbs: ["get", "list", "watch"] + + # Bruit de fond : sondes et lectures d'etat. + - level: None + users: ["system:kube-proxy", "system:apiserver"] + verbs: ["watch", "list"] + - level: None + nonResourceURLs: ["/healthz*", "/readyz*", "/livez*", "/version", "/metrics"] + + # Toute ecriture est tracee avec ses metadonnees (qui, quoi, quand). + - level: Metadata + verbs: ["create", "update", "patch", "delete", "deletecollection"] + + # Acces aux Secrets : trace, sans le contenu. + - level: Metadata + resources: + - group: "" + resources: ["secrets"] + + # Escalades de privileges : trace complet cote requete. + - level: Request + resources: + - group: "rbac.authorization.k8s.io" + resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"] + + - level: Metadata +YAML +chmod 600 /var/lib/rancher/k3s/server/audit-policy.yaml + +# --- 3. Configuration Traefik (avant l'installation : k3s l'applique au boot) - +log "Configuration Traefik (HelmChartConfig)" +mkdir -p "$MANIFEST_DIR" +cat > "${MANIFEST_DIR}/traefik-config.yaml" <<'YAML' +apiVersion: helm.cattle.io/v1 +kind: HelmChartConfig +metadata: + name: traefik + namespace: kube-system +spec: + valuesContent: |- + # Les vraies IP des visiteurs arrivent dans X-Forwarded-For, pose par + # Cloudflare. Sans cette liste de confiance, la limitation de debit et les + # audit_logs verraient tous l'IP de Cloudflare : inexploitable. + # Rafraichir avec scripts/refresh-cloudflare-ips.sh. + additionalArguments: + - "--entrypoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,2400:cb00::/32,2606:4700::/32,2803:f800::/32,2405:b500::/32,2405:8100::/32,2a06:98c0::/29,2c0f:f248::/32" + - "--entrypoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,2400:cb00::/32,2606:4700::/32,2803:f800::/32,2405:b500::/32,2405:8100::/32,2a06:98c0::/29,2c0f:f248::/32" + # Tout le trafic HTTP est redirige en HTTPS au niveau de l'entrypoint : + # aucun Ingress ne peut oublier de le faire. + - "--entrypoints.web.http.redirections.entryPoint.to=websecure" + - "--entrypoints.web.http.redirections.entryPoint.scheme=https" + - "--entrypoints.web.http.redirections.entryPoint.permanent=true" + # Delais de garde : borne les connexions lentes (slowloris). + - "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=60s" + - "--entrypoints.websecure.transport.respondingTimeouts.writeTimeout=0s" + - "--entrypoints.websecure.transport.respondingTimeouts.idleTimeout=180s" + - "--serversTransport.maxIdleConnsPerHost=100" + # WebSocket (Socket.IO) : pas de timeout d'ecriture, sinon les + # notifications temps reel sont coupees toutes les 60 s. + - "--metrics.prometheus=true" + - "--metrics.prometheus.addEntryPointsLabels=true" + - "--metrics.prometheus.addServicesLabels=true" + - "--accesslog=true" + - "--accesslog.format=json" + - "--accesslog.fields.headers.defaultMode=drop" + - "--accesslog.fields.headers.names.User-Agent=keep" + - "--accesslog.fields.headers.names.Cf-Connecting-Ip=keep" + - "--ping=true" + + # Le tableau de bord Traefik n'est jamais expose. + ingressRoute: + dashboard: + enabled: false + + logs: + general: + level: WARN + access: + enabled: true + + # Le service est en LoadBalancer (klipper-lb de k3s) : il ecoute + # directement sur les ports 80/443 de l'hote, filtres par le firewall + # Hetzner qui n'accepte que les IP Cloudflare. + service: + spec: + externalTrafficPolicy: Local + + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + memory: 512Mi +YAML + +# --- 4. Installation de k3s -------------------------------------------------- +if ! command -v k3s >/dev/null; then + log "Installation de k3s ${K3S_VERSION}" + curl -sfL https://get.k3s.io | \ + INSTALL_K3S_VERSION="${K3S_VERSION}" \ + INSTALL_K3S_EXEC="server \ + --node-name=xpeditis-prod-app-01 \ + --node-ip=${PRIVATE_IP} \ + --advertise-address=${PRIVATE_IP} \ + --tls-san=${PUBLIC_IP} \ + --tls-san=${PRIVATE_IP} \ + --write-kubeconfig-mode=0600 \ + --secrets-encryption \ + --protect-kernel-defaults \ + --kube-apiserver-arg=audit-log-path=/var/log/k3s/audit.log \ + --kube-apiserver-arg=audit-policy-file=/var/lib/rancher/k3s/server/audit-policy.yaml \ + --kube-apiserver-arg=audit-log-maxage=30 \ + --kube-apiserver-arg=audit-log-maxbackup=10 \ + --kube-apiserver-arg=audit-log-maxsize=100 \ + --kube-apiserver-arg=request-timeout=300s \ + --kubelet-arg=streaming-connection-idle-timeout=5m \ + --kubelet-arg=event-qps=0 \ + --etcd-expose-metrics=false" \ + sh - +else + log "k3s deja installe : $(k3s --version | head -1)" +fi + +systemctl enable --now k3s +log "Attente de la disponibilite du noeud" +for _ in $(seq 1 60); do + k3s kubectl get node >/dev/null 2>&1 && break + sleep 5 +done +k3s kubectl get node -o wide + +# --- 5. kubectl pour l'utilisateur deploy ----------------------------------- +log "Configuration de kubectl pour l'utilisateur deploy" +DEPLOY_USER="${DEPLOY_USER:-deploy}" +install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "/home/${DEPLOY_USER}/.kube" +install -m 600 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \ + /etc/rancher/k3s/k3s.yaml "/home/${DEPLOY_USER}/.kube/config" +grep -q 'KUBECONFIG' "/home/${DEPLOY_USER}/.bashrc" || \ + echo 'export KUBECONFIG=$HOME/.kube/config' >> "/home/${DEPLOY_USER}/.bashrc" + +# `k3s kubectl` reste reserve a root ; deploy passe par son kubeconfig. +ln -sf /usr/local/bin/k3s /usr/local/bin/kubectl + +# --- 6. Rotation des journaux d'audit --------------------------------------- +cat > /etc/logrotate.d/k3s-audit <<'CONF' +/var/log/k3s/audit.log { + daily + rotate 30 + compress + delaycompress + missingok + notifempty + copytruncate + su root root +} +CONF + +# --- 7. Verifications -------------------------------------------------------- +log "Etat du cluster" +k3s kubectl get nodes +k3s kubectl -n kube-system get pods + +cat < ~/.kube/xpeditis-prod.yaml + chmod 600 ~/.kube/xpeditis-prod.yaml + export KUBECONFIG=~/.kube/xpeditis-prod.yaml + kubectl get nodes + + Etape suivante : + sudo bash 03-install-cluster-addons.sh +============================================================================= +NEXT diff --git a/infra/prod/scripts/03-install-cluster-addons.sh b/infra/prod/scripts/03-install-cluster-addons.sh new file mode 100755 index 0000000..3d3f1f0 --- /dev/null +++ b/infra/prod/scripts/03-install-cluster-addons.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# ============================================================================= +# 03 - Composants du cluster (cert-manager, namespaces, acces registre) +# ============================================================================= +# A executer depuis VOTRE POSTE, kubeconfig de prod charge : +# +# export KUBECONFIG=~/.kube/xpeditis-prod.yaml +# REGISTRY_TOKEN=... bash 03-install-cluster-addons.sh +# +# cert-manager est installe depuis son manifeste statique officiel : pas de +# Helm a maintenir sur le cluster, une seule version epinglee, un seul fichier +# a relire en cas de doute. +set -euo pipefail + +CERT_MANAGER_VERSION="${CERT_MANAGER_VERSION:-v1.16.2}" +REGISTRY_SERVER="${REGISTRY_SERVER:-rg.fr-par.scw.cloud}" +REGISTRY_TOKEN="${REGISTRY_TOKEN:-}" +NAMESPACE="${NAMESPACE:-xpeditis-prod}" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +K8S_DIR="${HERE}/../k8s" + +log() { printf '\n>>> %s\n' "$*"; } + +kubectl version --output=yaml >/dev/null || { echo "kubectl ne joint pas le cluster." >&2; exit 1; } + +# --- 1. Namespaces ----------------------------------------------------------- +log "Namespaces" +kubectl apply -f "${K8S_DIR}/base/00-namespaces.yaml" + +# --- 2. cert-manager --------------------------------------------------------- +if ! kubectl get ns cert-manager >/dev/null 2>&1; then + log "Installation de cert-manager ${CERT_MANAGER_VERSION}" + kubectl apply -f \ + "https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml" +else + log "cert-manager deja present" +fi + +log "Attente de cert-manager" +kubectl -n cert-manager rollout status deploy/cert-manager --timeout=180s +kubectl -n cert-manager rollout status deploy/cert-manager-webhook --timeout=180s +kubectl -n cert-manager rollout status deploy/cert-manager-cainjector --timeout=180s + +# --- 3. Acces au registre Scaleway ------------------------------------------ +# Le registre est prive : sans ce Secret, les pods restent en ImagePullBackOff. +if [[ -n "$REGISTRY_TOKEN" ]]; then + log "Secret d'acces au registre (${REGISTRY_SERVER})" + kubectl -n "$NAMESPACE" create secret docker-registry regcred \ + --docker-server="$REGISTRY_SERVER" \ + --docker-username=nologin \ + --docker-password="$REGISTRY_TOKEN" \ + --dry-run=client -o yaml | kubectl apply -f - +else + echo "AVERTISSEMENT: REGISTRY_TOKEN absent. Creez 'regcred' avant de deployer :" + echo " kubectl -n ${NAMESPACE} create secret docker-registry regcred \\" + echo " --docker-server=${REGISTRY_SERVER} --docker-username=nologin --docker-password=" +fi + +# --- 4. Emetteur ACME -------------------------------------------------------- +# Le ClusterIssuer depend d'un Secret contenant le token API Cloudflare : +# il est chiffre SOPS et doit etre applique avant (secrets-apply.sh). +if kubectl -n cert-manager get secret cloudflare-api-token >/dev/null 2>&1; then + log "ClusterIssuer Let's Encrypt (DNS-01 Cloudflare)" + kubectl apply -f "${K8S_DIR}/cluster/cluster-issuer.yaml" +else + echo "AVERTISSEMENT: le Secret cert-manager/cloudflare-api-token est absent." + echo " Appliquez d'abord les secrets : bash scripts/secrets-apply.sh" + echo " puis : kubectl apply -f ${K8S_DIR}/cluster/cluster-issuer.yaml" +fi + +# --- 5. Verifications -------------------------------------------------------- +log "Etat" +kubectl get ns +kubectl -n cert-manager get pods +kubectl get clusterissuer 2>/dev/null || true + +cat < cluster + 2. bash scripts/deploy.sh # premier deploiement +============================================================================= +NEXT diff --git a/infra/prod/scripts/deploy-monitoring.sh b/infra/prod/scripts/deploy-monitoring.sh new file mode 100755 index 0000000..8f9c9fe --- /dev/null +++ b/infra/prod/scripts/deploy-monitoring.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# ============================================================================= +# Deploiement de la pile d'observabilite +# ============================================================================= +# export KUBECONFIG=~/.kube/xpeditis-prod.yaml +# bash scripts/deploy-monitoring.sh +# +# Loki (journaux) + Promtail (collecte) + Prometheus (metriques) + +# Alertmanager (Discord) + Grafana (consultation). +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +K8S_DIR="${HERE}/../k8s" +REPO_ROOT="$(cd "${HERE}/../../.." && pwd)" + +log() { printf '\n>>> %s\n' "$*"; } + +kubectl get ns monitoring >/dev/null 2>&1 || kubectl apply -f "${K8S_DIR}/base/00-namespaces.yaml" + +kubectl -n monitoring get secret grafana-admin >/dev/null 2>&1 \ + || { echo "Secret grafana-admin absent : lancez d'abord scripts/secrets-apply.sh" >&2; exit 1; } +kubectl -n monitoring get secret alertmanager-secrets >/dev/null 2>&1 \ + || { echo "Secret alertmanager-secrets absent : lancez d'abord scripts/secrets-apply.sh" >&2; exit 1; } + +# --- Tableaux de bord -------------------------------------------------------- +# Reutilise les tableaux de bord deja ecrits pour la preprod plutot que d'en +# maintenir un second jeu. +DASHBOARD_SRC="${REPO_ROOT}/infra/logging/grafana/provisioning/dashboards" +if [[ -d "$DASHBOARD_SRC" ]]; then + log "Tableaux de bord depuis ${DASHBOARD_SRC}" + kubectl -n monitoring create configmap grafana-dashboards \ + $(find "$DASHBOARD_SRC" -name '*.json' -exec printf -- '--from-file=%s ' {} +) \ + --dry-run=client -o yaml | kubectl apply -f - +else + echo "AVERTISSEMENT: aucun tableau de bord trouve dans ${DASHBOARD_SRC}" +fi + +log "Loki" +kubectl apply -f "${K8S_DIR}/monitoring/01-loki.yaml" +log "Promtail" +kubectl apply -f "${K8S_DIR}/monitoring/02-promtail.yaml" +log "Prometheus" +kubectl apply -f "${K8S_DIR}/monitoring/03-prometheus.yaml" +log "node-exporter" +kubectl apply -f "${K8S_DIR}/monitoring/04-node-exporter.yaml" +log "Alertmanager" +kubectl apply -f "${K8S_DIR}/monitoring/05-alertmanager.yaml" +log "Grafana" +kubectl apply -f "${K8S_DIR}/monitoring/06-grafana.yaml" + +log "Politiques reseau du namespace monitoring" +kubectl apply -f "${K8S_DIR}/base/10-network-policies.yaml" +kubectl apply -f "${K8S_DIR}/base/08-traefik-middlewares.yaml" + +log "Attente du demarrage" +kubectl -n monitoring rollout status deploy/loki --timeout=300s +kubectl -n monitoring rollout status deploy/prometheus --timeout=300s +kubectl -n monitoring rollout status deploy/alertmanager --timeout=180s +kubectl -n monitoring rollout status deploy/grafana --timeout=300s + +log "Etat" +kubectl -n monitoring get pods,svc,pvc + +cat <<'NEXT' + +Grafana : https://grafana.xpeditis.com + Identifiants : Secret monitoring/grafana-admin + L'acces est filtre par IP (middleware monitoring-admin-ip-allowlist). + Adaptez la liste dans k8s/base/08-traefik-middlewares.yaml, sinon vous + obtiendrez un 403 depuis votre poste. + +Verifier que les alertes partent bien, AVANT d'en avoir besoin : + kubectl -n monitoring port-forward svc/alertmanager 9093:9093 + curl -XPOST http://localhost:9093/api/v2/alerts -H 'Content-Type: application/json' \ + -d '[{"labels":{"alertname":"TestDeRoutage","severity":"avertissement"}}]' +NEXT diff --git a/infra/prod/scripts/deploy.sh b/infra/prod/scripts/deploy.sh new file mode 100755 index 0000000..5de6ea9 --- /dev/null +++ b/infra/prod/scripts/deploy.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# ============================================================================= +# Deploiement d'une version en production +# ============================================================================= +# export KUBECONFIG=~/.kube/xpeditis-prod.yaml +# bash scripts/deploy.sh prod-a1b2c3d +# +# Sequence : +# 1. verification que les images existent dans le registre +# 2. Job de migration (parallelisme 1) -- bloquant +# 3. mise a jour des images backend et frontend +# 4. attente du deploiement complet +# 5. tests de fumee sur les URLs publiques +# 6. retour arriere automatique si l'une des etapes echoue +# +# C'est la meme sequence que cd-main.yml : ce script est le chemin manuel de +# secours quand GitHub Actions est indisponible. +set -euo pipefail + +TAG="${1:?usage: $0 ex: prod-a1b2c3d}" +NAMESPACE="${NAMESPACE:-xpeditis-prod}" +REGISTRY="${REGISTRY:-rg.fr-par.scw.cloud/weworkstudio}" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +K8S_DIR="${HERE}/../k8s" + +log() { printf '\n>>> %s\n' "$*"; } +fail() { printf '\nECHEC: %s\n' "$*" >&2; exit 1; } + +rollback() { + log "RETOUR ARRIERE" + kubectl -n "$NAMESPACE" rollout undo deploy/xpeditis-backend || true + kubectl -n "$NAMESPACE" rollout undo deploy/xpeditis-frontend || true + kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-backend --timeout=180s || true + kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-frontend --timeout=180s || true + cat >&2 <<'MSG' + +Les deploiements sont revenus a la version precedente. + +ATTENTION : les MIGRATIONS DE BASE, elles, ne sont pas annulees. Si la version +retiree contenait une migration destructrice (colonne supprimee, type change), +l'ancienne version applicative peut ne plus fonctionner contre le schema +courant. Voir docs/mise-en-prod/15-exploitation-incidents.md, "Retour arriere +avec migration". +MSG + exit 1 +} + +# --- 0. Preconditions -------------------------------------------------------- +kubectl get ns "$NAMESPACE" >/dev/null || fail "namespace $NAMESPACE introuvable" +kubectl -n "$NAMESPACE" get secret regcred >/dev/null \ + || fail "secret 'regcred' absent : les images ne pourront pas etre telechargees" +kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets >/dev/null \ + || fail "secrets applicatifs absents : lancez scripts/secrets-apply.sh" + +BACKEND_IMAGE="${REGISTRY}/xpeditis-backend:${TAG}" +FRONTEND_IMAGE="${REGISTRY}/xpeditis-frontend:${TAG}" +EXPORTER_IMAGE="${REGISTRY}/xpeditis-log-exporter:${TAG}" + +log "Version deployee : ${TAG}" +kubectl -n "$NAMESPACE" get deploy -o wide + +# --- 1. Configuration -------------------------------------------------------- +log "Application de la configuration (ConfigMap, Ingress, politiques)" +kubectl apply -f "${K8S_DIR}/base/00-namespaces.yaml" +kubectl apply -f "${K8S_DIR}/base/01-limits.yaml" +kubectl apply -f "${K8S_DIR}/base/02-configmap-backend.yaml" +kubectl apply -f "${K8S_DIR}/base/08-traefik-middlewares.yaml" +kubectl apply -f "${K8S_DIR}/base/10-network-policies.yaml" +kubectl apply -f "${K8S_DIR}/base/11-certificate.yaml" +kubectl apply -f "${K8S_DIR}/base/09-ingress.yaml" + +# Empreinte de la configuration : sans elle, un changement de ConfigMap ne +# provoque aucun redemarrage et reste sans effet jusqu'au deploiement suivant. +CONFIG_SUM="$(kubectl -n "$NAMESPACE" get cm xpeditis-backend-config -o yaml \ + | sha256sum | cut -c1-16)" + +# --- 2. Migrations ----------------------------------------------------------- +JOB_NAME="xpeditis-migrate-${TAG//[^a-z0-9-]/-}" +log "Migrations de base (Job ${JOB_NAME})" + +kubectl -n "$NAMESPACE" delete job "$JOB_NAME" --ignore-not-found >/dev/null + +sed -e "s|__IMAGE_TAG__|${TAG}|g" "${K8S_DIR}/base/07-migration-job.yaml" \ + | kubectl apply -f - + +if ! kubectl -n "$NAMESPACE" wait --for=condition=complete "job/${JOB_NAME}" --timeout=900s; then + echo "--- journaux du Job de migration ---" >&2 + kubectl -n "$NAMESPACE" logs "job/${JOB_NAME}" --tail=200 >&2 || true + fail "les migrations ont echoue : AUCUNE image n'a ete deployee, la production tourne toujours sur la version precedente" +fi +kubectl -n "$NAMESPACE" logs "job/${JOB_NAME}" --tail=50 + +# --- 3. Deploiement ---------------------------------------------------------- +log "Mise a jour du backend" +kubectl -n "$NAMESPACE" patch deploy xpeditis-backend --type=strategic -p \ + "{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"xpeditis.com/config-checksum\":\"${CONFIG_SUM}\"}}}}}" +kubectl -n "$NAMESPACE" set image deploy/xpeditis-backend "backend=${BACKEND_IMAGE}" +kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-backend --timeout=300s || rollback + +log "Mise a jour du frontend" +kubectl -n "$NAMESPACE" set image deploy/xpeditis-frontend "frontend=${FRONTEND_IMAGE}" +kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-frontend --timeout=300s || rollback + +# Le collecteur de logs n'est pas critique : son echec ne doit pas declencher +# un retour arriere de l'application. +log "Mise a jour du log-exporter" +kubectl -n "$NAMESPACE" set image deploy/xpeditis-log-exporter "log-exporter=${EXPORTER_IMAGE}" || true +kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-log-exporter --timeout=180s \ + || log "AVERTISSEMENT: le log-exporter n'a pas demarre (non bloquant)" + +# --- 4. Tests de fumee ------------------------------------------------------- +log "Tests de fumee" +if ! bash "${HERE}/smoke-test.sh"; then + rollback +fi + +# --- 5. Compte rendu --------------------------------------------------------- +log "Deploiement termine" +kubectl -n "$NAMESPACE" get pods -o wide +echo +echo "Backend : ${BACKEND_IMAGE}" +echo "Frontend : ${FRONTEND_IMAGE}" +echo +echo "Retour arriere manuel si necessaire :" +echo " kubectl -n ${NAMESPACE} rollout undo deploy/xpeditis-backend" +echo " kubectl -n ${NAMESPACE} rollout undo deploy/xpeditis-frontend" diff --git a/infra/prod/scripts/harden-seed-data.sh b/infra/prod/scripts/harden-seed-data.sh new file mode 100755 index 0000000..63feb62 --- /dev/null +++ b/infra/prod/scripts/harden-seed-data.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# ============================================================================= +# Neutralisation des comptes de demonstration -- OUTIL DE SECOURS ET D'AUDIT +# ============================================================================= +# +# LE CAS NOMINAL N'A PLUS BESOIN DE CE SCRIPT. +# +# Le traitement est desormais fait par les migrations, donc automatiquement et +# sans risque d'oubli : +# +# 1730000000007-SeedTestUsers ne s'execute plus si +# NODE_ENV=production +# 1756000000000-NeutralizeSeedAccountsInProduction filet de securite +# 1756000000001-BootstrapAdminFromEnv cree VOTRE administrateur +# +# Ce script reste utile dans trois situations : +# +# - une base de production migree AVANT l'ajout de la garde NODE_ENV ; +# - un deploiement ou NODE_ENV n'etait pas correctement positionne (le journal +# du Job de migration affiche alors "Seeded test users successfully") ; +# - une verification manuelle : il affiche l'etat des comptes sans rien +# changer s'il n'y a rien a changer. +# +# ssh deploy@ +# sudo bash /opt/xpeditis/infra-prod/scripts/harden-seed-data.sh +# +# RAPPEL DU PROBLEME +# +# La migration 1730000000007-SeedTestUsers cree trois comptes dont le mot de +# passe est ecrit en clair dans le depot : +# +# admin@xpeditis.com role ADMIN Password123! +# manager@xpeditis.com role MANAGER Password123! +# user@xpeditis.com role USER Password123! +# +# Sur une base de production, cela donne un acces complet a la plateforme a +# quiconque a lu le depot. +# +# Ce script ne SUPPRIME pas les lignes (des cles etrangeres peuvent y pointer, +# et une suppression en cascade dans audit_logs serait pire). Il : +# 1. renomme les adresses vers un domaine invalide -- ce qui libere au passage +# admin@xpeditis.com pour votre vrai compte ; +# 2. remplace le mot de passe par une valeur aleatoire inutilisable ; +# 3. desactive les comptes (is_active = false). +# +# Idempotent : peut etre relance sans risque. +set -euo pipefail + +COMPOSE_DIR="${COMPOSE_DIR:-/opt/xpeditis/data-node}" +ENV_FILE="${ENV_FILE:-${COMPOSE_DIR}/.env.data}" + +[[ -f "$ENV_FILE" ]] || { echo "Fichier d'environnement introuvable : $ENV_FILE" >&2; exit 1; } +# shellcheck disable=SC1090 +set -a; source "$ENV_FILE"; set +a + +psql_run() { + docker compose -f "${COMPOSE_DIR}/docker-compose.data.yml" --env-file "$ENV_FILE" \ + exec -T -u postgres postgres psql -v ON_ERROR_STOP=1 -d "$POSTGRES_DB" "$@" +} + +echo ">>> Etat avant intervention" +psql_run -c " + SELECT email, role, is_active + FROM users + WHERE email IN ('admin@xpeditis.com','manager@xpeditis.com','user@xpeditis.com'); +" + +echo +echo ">>> Neutralisation" +psql_run <<'SQL' +BEGIN; + +-- Mot de passe remplace par une valeur aleatoire : le format reste un hash +-- Argon2 valide en apparence, mais aucun mot de passe ne peut y correspondre. +UPDATE users +SET + email = 'seed-desactive-' || substr(id::text, 1, 8) || '@invalid.local', + -- md5(random()) plutot que gen_random_bytes : pas besoin de l'extension + -- pgcrypto, qui n'est pas installee sur cette base. + password_hash = '$argon2id$v=19$m=65536,t=3,p=4$' || md5(random()::text) + || '$' || md5(random()::text) || md5(clock_timestamp()::text), + is_active = false, + updated_at = NOW() +WHERE email IN ('admin@xpeditis.com', 'manager@xpeditis.com', 'user@xpeditis.com'); + +COMMIT; +SQL + +echo +echo ">>> Etat apres intervention" +psql_run -c " + SELECT email, role, is_active + FROM users + WHERE email LIKE 'seed-desactive-%@invalid.local'; +" + +echo +echo ">>> Controle : aucun compte de demonstration ne doit subsister" +RESTE=$(psql_run -tAc " + SELECT count(*) FROM users + WHERE email IN ('admin@xpeditis.com','manager@xpeditis.com','user@xpeditis.com'); +") +if [[ "$RESTE" != "0" ]]; then + echo "ECHEC : ${RESTE} compte(s) de demonstration encore actifs." >&2 + exit 1 +fi +echo "OK : aucun compte de demonstration actif." + +echo +echo ">>> Organisations de demonstration presentes (a examiner, non modifiees)" +# Non supprimees automatiquement : les comptes desactives y sont rattaches, et +# une suppression en cascade toucherait aussi audit_logs. +psql_run -c " + SELECT id, name, created_at + FROM organizations + WHERE name IN ('Test Freight Forwarder Inc.','Demo Shipping Company','Sample Shipper Ltd.'); +" + +cat <<'NEXT' + +----------------------------------------------------------------------------- + Etape suivante : disposer d'un administrateur. + + La migration 1756000000001-BootstrapAdminFromEnv s'en charge normalement, a + partir de BOOTSTRAP_ADMIN_EMAIL (ConfigMap). Si elle a ete ignoree parce qu'un + ADMIN actif existait alors -- typiquement le compte de demonstration que ce + script vient de neutraliser -- relancez simplement le Job de migration : + + kubectl -n xpeditis-prod delete job -l app.kubernetes.io/name=xpeditis-migrate + # puis redeployez, ou rejouez le Job pour le tag courant + + Elle ne rejouera pas les migrations deja appliquees ; pour forcer uniquement + l'amorcage, creez votre compte a la main : + + 1. Inscrivez-vous sur https://app.xpeditis.com/fr/register + 2. Promouvez le compte : + UPDATE users SET role = 'ADMIN' WHERE email = ''; + + Dans les deux cas, verifiez qu'il n'existe qu'un seul ADMIN actif : + + SELECT email, role, is_active FROM users WHERE role = 'ADMIN'; + + Puis controlez depuis l'exterieur que l'ancien compte est bien mort : + + curl -s -o /dev/null -w '%{http_code}\n' -X POST \ + https://api.xpeditis.com/api/v1/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"admin@xpeditis.com","password":"Password123!"}' + # Attendu : 401 (et surtout pas 200 ni 201) +----------------------------------------------------------------------------- +NEXT diff --git a/infra/prod/scripts/preflight-check.sh b/infra/prod/scripts/preflight-check.sh new file mode 100755 index 0000000..08d73ad --- /dev/null +++ b/infra/prod/scripts/preflight-check.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# ============================================================================= +# Controle go / no-go avant ouverture au public +# ============================================================================= +# export KUBECONFIG=~/.kube/xpeditis-prod.yaml +# bash scripts/preflight-check.sh +# +# Chaque point BLOQUANT en echec doit etre corrige avant d'ouvrir le service. +# Les points d'AVERTISSEMENT peuvent etre acceptes consciemment et notes dans +# le registre des risques. +set -uo pipefail + +NAMESPACE=xpeditis-prod +API="${PROD_API_URL:-https://api.xpeditis.com}" +DB_HOST="${DB_PRIVATE_IP:-10.10.1.20}" +DB_PUBLIC_IP="${DB_PUBLIC_IP:-}" + +BLOCK=0 +WARN=0 + +ok() { printf ' \033[32m[OK]\033[0m %s\n' "$1"; } +block() { printf ' \033[31m[BLOQUANT]\033[0m %s\n' "$1"; BLOCK=$((BLOCK+1)); } +warn() { printf ' \033[33m[ATTENTION]\033[0m %s\n' "$1"; WARN=$((WARN+1)); } + +section() { printf '\n\033[1m%s\033[0m\n' "$1"; } + +# --------------------------------------------------------------------------- +section "1. Cluster" + +if kubectl get nodes >/dev/null 2>&1; then + ok "Cluster joignable" + if kubectl get nodes --no-headers | grep -qv ' Ready'; then + block "Un noeud n'est pas Ready" + else + ok "Tous les noeuds sont Ready" + fi +else + block "Cluster injoignable : rien d'autre ne peut etre verifie" +fi + +if kubectl -n "$NAMESPACE" get pods --no-headers 2>/dev/null | grep -qE 'CrashLoop|ImagePull|Error|Pending'; then + block "Des pods ne sont pas sains dans $NAMESPACE" +else + ok "Tous les pods de $NAMESPACE sont sains" +fi + +for d in xpeditis-backend xpeditis-frontend; do + READY=$(kubectl -n "$NAMESPACE" get deploy "$d" -o jsonpath='{.status.readyReplicas}' 2>/dev/null || echo 0) + [[ "${READY:-0}" -ge 2 ]] && ok "$d : ${READY} replicas prets" \ + || warn "$d : ${READY:-0} replica(s) pret(s), 2 attendus (pas de haute disponibilite)" +done + +# --------------------------------------------------------------------------- +section "2. Secrets" + +if kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets >/dev/null 2>&1; then + ok "Secrets applicatifs presents" + JWT=$(kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets -o jsonpath='{.data.JWT_SECRET}' 2>/dev/null | base64 -d 2>/dev/null || echo "") + [[ ${#JWT} -ge 32 ]] && ok "JWT_SECRET fait ${#JWT} caracteres" || block "JWT_SECRET trop court (${#JWT} caracteres, 32 minimum)" + + # Les secrets publies dans infra/preprod/docker-stack.preprod.yml sont dans + # l'historique Git : les retrouver en production serait une compromission. + for compromis in "4C4tQC8qym" "9Lc3M9qoPBeHLKHDXGUf1" "hXiy5GMPswMtxMZujjS2O" "RBJfD0QVXC5JDfAHCwdUW"; do + if kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets -o json 2>/dev/null \ + | grep -q "$(printf '%s' "$compromis" | base64 | cut -c1-12)"; then + block "Un secret de preprod (present dans Git, donc compromis) est reutilise en production" + fi + done + ok "Aucun secret de preprod detecte" + + STRIPE=$(kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets -o jsonpath='{.data.STRIPE_SECRET_KEY}' 2>/dev/null | base64 -d 2>/dev/null || echo "") + case "$STRIPE" in + sk_live_*) ok "Cle Stripe en mode LIVE" ;; + sk_test_*) block "Cle Stripe en mode TEST : aucun paiement reel ne sera encaisse" ;; + *) warn "Cle Stripe absente ou non reconnue" ;; + esac +else + block "Secrets applicatifs absents" +fi + +if kubectl -n "$NAMESPACE" get secret regcred >/dev/null 2>&1; then + ok "Acces au registre configure" +else + block "Secret 'regcred' absent : aucune image ne pourra etre telechargee" +fi + +if sudo k3s secrets-encrypt status 2>/dev/null | grep -qi 'Encryption Status: Enabled'; then + ok "Chiffrement des Secrets au repos actif" +else + warn "Chiffrement des Secrets au repos non verifiable depuis ce poste (a controler sur app-01)" +fi + +# --------------------------------------------------------------------------- +section "3. TLS et exposition" + +CERT=$(kubectl -n "$NAMESPACE" get certificate xpeditis-wildcard -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo "") +[[ "$CERT" == "True" ]] && ok "Certificat wildcard emis" || block "Certificat wildcard non pret" + +ISSUER=$(kubectl -n "$NAMESPACE" get certificate xpeditis-wildcard -o jsonpath='{.spec.issuerRef.name}' 2>/dev/null || echo "") +[[ "$ISSUER" == "letsencrypt-prod" ]] && ok "Emetteur : letsencrypt-prod" \ + || block "Emetteur '$ISSUER' : un certificat de staging n'est pas reconnu par les navigateurs" + +if curl -sSI --max-time 15 "${API}/api/v1/health" 2>/dev/null | grep -qi '^strict-transport-security:'; then + ok "HSTS actif" +else + block "En-tete HSTS absent" +fi + +if [[ -n "$DB_PUBLIC_IP" ]]; then + if command -v nc >/dev/null; then + if nc -z -w3 "$DB_PUBLIC_IP" 5432 2>/dev/null; then + block "PostgreSQL repond sur l'IP PUBLIQUE de db-01" + else + ok "PostgreSQL injoignable publiquement" + fi + if nc -z -w3 "$DB_PUBLIC_IP" 6379 2>/dev/null; then + block "Redis repond sur l'IP PUBLIQUE de db-01" + else + ok "Redis injoignable publiquement" + fi + fi +else + warn "DB_PUBLIC_IP non fournie : exposition de la base non verifiee" +fi + +# --------------------------------------------------------------------------- +section "4. Comptes de demonstration" + +# La migration 1730000000007-SeedTestUsers creait admin@xpeditis.com avec le mot +# de passe "Password123!", ecrit en clair dans le depot. Elle ne s'execute plus +# quand NODE_ENV=production, et 1756000000000 neutralise ces comptes s'ils +# existent malgre tout. +# +# Ce controle verifie le RESULTAT en conditions reelles, pas l'intention : c'est +# le seul moyen de detecter un NODE_ENV mal positionne ou une base restauree +# depuis une sauvegarde anterieure. Le controle le plus important de la liste. +for compte in admin manager user; do + CODE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 \ + -X POST "${API}/api/v1/auth/login" \ + -H 'Content-Type: application/json' \ + -d "{\"email\":\"${compte}@xpeditis.com\",\"password\":\"Password123!\"}" 2>/dev/null || echo "000") + case "$CODE" in + 200|201) block "Le compte de demonstration ${compte}@xpeditis.com accepte encore Password123! (HTTP ${CODE})" ;; + 000) warn "Impossible de tester ${compte}@xpeditis.com (API injoignable)" ;; + *) ok "${compte}@xpeditis.com neutralise (HTTP ${CODE})" ;; + esac +done + +echo " Verifier aussi qu'il n'existe qu'un seul ADMIN actif, sur db-01 :" +echo " SELECT email, role, is_active FROM users WHERE role = 'ADMIN';" + +# --------------------------------------------------------------------------- +section "5. Sauvegardes" + +cat <<'MANUEL' + Ces points se verifient sur db-01, ils ne peuvent pas l'etre d'ici : + + ssh deploy@ + systemctl list-timers 'xpeditis-*' # timers actifs + sudo journalctl -u xpeditis-backup -n 30 # derniere execution + sudo /opt/xpeditis/data-node/backup/pg-restore.sh verify + + Une sauvegarde jamais restauree n'est pas une sauvegarde. Le test de + restauration DOIT avoir ete passe au moins une fois avant l'ouverture. +MANUEL + +# --------------------------------------------------------------------------- +section "6. Fonctionnement" + +if bash "$(dirname "${BASH_SOURCE[0]}")/smoke-test.sh" >/dev/null 2>&1; then + ok "Tests de fumee au vert" +else + block "Tests de fumee en echec (relancer smoke-test.sh pour le detail)" +fi + +# --------------------------------------------------------------------------- +printf '\n=========================================\n' +printf ' Bloquants : %d Avertissements : %d\n' "$BLOCK" "$WARN" +printf '=========================================\n' + +if [[ "$BLOCK" -gt 0 ]]; then + printf '\n\033[31mNO-GO\033[0m : corrigez les points bloquants avant d ouvrir au public.\n' + exit 1 +fi +printf '\n\033[32mGO\033[0m : les controles bloquants sont passes.\n' +[[ "$WARN" -gt 0 ]] && printf 'Consignez les %d avertissement(s) dans le registre des risques.\n' "$WARN" +exit 0 diff --git a/infra/prod/scripts/refresh-cloudflare-ips.sh b/infra/prod/scripts/refresh-cloudflare-ips.sh new file mode 100755 index 0000000..cfde4f6 --- /dev/null +++ b/infra/prod/scripts/refresh-cloudflare-ips.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# ============================================================================= +# Rafraichissement des rangs d'IP Cloudflare +# ============================================================================= +# Cloudflare fait evoluer ses rangs. Deux endroits en dependent : +# - terraform/firewall.tf (qui peut atteindre 80/443 sur app-01) +# - la configuration Traefik (quelles IP ont le droit d'ecrire X-Forwarded-For) +# +# Une liste perimee produit deux pannes distinctes et peu evidentes : +# - du trafic legitime rejete par le firewall Hetzner ; +# - une IP client mal identifiee, donc une limitation de debit qui frappe +# tout le monde et des audit_logs faux. +# +# A relancer tous les trimestres, et systematiquement avant un `terraform apply`. +# +# bash scripts/refresh-cloudflare-ips.sh # compare et affiche +# bash scripts/refresh-cloudflare-ips.sh --write # met a jour firewall.tf +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TF_FILE="${HERE}/../terraform/firewall.tf" +WRITE=false +[[ "${1:-}" == "--write" ]] && WRITE=true + +echo ">>> Recuperation des rangs officiels Cloudflare" +V4="$(curl -fsS --max-time 15 https://www.cloudflare.com/ips-v4)" +V6="$(curl -fsS --max-time 15 https://www.cloudflare.com/ips-v6)" + +[[ -n "$V4" && -n "$V6" ]] || { echo "Reponse vide de Cloudflare, abandon." >&2; exit 1; } + +echo +echo "IPv4 ($(echo "$V4" | wc -l | tr -d ' ') rangs) :" +echo "$V4" | sed 's/^/ /' +echo +echo "IPv6 ($(echo "$V6" | wc -l | tr -d ' ') rangs) :" +echo "$V6" | sed 's/^/ /' + +echo +echo ">>> Rangs actuellement declares dans firewall.tf :" +grep -oE '"[0-9a-f:.]+/[0-9]+"' "$TF_FILE" | tr -d '"' | sort -u | sed 's/^/ /' + +DIFF=$(diff <(printf '%s\n%s\n' "$V4" "$V6" | sort -u) \ + <(grep -oE '"[0-9a-f:.]+/[0-9]+"' "$TF_FILE" | tr -d '"' | sort -u) || true) + +if [[ -z "$DIFF" ]]; then + echo + echo ">>> Aucune difference : rien a faire." + exit 0 +fi + +echo +echo ">>> DIFFERENCES DETECTEES :" +echo "$DIFF" + +if ! $WRITE; then + cat <<'MSG' + +Relancez avec --write pour mettre a jour terraform/firewall.tf, puis : + cd terraform && terraform plan && terraform apply + +Pensez aussi a reporter la liste dans la configuration Traefik : + /var/lib/rancher/k3s/server/manifests/traefik-config.yaml (sur app-01) + puis : sudo systemctl restart k3s +MSG + exit 2 +fi + +BLOCK_V4=$(echo "$V4" | sed 's/^/ "/; s/$/",/') +BLOCK_V6=$(echo "$V6" | sed 's/^/ "/; s/$/",/') + +python3 - "$TF_FILE" <>> Verifiez le diff avant d'appliquer :" +echo " git diff infra/prod/terraform/firewall.tf" +echo " cd infra/prod/terraform && terraform plan" diff --git a/infra/prod/scripts/secrets-apply.sh b/infra/prod/scripts/secrets-apply.sh new file mode 100755 index 0000000..c478f4d --- /dev/null +++ b/infra/prod/scripts/secrets-apply.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# ============================================================================= +# Application des secrets chiffres SOPS sur le cluster +# ============================================================================= +# export KUBECONFIG=~/.kube/xpeditis-prod.yaml +# bash scripts/secrets-apply.sh +# +# Le contenu dechiffre ne touche JAMAIS le disque : sops ecrit sur la sortie +# standard, kubectl lit sur l'entree standard. Rien a nettoyer, rien a oublier. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SECRETS_FILE="${HERE}/../k8s/base/03-secrets.sops.yaml" + +command -v sops >/dev/null || { echo "sops n'est pas installe." >&2; exit 1; } +[[ -f "$SECRETS_FILE" ]] || { + cat >&2 < k8s/base/03-secrets.sops.yaml + shred -u /tmp/secrets.yaml +MSG + exit 1 +} + +# Refuse d'appliquer un fichier qui ne serait pas reellement chiffre : c'est le +# genre d'erreur qui ne se voit qu'une fois le secret pousse sur GitHub. +grep -q 'sops:' "$SECRETS_FILE" || { + echo "ERREUR: $SECRETS_FILE ne semble pas chiffre par SOPS. Abandon." >&2 + exit 1 +} + +echo ">>> Application des secrets sur $(kubectl config current-context)" +sops -d "$SECRETS_FILE" | kubectl apply -f - + +echo +echo ">>> Secrets presents :" +kubectl -n xpeditis-prod get secrets +kubectl -n monitoring get secrets 2>/dev/null || true +kubectl -n cert-manager get secret cloudflare-api-token 2>/dev/null || true + +cat <<'NEXT' + +Rappel : modifier un Secret ne redemarre PAS les pods qui l'utilisent. +Pour que la nouvelle valeur soit prise en compte : + kubectl -n xpeditis-prod rollout restart deploy/xpeditis-backend +NEXT diff --git a/infra/prod/scripts/smoke-test.sh b/infra/prod/scripts/smoke-test.sh new file mode 100755 index 0000000..1319e37 --- /dev/null +++ b/infra/prod/scripts/smoke-test.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# ============================================================================= +# Tests de fumee post-deploiement +# ============================================================================= +# Verifie ce qu'un utilisateur constate reellement, depuis l'exterieur, a +# travers Cloudflare et Traefik -- pas l'etat interne du cluster. +# +# bash scripts/smoke-test.sh +# +# Code de retour 0 = production saine. Utilise par deploy.sh et cd-main.yml +# pour declencher un retour arriere automatique. +set -uo pipefail + +API="${PROD_API_URL:-https://api.xpeditis.com}" +APP="${PROD_APP_URL:-https://app.xpeditis.com}" +SITE="${PROD_SITE_URL:-https://xpeditis.com}" + +PASS=0 +FAIL=0 + +check() { + local label="$1"; shift + if "$@" >/dev/null 2>&1; then + printf ' [OK] %s\n' "$label"; PASS=$((PASS + 1)) + else + printf ' [ECHEC] %s\n' "$label"; FAIL=$((FAIL + 1)) + fi +} + +http_code() { curl -sS -o /dev/null -w '%{http_code}' --max-time 15 "$1"; } + +expect_code() { + local url="$1" expected="$2" + [[ "$(http_code "$url")" == "$expected" ]] +} + +expect_header() { + local url="$1" header="$2" + curl -sSI --max-time 15 "$url" | grep -qi "^${header}:" +} + +echo "Tests de fumee - $(date -Is)" +echo + +echo "Disponibilite" +check "API en ligne (200 sur /api/v1/health)" expect_code "${API}/api/v1/health" 200 +check "Frontend en ligne (app)" expect_code "${APP}/" 200 +check "Vitrine en ligne (apex)" expect_code "${SITE}/" 200 + +echo +echo "TLS et redirections" +check "HTTP redirige vers HTTPS" bash -c "[[ \$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 'http://api.xpeditis.com/api/v1/health') =~ ^30 ]]" +check "Certificat valide (pas d'option -k)" curl -sS --max-time 15 -o /dev/null "${API}/api/v1/health" +check "En-tete HSTS present" expect_header "${API}/api/v1/health" "strict-transport-security" + +echo +echo "Durcissement" +check "X-Frame-Options present" expect_header "${APP}/" "x-frame-options" +check "X-Content-Type-Options present" expect_header "${APP}/" "x-content-type-options" +# main.ts desactive Swagger en production sauf si SWAGGER_USERNAME/PASSWORD +# sont definis. 404 = desactive, 401 = protege : les deux sont acceptables, +# 200 signifie que la documentation de l'API est publique. +check "Swagger non accessible librement" bash -c "[[ \$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 '${API}/api/docs') != '200' ]]" +check "Route protegee refuse l'anonyme (401/403)" bash -c "[[ \$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 '${API}/api/v1/bookings') =~ ^(401|403)$ ]]" +check "CORS refuse une origine inconnue" bash -c "! curl -sSI --max-time 15 -H 'Origin: https://evil.example' '${API}/api/v1/health' | grep -qi 'access-control-allow-origin: https://evil.example'" + +echo +echo "Etat du cluster" +if command -v kubectl >/dev/null && kubectl get ns xpeditis-prod >/dev/null 2>&1; then + check "Aucun pod en erreur" bash -c "! kubectl -n xpeditis-prod get pods --no-headers | grep -qE 'CrashLoopBackOff|ImagePullBackOff|Error'" + check "Certificat cert-manager pret" bash -c "kubectl -n xpeditis-prod get certificate xpeditis-wildcard -o jsonpath='{.status.conditions[?(@.type==\"Ready\")].status}' | grep -q True" +else + echo " [saute] kubectl indisponible : verifications cluster ignorees" +fi + +echo +echo "-----------------------------------------" +printf ' Reussis : %d Echecs : %d\n' "$PASS" "$FAIL" +echo "-----------------------------------------" + +[[ "$FAIL" -eq 0 ]] diff --git a/infra/prod/scripts/ssh-deploy-wrapper.sh b/infra/prod/scripts/ssh-deploy-wrapper.sh new file mode 100755 index 0000000..555b286 --- /dev/null +++ b/infra/prod/scripts/ssh-deploy-wrapper.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# ============================================================================= +# Enveloppe de la cle SSH de deploiement +# ============================================================================= +# Reference depuis ~deploy/.ssh/authorized_keys sur app-01 : +# +# restrict,pty,command="/opt/xpeditis/infra-prod/scripts/ssh-deploy-wrapper.sh" ssh-ed25519 AAAA... github-actions-prod +# +# Le `command=` d'OpenSSH ignore ce que le client demande et execute ce script, +# en placant la commande d'origine dans SSH_ORIGINAL_COMMAND. Une cle volee ne +# donne donc pas un shell : elle ne peut lancer que ce qui est autorise ici. +set -euo pipefail + +CMD="${SSH_ORIGINAL_COMMAND:-}" +LOG_TAG=xpeditis-ssh-deploy + +deny() { + logger -t "$LOG_TAG" -- "REFUS: ${CMD}" + echo "Commande non autorisee pour cette cle." >&2 + exit 126 +} + +logger -t "$LOG_TAG" -- "DEMANDE: ${CMD}" + +case "$CMD" in + # rsync doit pouvoir se lancer en mode serveur pour recevoir infra/prod. + # --server et --sender uniquement, chemin de destination contraint. + "rsync --server "*"/opt/xpeditis/infra-prod/"*) + exec $CMD + ;; + + # Deploiement d'une version. Le tag est valide par une expression stricte : + # sans cela, `deploy.sh "; rm -rf /"` serait accepte. + "deploy "*) + TAG="${CMD#deploy }" + [[ "$TAG" =~ ^prod-[a-f0-9]{7,40}$ ]] || deny + exec /opt/xpeditis/infra-prod/scripts/deploy.sh "$TAG" + ;; + + # Retour arriere d'urgence. + "rollback") + kubectl -n xpeditis-prod rollout undo deploy/xpeditis-backend + kubectl -n xpeditis-prod rollout undo deploy/xpeditis-frontend + exec kubectl -n xpeditis-prod rollout status deploy/xpeditis-backend --timeout=180s + ;; + + # Diagnostic en lecture seule. + "status") + kubectl -n xpeditis-prod get pods,deploy -o wide + exec kubectl -n xpeditis-prod get events --sort-by=.lastTimestamp | tail -20 + ;; + + *) + deny + ;; +esac diff --git a/infra/prod/terraform/cloud-init.yaml.tftpl b/infra/prod/terraform/cloud-init.yaml.tftpl new file mode 100644 index 0000000..01a7dca --- /dev/null +++ b/infra/prod/terraform/cloud-init.yaml.tftpl @@ -0,0 +1,76 @@ +#cloud-config +# ============================================================================= +# Amorcage minimal des serveurs Xpeditis prod +# ============================================================================= +# Ce fichier ne fait que le strict necessaire pour obtenir un serveur joignable +# en SSH par un compte non-root. Tout le durcissement reel est fait par +# scripts/00-bootstrap-common.sh, versionne et relisible. + +hostname: ${hostname} +fqdn: ${hostname} +preserve_hostname: false + +users: + - name: ${deploy_user} + groups: [sudo] + shell: /bin/bash + sudo: ["ALL=(ALL) NOPASSWD:ALL"] + lock_passwd: true + ssh_authorized_keys: + - ${ssh_public_key} + +# Le compte root n'a ni mot de passe ni acces SSH par mot de passe. +disable_root: true +ssh_pwauth: false + +package_update: true +package_upgrade: true + +packages: + - curl + - ca-certificates + - gnupg + - ufw + - fail2ban + - unattended-upgrades + - chrony + - jq + - git + - htop + - rsync + +write_files: + # Pare-feu local minimal des le premier boot : le serveur n'est jamais + # accessible "nu", meme entre cloud-init et l'execution du script de + # durcissement. + - path: /etc/ssh/sshd_config.d/99-xpeditis-hardening.conf + permissions: "0644" + content: | + PermitRootLogin no + PasswordAuthentication no + KbdInteractiveAuthentication no + ChallengeResponseAuthentication no + PubkeyAuthentication yes + X11Forwarding no + AllowAgentForwarding no + AllowTcpForwarding yes + MaxAuthTries 3 + MaxSessions 5 + LoginGraceTime 20 + ClientAliveInterval 300 + ClientAliveCountMax 2 + AllowUsers ${deploy_user} + +runcmd: + - systemctl restart ssh || systemctl restart sshd + - systemctl enable --now fail2ban + - systemctl enable --now chrony + - | + ufw --force reset + ufw default deny incoming + ufw default allow outgoing + ufw allow 22/tcp + ufw --force enable + - touch /var/log/cloud-init-xpeditis-done + +final_message: "Xpeditis prod node ${hostname} pret. Lancer scripts/00-bootstrap-common.sh." diff --git a/infra/prod/terraform/firewall.tf b/infra/prod/terraform/firewall.tf new file mode 100644 index 0000000..ff38ca3 --- /dev/null +++ b/infra/prod/terraform/firewall.tf @@ -0,0 +1,157 @@ +# ============================================================================= +# Firewalls Hetzner Cloud (interfaces publiques uniquement) +# ============================================================================= +# Politique : tout est ferme par defaut. On n'ouvre que le strict necessaire, +# et jamais vers 0.0.0.0/0 sauf pour le trafic web derriere Cloudflare. + +locals { + # Rangs d'IP Cloudflare. A rafraichir avec : + # curl -s https://www.cloudflare.com/ips-v4 + # curl -s https://www.cloudflare.com/ips-v6 + # Verifiez cette liste a chaque `terraform apply` (cf. scripts/refresh-cloudflare-ips.sh). + cloudflare_ipv4 = [ + "173.245.48.0/20", + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "141.101.64.0/18", + "108.162.192.0/18", + "190.93.240.0/20", + "188.114.96.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "162.158.0.0/15", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", + ] + + cloudflare_ipv6 = [ + "2400:cb00::/32", + "2606:4700::/32", + "2803:f800::/32", + "2405:b500::/32", + "2405:8100::/32", + "2a06:98c0::/29", + "2c0f:f248::/32", + ] + + http_sources_v4 = var.restrict_http_to_cloudflare ? local.cloudflare_ipv4 : ["0.0.0.0/0"] + http_sources_v6 = var.restrict_http_to_cloudflare ? local.cloudflare_ipv6 : ["::/0"] + http_sources = concat(local.http_sources_v4, local.http_sources_v6) +} + +# --- Noeud applicatif (k3s server) ------------------------------------------ + +resource "hcloud_firewall" "app" { + name = "${var.project_name}-fw-app" + + # SSH : uniquement depuis les IPs d'administration. + rule { + direction = "in" + protocol = "tcp" + port = "22" + source_ips = var.admin_ip_allowlist + description = "SSH administrateur" + } + + # API Kubernetes : uniquement depuis les IPs d'administration. + # Les runners GitHub Actions n'ont pas d'IP fixe : le deploiement passe donc + # par SSH + kubectl local sur le serveur, pas par 6443 expose. + # Cf. .github/workflows/cd-main.yml. + rule { + direction = "in" + protocol = "tcp" + port = "6443" + source_ips = var.admin_ip_allowlist + description = "API k3s (kubectl administrateur)" + } + + rule { + direction = "in" + protocol = "tcp" + port = "80" + source_ips = local.http_sources + description = "HTTP (redirection 301 + ACME HTTP-01 de secours)" + } + + rule { + direction = "in" + protocol = "tcp" + port = "443" + source_ips = local.http_sources + description = "HTTPS via Cloudflare" + } + + # ICMP : utile pour le diagnostic reseau, sans risque notable. + rule { + direction = "in" + protocol = "icmp" + source_ips = ["0.0.0.0/0", "::/0"] + description = "ICMP (ping / MTU discovery)" + } + + labels = { + project = var.project_name + managed = "terraform" + } +} + +# --- Ouverture temporaire pour la CI/CD ------------------------------------- +# Les runners GitHub Actions n'ont pas d'IP fixe : impossible de les inscrire +# une fois pour toutes dans une liste blanche, et ouvrir SSH au monde entier +# n'est pas une option. +# +# Ce firewall est attache a app-01 et reste VIDE en regime nominal. Le workflow +# cd-main.yml y injecte l'IP du runner juste avant le deploiement, puis le vide +# systematiquement (etape `if: always()`). La fenetre d'exposition dure le temps +# du deploiement, pour une seule IP, sur le seul port 22. +# +# `ignore_changes = [rule]` est indispensable : sans lui, le prochain +# `terraform apply` supprimerait une regle posee par la CI en cours d'execution. + +resource "hcloud_firewall" "cicd" { + name = "${var.project_name}-fw-cicd" + + # Aucune regle : l'etat au repos est "ferme". + + labels = { + project = var.project_name + managed = "terraform" + purpose = "cicd-temporaire" + } + + lifecycle { + ignore_changes = [rule] + } +} + +# --- Noeud de donnees -------------------------------------------------------- +# Aucun port applicatif expose publiquement. PostgreSQL et Redis n'ecoutent que +# sur l'IP privee (cf. conf/postgresql.conf et conf/redis.conf) et sont en plus +# filtres par nftables. Ce firewall ne laisse passer que le SSH d'administration. + +resource "hcloud_firewall" "db" { + name = "${var.project_name}-fw-db" + + rule { + direction = "in" + protocol = "tcp" + port = "22" + source_ips = var.admin_ip_allowlist + description = "SSH administrateur" + } + + rule { + direction = "in" + protocol = "icmp" + source_ips = ["0.0.0.0/0", "::/0"] + description = "ICMP (ping / MTU discovery)" + } + + labels = { + project = var.project_name + managed = "terraform" + } +} diff --git a/infra/prod/terraform/network.tf b/infra/prod/terraform/network.tf new file mode 100644 index 0000000..9159543 --- /dev/null +++ b/infra/prod/terraform/network.tf @@ -0,0 +1,52 @@ +# ============================================================================= +# Reseau prive Hetzner +# ============================================================================= +# Le noeud de donnees n'est JAMAIS joignable depuis Internet sur 5432/6379. +# Tout le trafic applicatif -> base passe par ce reseau prive, isole au niveau +# du projet Hetzner. +# +# Attention : le trafic sur un reseau prive Hetzner n'est pas chiffre par +# l'hyperviseur. On ajoute donc deux couches par-dessus : +# 1. TLS PostgreSQL (ssl = on + DATABASE_SSL=true cote applicatif) +# 2. Filtrage nftables sur db-01 (cf. scripts/01-setup-data-node.sh) +# Les firewalls Hetzner Cloud ne filtrent que les interfaces PUBLIQUES. + +resource "hcloud_network" "main" { + name = "${var.project_name}-net" + ip_range = var.network_cidr + + labels = { + project = var.project_name + managed = "terraform" + } +} + +resource "hcloud_network_subnet" "main" { + network_id = hcloud_network.main.id + type = "cloud" + network_zone = "eu-central" + ip_range = var.subnet_cidr +} + +# Cle SSH partagee par les deux serveurs. +resource "hcloud_ssh_key" "admin" { + name = "${var.project_name}-admin" + public_key = var.ssh_public_key + + labels = { + project = var.project_name + managed = "terraform" + } +} + +# Repartit les deux VMs sur des hotes physiques differents : une panne materielle +# ne peut pas emporter l'app ET la base en meme temps. +resource "hcloud_placement_group" "spread" { + name = "${var.project_name}-spread" + type = "spread" + + labels = { + project = var.project_name + managed = "terraform" + } +} diff --git a/infra/prod/terraform/outputs.tf b/infra/prod/terraform/outputs.tf new file mode 100644 index 0000000..25d4d14 --- /dev/null +++ b/infra/prod/terraform/outputs.tf @@ -0,0 +1,64 @@ +output "app_public_ipv4" { + description = "IP publique du noeud applicatif. A pointer depuis Cloudflare (enregistrements A, proxifies)." + value = hcloud_server.app.ipv4_address +} + +output "app_public_ipv6" { + description = "IPv6 du noeud applicatif (enregistrements AAAA)." + value = hcloud_server.app.ipv6_address +} + +output "app_private_ip" { + description = "IP privee du noeud applicatif." + value = var.app_private_ip +} + +output "db_public_ipv4" { + description = "IP publique du noeud de donnees. NE JAMAIS publier en DNS : elle ne sert qu'au SSH d'administration." + value = hcloud_server.db.ipv4_address +} + +output "db_private_ip" { + description = "IP privee du noeud de donnees. C'est cette valeur qui alimente DATABASE_HOST et REDIS_HOST." + value = var.db_private_ip +} + +output "pgdata_volume_device" { + description = "Chemin du peripherique du volume PostgreSQL sur db-01 (a monter sur /var/lib/xpeditis/pgdata)." + value = hcloud_volume.pgdata.linux_device +} + +output "cicd_firewall_name" { + description = "Firewall temporaire pilote par cd-main.yml. Doit rester vide au repos." + value = hcloud_firewall.cicd.name +} + +output "ssh_commands" { + description = "Commandes de connexion." + value = { + app = "ssh ${var.deploy_user}@${hcloud_server.app.ipv4_address}" + db = "ssh ${var.deploy_user}@${hcloud_server.db.ipv4_address}" + } +} + +output "dns_records_to_create" { + description = "Enregistrements DNS a creer dans Cloudflare (tous proxifies, nuage orange)." + value = { + "xpeditis.com" = "A ${hcloud_server.app.ipv4_address}" + "www.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}" + "app.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}" + "api.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}" + "grafana.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}" + } +} + +output "monthly_cost_estimate_eur" { + description = "Estimation indicative HT (tarifs Hetzner T2 2026, hors Storage Box et services tiers)." + value = { + app_server = "${var.app_server_type} : voir grille Hetzner" + db_server = "${var.db_server_type} : voir grille Hetzner" + volume = "${var.db_volume_size} Go x 0.048 EUR/Go = ${format("%.2f", var.db_volume_size * 0.048)} EUR" + backups = var.enable_hetzner_backups ? "+20% du prix des deux serveurs" : "desactives" + note = "Reference budgetaire : Xpeditis_Previsions_Couts.xlsx, feuille 'Hetzner (auto-heberge)'." + } +} diff --git a/infra/prod/terraform/servers.tf b/infra/prod/terraform/servers.tf new file mode 100644 index 0000000..47b0536 --- /dev/null +++ b/infra/prod/terraform/servers.tf @@ -0,0 +1,116 @@ +# ============================================================================= +# Serveurs +# ============================================================================= +# Topologie retenue (2 VMs, cf. docs/mise-en-prod/00 et Excel previsions couts) : +# +# app-01 CPX41 8 vCPU / 16 Go k3s server + tous les pods (stateless) +# db-01 CPX31 4 vCPU / 8 Go PostgreSQL 15 + Redis 7 (stateful, Docker) +# +# Pourquoi la base HORS de Kubernetes : PostgreSQL en StatefulSet apporte de la +# complexite (PV, ordre de demarrage, upgrades) sans aucun gain a cette echelle. +# Le hors-cluster rend les sauvegardes, la PITR et les restaurations triviales, +# et permet de reconstruire integralement le noeud app sans toucher aux donnees. + +resource "hcloud_server" "app" { + name = "${var.project_name}-app-01" + server_type = var.app_server_type + image = var.image + location = var.location + ssh_keys = [hcloud_ssh_key.admin.id] + # Deux firewalls : le permanent, et celui que la CI ouvre puis referme. + firewall_ids = [hcloud_firewall.app.id, hcloud_firewall.cicd.id] + placement_group_id = hcloud_placement_group.spread.id + backups = var.enable_hetzner_backups + + public_net { + ipv4_enabled = true + ipv6_enabled = true + } + + network { + network_id = hcloud_network.main.id + ip = var.app_private_ip + } + + user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", { + hostname = "${var.project_name}-app-01" + deploy_user = var.deploy_user + ssh_public_key = var.ssh_public_key + }) + + labels = { + project = var.project_name + role = "app" + managed = "terraform" + } + + depends_on = [hcloud_network_subnet.main] + + lifecycle { + # Un changement d'image ou de user_data recreerait le serveur : on veut une + # decision explicite, pas une destruction silencieuse de la prod. + ignore_changes = [image, user_data] + } +} + +resource "hcloud_server" "db" { + name = "${var.project_name}-db-01" + server_type = var.db_server_type + image = var.image + location = var.location + ssh_keys = [hcloud_ssh_key.admin.id] + firewall_ids = [hcloud_firewall.db.id] + placement_group_id = hcloud_placement_group.spread.id + backups = var.enable_hetzner_backups + + public_net { + ipv4_enabled = true + ipv6_enabled = true + } + + network { + network_id = hcloud_network.main.id + ip = var.db_private_ip + } + + user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", { + hostname = "${var.project_name}-db-01" + deploy_user = var.deploy_user + ssh_public_key = var.ssh_public_key + }) + + labels = { + project = var.project_name + role = "data" + managed = "terraform" + } + + depends_on = [hcloud_network_subnet.main] + + lifecycle { + ignore_changes = [image, user_data] + } +} + +# --- Volume de donnees PostgreSQL ------------------------------------------- +# Volume dedie plutot que le disque systeme : agrandissable a chaud, snapshotable +# independamment, et survit a une reinstallation complete du serveur. + +resource "hcloud_volume" "pgdata" { + name = "${var.project_name}-pgdata" + size = var.db_volume_size + server_id = hcloud_server.db.id + automount = false + format = "ext4" + + labels = { + project = var.project_name + role = "pgdata" + managed = "terraform" + } + + lifecycle { + # Garde-fou : un `terraform destroy` ne doit jamais emporter les donnees. + prevent_destroy = true + } +} diff --git a/infra/prod/terraform/terraform.tfvars.example b/infra/prod/terraform/terraform.tfvars.example new file mode 100644 index 0000000..0faff98 --- /dev/null +++ b/infra/prod/terraform/terraform.tfvars.example @@ -0,0 +1,37 @@ +# ============================================================================= +# Copier en terraform.tfvars (gitignore) et completer. +# cp terraform.tfvars.example terraform.tfvars +# ============================================================================= + +# Token API Hetzner Cloud, projet "xpeditis-prod" UNIQUEMENT (Read & Write). +# Console Hetzner > Security > API tokens. +# Ne le mettez pas ici si vous preferez la variable d'environnement : +# export TF_VAR_hcloud_token="..." +hcloud_token = "REMPLACER" + +project_name = "xpeditis-prod" +location = "fsn1" + +# Cle publique SSH de l'administrateur (ed25519 recommande). +# ssh-keygen -t ed25519 -a 100 -C "xpeditis-prod-admin" -f ~/.ssh/xpeditis_prod +# cat ~/.ssh/xpeditis_prod.pub +ssh_public_key = "ssh-ed25519 AAAA... xpeditis-prod-admin" + +# IP publique fixe depuis laquelle vous administrez (SSH + kubectl). +# Trouver la votre : curl -s https://ifconfig.me +# Si votre IP est dynamique, utilisez plutot un VPN a IP fixe, ou acceptez de +# mettre a jour cette liste puis de relancer `terraform apply`. +admin_ip_allowlist = [ + "203.0.113.7/32", +] + +# Dimensionnement (phase 1 = 0-100 utilisateurs) +app_server_type = "cpx41" # 8 vCPU / 16 Go +db_server_type = "cpx31" # 4 vCPU / 8 Go +db_volume_size = 50 # Go + +enable_hetzner_backups = true + +# Laisser a true en regime nominal : personne ne doit pouvoir contourner +# Cloudflare en tapant directement l'IP d'origine. +restrict_http_to_cloudflare = true diff --git a/infra/prod/terraform/variables.tf b/infra/prod/terraform/variables.tf new file mode 100644 index 0000000..32f0901 --- /dev/null +++ b/infra/prod/terraform/variables.tf @@ -0,0 +1,120 @@ +variable "hcloud_token" { + description = "Token API Hetzner Cloud (Read & Write), projet xpeditis-prod uniquement." + type = string + sensitive = true +} + +variable "project_name" { + description = "Prefixe applique a toutes les ressources." + type = string + default = "xpeditis-prod" +} + +variable "location" { + description = "Datacenter Hetzner. fsn1 = Falkenstein (DE), nbg1 = Nuremberg (DE), hel1 = Helsinki (FI). Rester dans l'UE pour le RGPD." + type = string + default = "fsn1" + + validation { + condition = contains(["fsn1", "nbg1", "hel1"], var.location) + error_message = "Le RGPD impose de rester en UE : fsn1, nbg1 ou hel1." + } +} + +# --- Dimensionnement (cf. Xpeditis_Previsions_Couts.xlsx, feuille Hetzner) --- + +variable "app_server_type" { + description = "Type du noeud applicatif (k3s server + pods). CPX41 = 8 vCPU / 16 Go / 240 Go." + type = string + default = "cpx41" +} + +variable "db_server_type" { + description = "Type du noeud de donnees (PostgreSQL + Redis). CPX31 = 4 vCPU / 8 Go / 160 Go." + type = string + default = "cpx31" +} + +variable "image" { + description = "Image systeme de base." + type = string + default = "ubuntu-24.04" +} + +variable "db_volume_size" { + description = "Volume dedie aux donnees PostgreSQL, en Go. Detache du disque systeme : on peut agrandir, snapshotter et reattacher sans toucher au serveur." + type = number + default = 50 +} + +variable "enable_hetzner_backups" { + description = "Snapshots automatiques Hetzner (+20% du prix du serveur). Ce n'est PAS une strategie de sauvegarde suffisante : cf. 12-sauvegardes-restauration.md." + type = bool + default = true +} + +# --- Reseau ------------------------------------------------------------------ + +variable "network_cidr" { + description = "CIDR du reseau prive Hetzner." + type = string + default = "10.10.0.0/16" +} + +variable "subnet_cidr" { + description = "CIDR du sous-reseau." + type = string + default = "10.10.1.0/24" +} + +variable "app_private_ip" { + description = "IP privee fixe du noeud applicatif." + type = string + default = "10.10.1.10" +} + +variable "db_private_ip" { + description = "IP privee fixe du noeud de donnees." + type = string + default = "10.10.1.20" +} + +# --- Acces administrateur ---------------------------------------------------- + +variable "ssh_public_key" { + description = "Cle publique SSH (ed25519) de l'administrateur. C'est la seule methode d'authentification autorisee sur les serveurs." + type = string +} + +variable "admin_ip_allowlist" { + description = <<-EOT + IPs/CIDR autorises a atteindre SSH (22) et l'API Kubernetes (6443). + Mettez votre IP fixe ou celle de votre VPN, JAMAIS 0.0.0.0/0. + Format CIDR obligatoire, ex. ["203.0.113.7/32"]. + EOT + type = list(string) + + validation { + condition = !contains(var.admin_ip_allowlist, "0.0.0.0/0") + error_message = "Ouvrir SSH et l'API k3s au monde entier est interdit. Renseignez votre IP en /32." + } +} + +variable "deploy_user" { + description = "Compte non-root utilise pour l'administration et les deploiements." + type = string + default = "deploy" +} + +# --- Cloudflare -------------------------------------------------------------- + +variable "restrict_http_to_cloudflare" { + description = <<-EOT + Si true, seuls les rangs d'IP Cloudflare peuvent joindre 80/443 sur le noeud + applicatif : impossible de contourner le WAF en tapant l'IP d'origine. + Mettre a false UNIQUEMENT le temps d'emettre le premier certificat en HTTP-01 + ou si le proxy Cloudflare (nuage orange) est desactive. + EOT + type = bool + default = true +} diff --git a/infra/prod/terraform/versions.tf b/infra/prod/terraform/versions.tf new file mode 100644 index 0000000..99c0a83 --- /dev/null +++ b/infra/prod/terraform/versions.tf @@ -0,0 +1,33 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + hcloud = { + source = "hetznercloud/hcloud" + version = "~> 1.48" + } + } + + # Backend distant recommande des que vous n'etes plus seul sur le projet. + # Par defaut le state reste local : il contient des donnees sensibles + # (IPs, ids), il est donc gitignore. Sauvegardez-le dans votre coffre-fort. + # + # Pour passer sur un backend S3 (Hetzner Object Storage compatible S3) : + # + # backend "s3" { + # bucket = "xpeditis-prod-tfstate" + # key = "prod/terraform.tfstate" + # region = "fsn1" + # endpoints = { s3 = "https://fsn1.your-objectstorage.com" } + # skip_credentials_validation = true + # skip_region_validation = true + # skip_requesting_account_id = true + # skip_s3_checksum = true + # use_path_style = true + # encrypt = true + # } +} + +provider "hcloud" { + token = var.hcloud_token +} From b22f4e0b749bb6a679df144619e3a17fd751a3a7 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:50 +0200 Subject: [PATCH 2/4] docs: procedure de mise en production pas a pas Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- docs/README.md | 40 +- docs/mise-en-prod/01-prerequis.md | 163 +++++++ docs/mise-en-prod/02-provisioning-hetzner.md | 211 +++++++++ docs/mise-en-prod/03-durcissement-serveurs.md | 180 ++++++++ docs/mise-en-prod/04-noeud-donnees.md | 337 +++++++++++++++ docs/mise-en-prod/05-cluster-k3s.md | 181 ++++++++ docs/mise-en-prod/06-secrets-sops.md | 251 +++++++++++ docs/mise-en-prod/07-stockage-objet-s3.md | 173 ++++++++ docs/mise-en-prod/08-dns-tls-cloudflare.md | 294 +++++++++++++ .../09-deploiement-application.md | 337 +++++++++++++++ docs/mise-en-prod/10-cicd-github-actions.md | 260 +++++++++++ docs/mise-en-prod/11-observabilite.md | 273 ++++++++++++ .../12-sauvegardes-restauration.md | 315 ++++++++++++++ docs/mise-en-prod/13-securite-durcissement.md | 310 ++++++++++++++ docs/mise-en-prod/14-runbook-go-live.md | 256 +++++++++++ .../mise-en-prod/15-exploitation-incidents.md | 405 ++++++++++++++++++ docs/mise-en-prod/16-rgpd-conformite.md | 241 +++++++++++ docs/mise-en-prod/README.md | 174 ++++++++ 18 files changed, 4398 insertions(+), 3 deletions(-) create mode 100644 docs/mise-en-prod/01-prerequis.md create mode 100644 docs/mise-en-prod/02-provisioning-hetzner.md create mode 100644 docs/mise-en-prod/03-durcissement-serveurs.md create mode 100644 docs/mise-en-prod/04-noeud-donnees.md create mode 100644 docs/mise-en-prod/05-cluster-k3s.md create mode 100644 docs/mise-en-prod/06-secrets-sops.md create mode 100644 docs/mise-en-prod/07-stockage-objet-s3.md create mode 100644 docs/mise-en-prod/08-dns-tls-cloudflare.md create mode 100644 docs/mise-en-prod/09-deploiement-application.md create mode 100644 docs/mise-en-prod/10-cicd-github-actions.md create mode 100644 docs/mise-en-prod/11-observabilite.md create mode 100644 docs/mise-en-prod/12-sauvegardes-restauration.md create mode 100644 docs/mise-en-prod/13-securite-durcissement.md create mode 100644 docs/mise-en-prod/14-runbook-go-live.md create mode 100644 docs/mise-en-prod/15-exploitation-incidents.md create mode 100644 docs/mise-en-prod/16-rgpd-conformite.md create mode 100644 docs/mise-en-prod/README.md diff --git a/docs/README.md b/docs/README.md index 157cefe..2e63b48 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,14 +40,48 @@ Documentation complète de la plateforme B2B SaaS de réservation de fret mariti --- -## Déploiement +## Mise en production + +**[mise-en-prod/](mise-en-prod/README.md)** — procédure complète, pas à pas, pour +ouvrir la plateforme au public sur Hetzner. C'est le point d'entrée à suivre +pour un déploiement réel. Les fichiers correspondants sont dans +[`infra/prod/`](../infra/prod/README.md). + +| Étape | Fichier | +|---|---| +| Index, chronologie, blocages identifiés | [mise-en-prod/README.md](mise-en-prod/README.md) | +| Prérequis (comptes, outils, clés) | [mise-en-prod/01-prerequis.md](mise-en-prod/01-prerequis.md) | +| Provisioning Hetzner (Terraform) | [mise-en-prod/02-provisioning-hetzner.md](mise-en-prod/02-provisioning-hetzner.md) | +| Durcissement des serveurs | [mise-en-prod/03-durcissement-serveurs.md](mise-en-prod/03-durcissement-serveurs.md) | +| Nœud de données (PostgreSQL, Redis) | [mise-en-prod/04-noeud-donnees.md](mise-en-prod/04-noeud-donnees.md) | +| Cluster k3s | [mise-en-prod/05-cluster-k3s.md](mise-en-prod/05-cluster-k3s.md) | +| Secrets (SOPS + age) | [mise-en-prod/06-secrets-sops.md](mise-en-prod/06-secrets-sops.md) | +| Stockage objet | [mise-en-prod/07-stockage-objet-s3.md](mise-en-prod/07-stockage-objet-s3.md) | +| DNS, TLS, Cloudflare | [mise-en-prod/08-dns-tls-cloudflare.md](mise-en-prod/08-dns-tls-cloudflare.md) | +| Déploiement applicatif | [mise-en-prod/09-deploiement-application.md](mise-en-prod/09-deploiement-application.md) | +| CI/CD GitHub Actions | [mise-en-prod/10-cicd-github-actions.md](mise-en-prod/10-cicd-github-actions.md) | +| Observabilité | [mise-en-prod/11-observabilite.md](mise-en-prod/11-observabilite.md) | +| Sauvegardes et restauration | [mise-en-prod/12-sauvegardes-restauration.md](mise-en-prod/12-sauvegardes-restauration.md) | +| Sécurité | [mise-en-prod/13-securite-durcissement.md](mise-en-prod/13-securite-durcissement.md) | +| Runbook de mise en ligne | [mise-en-prod/14-runbook-go-live.md](mise-en-prod/14-runbook-go-live.md) | +| Exploitation et incidents | [mise-en-prod/15-exploitation-incidents.md](mise-en-prod/15-exploitation-incidents.md) | +| RGPD et conformité | [mise-en-prod/16-rgpd-conformite.md](mise-en-prod/16-rgpd-conformite.md) | + +--- + +## Déploiement — autres environnements | Sujet | Fichier | |---|---| -| Portainer / Docker Swarm | [deployment/portainer.md](deployment/portainer.md) | -| Hetzner / Kubernetes | [deployment/hetzner/README.md](deployment/hetzner/README.md) | +| Preprod : Portainer / Docker Swarm | [deployment/portainer.md](deployment/portainer.md) | +| Étude Hetzner / Kubernetes (antérieure) | [deployment/hetzner/README.md](deployment/hetzner/README.md) | | Stripe (paiements) | [deployment/STRIPE_SETUP.md](deployment/STRIPE_SETUP.md) | +> `deployment/hetzner/` est l'étude de cadrage qui a précédé la mise en œuvre. +> Elle reste utile pour comprendre les arbitrages, mais **la procédure à suivre +> est `mise-en-prod/`**, seule alignée sur les fichiers réellement livrés dans +> `infra/prod/`. + --- ## Tests diff --git a/docs/mise-en-prod/01-prerequis.md b/docs/mise-en-prod/01-prerequis.md new file mode 100644 index 0000000..8d8a64e --- /dev/null +++ b/docs/mise-en-prod/01-prerequis.md @@ -0,0 +1,163 @@ +# 01 — Prérequis + +**Durée : 2 à 3 h.** Rien de technique ici, mais tout bloque si un élément +manque au moment où vous en avez besoin. + +--- + +## 1. Comptes à créer ou vérifier + +| Service | Rôle | À faire | Coût | +|---|---|---|---| +| **Hetzner Cloud** | Serveurs, réseau, firewalls, volume | Créer un **projet dédié** `xpeditis-prod`, séparé de la preprod. Activer la **2FA**. | ~50 €/mois | +| **Hetzner Storage Box** | Seconde copie des dumps | Commander une **BX11** (1 To). Commande séparée de Hetzner Cloud. | 3,90 €/mois | +| **Hetzner Object Storage** | Documents + archives WAL-G | Activer dans le projet, région `fsn1`. | ~6 €/mois | +| **Cloudflare** | DNS, WAF, anti-DDoS | Zone `xpeditis.com` déléguée. Plan Free suffisant. **2FA obligatoire.** | 0 € | +| **Registrar du domaine** | `xpeditis.com` | Pointer les serveurs de noms vers Cloudflare. Activer le **verrou de transfert**. | ~10 €/an | +| **Scaleway Container Registry** | Images Docker | Déjà utilisé par la preprod. Vérifier que `REGISTRY_TOKEN` est encore valide. | ~1 €/mois | +| **Brevo** | E-mails transactionnels | Créer une **nouvelle clé SMTP de production** (celle de preprod est compromise). Vérifier le domaine expéditeur. | 0 → 19 €/mois | +| **Stripe** | Paiements | Passer le compte en **mode Live**. Récupérer `sk_live_…`, créer le webhook de production, noter les 6 identifiants de tarif. | % du volume | +| **Sentry** | Erreurs applicatives | Projet `xpeditis-prod` distinct de la preprod. | 0 → 26 €/mois | +| **Pappers** | Vérification SIRET | Clé API. Facultatif : sans clé, la vérification est simplement ignorée. | ~3 €/mois | +| **Discord** | Alertes et déploiements | Deux webhooks : `#deploiements` et `#alertes`. | 0 € | +| **healthchecks.io** ou **BetterStack** | Surveillance externe | Un *heartbeat* pour les sauvegardes, un contrôle d'uptime sur `https://app.xpeditis.com`. | 0 € | + +> **Pourquoi un projet Hetzner séparé** — le token API est valable pour tout un +> projet. Un token de preprod compromis ne doit pas pouvoir détruire la +> production. La séparation est aussi ce qui isole les deux réseaux privés. + +--- + +## 2. Décisions à trancher maintenant + +### 2.1 Adresse e-mail d'exploitation + +Une adresse **relevée** est nécessaire pour : +- Let's Encrypt (avertissements d'expiration si le renouvellement casse) ; +- Hetzner (incidents, maintenances) ; +- Cloudflare et le registrar. + +Recommandé : `ops@xpeditis.com`, redirigée vers votre boîte personnelle. +Remplacez `ops@xpeditis.com` dans `infra/prod/k8s/cluster/cluster-issuer.yaml` +si vous choisissez autre chose. + +### 2.2 IP d'administration + +Terraform refuse `0.0.0.0/0` pour SSH. Il vous faut une IP publique stable. + +```bash +curl -s https://ifconfig.me +``` + +- **IP fixe** (fibre pro, bureau) → parfait. +- **IP dynamique** → deux options : + - relancer `terraform apply` quand elle change (acceptable si c'est rare) ; + - passer par un VPN à IP fixe (Mullvad, un petit serveur Hetzner CX22 à 4 €). + +### 2.3 Noms de domaine + +La configuration livrée suppose : + +| Domaine | Sert | +|---|---| +| `xpeditis.com`, `www.xpeditis.com` | vitrine (pages publiques du frontend) | +| `app.xpeditis.com` | application (origine des cookies d'authentification) | +| `api.xpeditis.com` | API | +| `grafana.xpeditis.com` | supervision | + +Pour un autre découpage, modifiez `k8s/base/02-configmap-backend.yaml` +(`APP_URL`, `CORS_ORIGIN`, `COOKIE_DOMAIN`) **et** `k8s/base/09-ingress.yaml`. + +> `COOKIE_DOMAIN=.xpeditis.com` (avec le point initial) est **indispensable** : +> l'API pose le cookie sur `api.xpeditis.com`, le middleware Next.js le lit sur +> `app.xpeditis.com`. Sans le point, la connexion réussit mais l'utilisateur est +> renvoyé sur `/login` — le symptôme est déroutant, la cause est ici. + +--- + +## 3. Outils sur votre poste + +```bash +# macOS +brew install terraform kubectl sops age hcloud jq rsync +brew install --cask docker # pour construire des images localement + +# Vérification +terraform version # >= 1.6 +kubectl version --client +sops --version # >= 3.8 +age --version +hcloud version +jq --version +``` + +`shellcheck` est facultatif mais utilisé par `make validate` : +```bash +brew install shellcheck +``` + +--- + +## 4. Clés SSH + +Trois clés distinctes, jamais interchangeables : + +```bash +# 1. Administration (vous, sur les deux serveurs) +ssh-keygen -t ed25519 -a 100 -C "xpeditis-prod-admin" -f ~/.ssh/xpeditis_prod + +# 2. Déploiement CI (GitHub Actions → app-01, restreinte à un script) +ssh-keygen -t ed25519 -a 100 -C "github-actions-prod" -f ~/.ssh/xpeditis_ci + +# 3. Storage Box (db-01 → Storage Box, pour les dumps) +ssh-keygen -t ed25519 -a 100 -C "xpeditis-storagebox" -f ~/.ssh/xpeditis_storagebox +``` + +Protégez la clé d'administration par une phrase de passe. Celles de la CI et de +la Storage Box sont utilisées par des automates : elles ne peuvent pas en avoir, +c'est justement pourquoi leurs privilèges sont limités. + +**Sauvegardez les trois clés privées dans votre gestionnaire de mots de passe.** +Perdre la clé d'administration signifie repasser par la console Hetzner en mode +secours. + +--- + +## 5. Clé de chiffrement des secrets + +C'est **la** clé à ne pas perdre : elle déchiffre tous les secrets de +production. Sa création est détaillée dans [06-secrets-sops.md](./06-secrets-sops.md), +mais générez-la maintenant, vous en aurez besoin partout : + +```bash +mkdir -p ~/.config/sops/age +age-keygen -o ~/.config/sops/age/keys.txt +chmod 600 ~/.config/sops/age/keys.txt +grep 'public key' ~/.config/sops/age/keys.txt +``` + +Générez **aussi une clé de secours**, stockée hors ligne (papier dans un coffre, +ou clé USB chiffrée rangée ailleurs). Les deux clés publiques iront dans +`infra/prod/.sops.yaml`. Sans clé de secours, la perte de votre poste = la perte +définitive de tous les secrets de production. + +--- + +## 6. Contrôle avant de passer à la suite + +``` +[ ] Projet Hetzner Cloud `xpeditis-prod` créé, 2FA activée +[ ] Storage Box BX11 commandée (l'activation prend jusqu'à 1 h) +[ ] Zone Cloudflare active pour xpeditis.com, 2FA activée +[ ] Compte Stripe en mode Live, webhook de production créé +[ ] Nouvelle clé SMTP Brevo de production créée +[ ] Domaine expéditeur vérifié chez Brevo (SPF/DKIM prêts à publier) +[ ] ops@xpeditis.com relevée +[ ] IP d'administration connue et stable +[ ] Outils installés (terraform, kubectl, sops, age, hcloud, jq) +[ ] 3 clés SSH générées et sauvegardées +[ ] Clé age principale + clé de secours générées +[ ] Webhooks Discord créés +``` + +→ **Suite : [02 — Provisioning Hetzner](./02-provisioning-hetzner.md)** diff --git a/docs/mise-en-prod/02-provisioning-hetzner.md b/docs/mise-en-prod/02-provisioning-hetzner.md new file mode 100644 index 0000000..38f230a --- /dev/null +++ b/docs/mise-en-prod/02-provisioning-hetzner.md @@ -0,0 +1,211 @@ +# 02 — Provisioning Hetzner + +**Durée : environ 1 h.** Création des serveurs, du réseau privé, des firewalls +et du volume de données, entièrement décrite en Terraform. + +> Le fichier de prévisions de coûts note explicitement le risque +> « bus factor DevOps — Terraform/IaC obligatoire ». C'est la raison d'être de +> ce dossier : l'infrastructure est reconstructible par quelqu'un d'autre, à +> partir du dépôt seul. + +--- + +## 1. Token API Hetzner + +Console Hetzner → projet `xpeditis-prod` → **Security → API tokens** → +*Generate API token*. + +- Description : `terraform-prod` +- Permissions : **Read & Write** + +Le token n'est affiché **qu'une fois**. Copiez-le dans votre gestionnaire de +mots de passe immédiatement. + +Créez un **second token** nommé `github-actions-cicd`, également Read & Write. +Il servira uniquement au firewall temporaire de la CI, ce qui permet de le +révoquer sans casser Terraform. + +--- + +## 2. Configuration + +```bash +cd infra/prod/terraform +cp terraform.tfvars.example terraform.tfvars +$EDITOR terraform.tfvars +``` + +À renseigner : + +```hcl +hcloud_token = "" +ssh_public_key = "ssh-ed25519 AAAA... xpeditis-prod-admin" # cat ~/.ssh/xpeditis_prod.pub +admin_ip_allowlist = ["/32"] # curl -s https://ifconfig.me +``` + +Le reste des valeurs par défaut correspond au dimensionnement de la phase 1 : +`cpx41` pour l'application, `cpx31` pour les données, volume de 50 Go, +sauvegardes Hetzner activées. + +> `terraform.tfvars` est ignoré par Git (`infra/prod/.gitignore`). Vérifiez-le +> avant tout commit : `git status` ne doit pas le mentionner. + +--- + +## 3. Vérifier avant d'appliquer + +```bash +make -C .. tf-init # ou : terraform init +make -C .. tf-plan # ou : terraform plan +``` + +Le plan doit annoncer **8 ressources à créer** : + +``` +hcloud_network.main +hcloud_network_subnet.main +hcloud_ssh_key.admin +hcloud_placement_group.spread +hcloud_firewall.app +hcloud_firewall.db +hcloud_firewall.cicd +hcloud_server.app +hcloud_server.db +hcloud_volume.pgdata +``` + +Trois points à relire dans le plan : + +1. `hcloud_firewall.app` — les règles 22 et 6443 portent **uniquement** votre + IP. Si vous y voyez `0.0.0.0/0`, arrêtez tout. +2. `hcloud_firewall.db` — **seul le port 22** est ouvert. Ni 5432, ni 6379. +3. `hcloud_firewall.cicd` — **aucune règle**. C'est normal : la CI les injecte + puis les retire. + +--- + +## 4. Appliquer + +```bash +make -C .. tf-apply +``` + +Environ 90 secondes. Puis : + +```bash +terraform output +``` + +Notez précieusement : + +``` +app_public_ipv4 = "..." → tous les enregistrements DNS Cloudflare +db_public_ipv4 = "..." → SSH d'administration UNIQUEMENT, jamais en DNS +db_private_ip = "10.10.1.20" +pgdata_volume_device = "/dev/disk/by-id/scsi-0HC_Volume_..." +``` + +--- + +## 5. Vérifier l'accès + +```bash +ssh -i ~/.ssh/xpeditis_prod deploy@ 'hostname; uptime' +ssh -i ~/.ssh/xpeditis_prod deploy@ 'hostname; uptime' +``` + +cloud-init met une à deux minutes après la création du serveur. Si la connexion +est refusée, attendez puis vérifiez : + +```bash +ssh -i ~/.ssh/xpeditis_prod deploy@ 'ls -l /var/log/cloud-init-xpeditis-done' +``` + +Le réseau privé doit fonctionner dans les deux sens : + +```bash +ssh deploy@ 'ping -c2 10.10.1.20' +``` + +--- + +## 6. Confirmer que la base est bien inaccessible + +**Depuis un autre réseau que votre IP d'administration** (partage de connexion +mobile, par exemple) : + +```bash +nmap -Pn -p 22,80,443,5432,6379,6443 +``` + +Attendu : **tous les ports `filtered`**, y compris le 22 — puisque vous testez +depuis une IP non autorisée. + +```bash +nmap -Pn -p 22,80,443,5432,6379,6443 +``` + +Attendu à ce stade : `80` et `443` ouverts uniquement si +`restrict_http_to_cloudflare = false`. Avec la valeur par défaut `true`, ils +apparaissent aussi `filtered` tant que la requête ne vient pas de Cloudflare — +c'est exactement l'effet recherché. + +--- + +## 7. Storage Box + +La Storage Box se commande séparément (Robot Hetzner, pas la console Cloud). + +1. Commandez une **BX11** (1 To, 3,90 €/mois). +2. Dans son interface : activez **SSH** et **désactivez** Samba/CIFS et WebDAV, + inutiles ici et exposés sur Internet. +3. Déposez la clé publique dédiée : + +```bash +# Depuis votre poste +ssh-copy-id -p 23 -i ~/.ssh/xpeditis_storagebox.pub uXXXXXX@uXXXXXX.your-storagebox.de + +# Créez l'arborescence +ssh -p 23 -i ~/.ssh/xpeditis_storagebox uXXXXXX@uXXXXXX.your-storagebox.de \ + 'mkdir -p xpeditis-prod/dumps' +``` + +La clé **privée** correspondante devra être déposée sur db-01 en +`/root/.ssh/storagebox` ([04 — Nœud de données](./04-noeud-donnees.md)). + +> **Activez les *snapshots* de la Storage Box** (gratuits, dans son interface). +> Ils protègent contre le cas où un rançongiciel présent sur db-01 chiffrerait +> ou effacerait aussi les sauvegardes distantes via la connexion SSH existante. + +--- + +## 8. Sauvegarder l'état Terraform + +`terraform.tfstate` décrit toute votre infrastructure. Il est gitignoré à +dessein (il contient des données sensibles), mais **le perdre signifie que +Terraform ne reconnaîtra plus vos ressources** et proposera de tout recréer. + +```bash +# Sauvegarde chiffrée dans le dépôt +sops -e terraform.tfstate > ../terraform-state-backup.sops.json +``` + +Ou, mieux, migrez vers un backend S3 sur Hetzner Object Storage : le bloc +`backend "s3"` est prêt, en commentaire, dans `versions.tf`. + +--- + +## 9. Contrôle + +``` +[ ] terraform apply passé sans erreur +[ ] SSH fonctionne sur app-01 et db-01 avec la clé d'administration +[ ] Le ping app-01 → 10.10.1.20 répond +[ ] nmap depuis une IP non autorisée : tout est filtered +[ ] Storage Box commandée, SSH activé, Samba/WebDAV désactivés +[ ] Snapshots de la Storage Box activés +[ ] État Terraform sauvegardé +[ ] Les deux IP publiques notées dans le gestionnaire de mots de passe +``` + +→ **Suite : [03 — Durcissement des serveurs](./03-durcissement-serveurs.md)** diff --git a/docs/mise-en-prod/03-durcissement-serveurs.md b/docs/mise-en-prod/03-durcissement-serveurs.md new file mode 100644 index 0000000..71bcae3 --- /dev/null +++ b/docs/mise-en-prod/03-durcissement-serveurs.md @@ -0,0 +1,180 @@ +# 03 — Durcissement des serveurs + +**Durée : environ 1 h pour les deux serveurs.** + +cloud-init a posé le minimum vital (compte `deploy`, SSH par clé, UFW fermé). +Ce script va nettement plus loin et rend le résultat vérifiable. + +--- + +## 1. Ce que fait `00-bootstrap-common.sh` + +| Domaine | Mesure | Pourquoi | +|---|---|---| +| Mises à jour | `unattended-upgrades`, redémarrage automatique à 04h30 si le noyau l'exige | Une faille non corrigée pendant six mois est la première cause de compromission d'un serveur laissé seul. | +| SSH | Clé uniquement, `root` interdit, 3 tentatives, algorithmes modernes, `AllowUsers deploy` | Supprime l'attaque par mot de passe et réduit la surface cryptographique. | +| fail2ban | Bannissement 24 h après 3 échecs, réseau privé exclu | Ralentit le balayage automatisé permanent d'Internet. | +| sysctl | Anti-spoofing, pas de redirections ICMP, `kptr_restrict`, `ptrace_scope` | Rend l'escalade locale nettement plus difficile après une compromission applicative. | +| journald | Plafonné à 2 Go, 30 jours | Des journaux qui remplissent le disque provoquent une panne totale. | +| auditd | Trace `passwd`, `shadow`, `sudoers`, `sshd_config`, commandes root | Sans journal d'audit, une intrusion est indémontrable. | +| chrony | Fuseau Europe/Paris, NTP | Une horloge décalée invalide les JWT, casse TLS et rend les `audit_logs` inexploitables. | +| UFW | Refus par défaut, ouvertures selon le rôle | Le firewall Hetzner ne filtre **que** les interfaces publiques : le trafic du réseau privé n'est filtré que par UFW. | + +--- + +## 2. Exécution + +### app-01 + +```bash +scp -i ~/.ssh/xpeditis_prod infra/prod/scripts/00-bootstrap-common.sh \ + deploy@:/tmp/ + +ssh -i ~/.ssh/xpeditis_prod deploy@ \ + 'sudo bash /tmp/00-bootstrap-common.sh app' +``` + +### db-01 + +`APP_PRIVATE_IP` détermine qui a le droit d'atteindre PostgreSQL et Redis. + +```bash +scp -i ~/.ssh/xpeditis_prod infra/prod/scripts/00-bootstrap-common.sh \ + deploy@:/tmp/ + +ssh -i ~/.ssh/xpeditis_prod deploy@ \ + 'sudo APP_PRIVATE_IP=10.10.1.10 bash /tmp/00-bootstrap-common.sh data' +``` + +> **Gardez la session SSH ouverte** pendant que le script tourne. Il redémarre +> `sshd` : si la configuration était invalide, `sshd -t` échouerait avant le +> redémarrage, mais mieux vaut pouvoir corriger sans passer par la console de +> secours Hetzner. Ouvrez un **second terminal** et vérifiez que vous arrivez +> encore à vous connecter **avant** de fermer le premier. + +--- + +## 3. Vérifications + +Sur chaque serveur : + +```bash +ssh deploy@ ' + echo "--- SSH ---" + sudo sshd -T | grep -E "^(permitrootlogin|passwordauthentication|maxauthtries|allowusers)" + echo "--- Services ---" + systemctl is-active fail2ban unattended-upgrades auditd chrony + echo "--- Pare-feu ---" + sudo ufw status verbose + echo "--- Horloge ---" + timedatectl | grep -E "Time zone|synchronized" +' +``` + +Attendu : + +``` +permitrootlogin no +passwordauthentication no +maxauthtries 3 +allowusers deploy +active × 4 +Status: active (avec les règles correspondant au rôle) +System clock synchronized: yes +``` + +### Règles UFW attendues + +**app-01** +``` +22/tcp ALLOW Anywhere # SSH (filtré en amont par Hetzner) +80/tcp ALLOW Anywhere # HTTP (filtré en amont par Cloudflare) +443/tcp ALLOW Anywhere # HTTPS +6443/tcp ALLOW Anywhere # API k3s (filtré en amont) +``` + +**db-01** +``` +22/tcp ALLOW Anywhere +5432/tcp ALLOW 10.10.1.10 # PostgreSQL ← app-01 uniquement +6379/tcp ALLOW 10.10.1.10 # Redis ← app-01 uniquement +``` + +Si `5432 ALLOW Anywhere` apparaît sur db-01, **arrêtez et corrigez** : la base +serait joignable depuis n'importe quelle machine du réseau privé. + +--- + +## 4. Durcissement complémentaire (recommandé) + +### 4.1 Protéger la console Hetzner + +La console web Hetzner donne un accès clavier au serveur, **sans passer par +SSH**. Elle contourne donc tout ce qui précède. + +- Activez la 2FA sur le compte Hetzner (si ce n'est pas déjà fait). +- Ne définissez **jamais** de mot de passe root sur les serveurs : sans mot de + passe, la console ne permet pas de se connecter. + +Vérifiez qu'aucun mot de passe n'est défini : +```bash +ssh deploy@ 'sudo passwd -S root' # doit afficher "L" (locked) +``` + +### 4.2 Alertes de connexion SSH + +Pour être prévenu de toute connexion réussie : + +```bash +ssh deploy@ 'sudo tee /etc/profile.d/99-ssh-alert.sh >/dev/null' <<'EOF' +#!/bin/sh +# Notifie Discord a chaque ouverture de session SSH interactive. +[ -n "$SSH_CONNECTION" ] || return 0 +[ -f /etc/xpeditis/discord-webhook ] || return 0 +WEBHOOK=$(cat /etc/xpeditis/discord-webhook) +curl -sf -m 5 -H 'Content-Type: application/json' \ + -d "{\"content\":\"SSH sur \`$(hostname)\` : ${USER} depuis ${SSH_CONNECTION%% *}\"}" \ + "$WEBHOOK" >/dev/null 2>&1 & +EOF + +ssh deploy@ ' + sudo mkdir -p /etc/xpeditis + echo "" | sudo tee /etc/xpeditis/discord-webhook >/dev/null + sudo chmod 600 /etc/xpeditis/discord-webhook +' +``` + +Une notification pour chacune de vos propres connexions est le prix à payer +pour repérer immédiatement celle qui n'est pas la vôtre. + +### 4.3 Bannissement permanent des récidivistes + +```bash +ssh deploy@ 'sudo tee /etc/fail2ban/jail.d/recidive.local >/dev/null' <<'EOF' +[recidive] +enabled = true +logpath = /var/log/fail2ban.log +banaction = iptables-allports +bantime = 1w +findtime = 1d +maxretry = 3 +EOF +ssh deploy@ 'sudo systemctl restart fail2ban' +``` + +--- + +## 5. Contrôle + +``` +[ ] Script exécuté sur app-01 (rôle app) et db-01 (rôle data) +[ ] SSH : root interdit, mot de passe interdit, AllowUsers deploy +[ ] fail2ban, unattended-upgrades, auditd, chrony actifs sur les deux +[ ] UFW db-01 : 5432 et 6379 restreints à 10.10.1.10 +[ ] Horloge synchronisée sur les deux +[ ] Compte root verrouillé (passwd -S root → L) +[ ] Une seconde session SSH a été testée avant de fermer la première +[ ] Alertes SSH Discord en place (optionnel mais recommandé) +``` + +→ **Suite : [04 — Nœud de données](./04-noeud-donnees.md)** diff --git a/docs/mise-en-prod/04-noeud-donnees.md b/docs/mise-en-prod/04-noeud-donnees.md new file mode 100644 index 0000000..d7e18e4 --- /dev/null +++ b/docs/mise-en-prod/04-noeud-donnees.md @@ -0,0 +1,337 @@ +# 04 — Nœud de données (PostgreSQL + Redis) + +**Durée : environ 2 h.** C'est l'étape la plus délicate : c'est la seule dont +les erreurs ne se rattrapent pas en redéployant. + +> **Prérequis** — WAL-G archive les journaux de transaction dès le premier +> démarrage de PostgreSQL. Créez d'abord le bucket de sauvegarde et ses clés : +> [07 — Stockage objet, sections 1 et 2](./07-stockage-objet-s3.md). Revenez +> ensuite ici. Sans le bucket, PostgreSQL démarre quand même mais accumule ses +> WAL sur disque jusqu'à saturation. + +--- + +## 1. Choix d'architecture, et pourquoi + +**PostgreSQL est hors de Kubernetes.** Ce n'est pas un raccourci, c'est un +choix : + +- Un `StatefulSet` PostgreSQL impose des volumes persistants, un ordre de + démarrage, et transforme chaque montée de version majeure en opération à + risque. +- Hors cluster, `pg_dump`, WAL-G, la restauration à un instant T et les tests + de restauration sont des commandes ordinaires. +- Le nœud applicatif redevient entièrement jetable : on peut le détruire et le + reconstruire sans jamais approcher les données. + +**Le trafic vers la base est chiffré.** Le réseau privé Hetzner isole les +projets mais ne chiffre pas les paquets. `pg_hba.conf` n'accepte que des lignes +`hostssl` : une connexion en clair est refusée, y compris depuis app-01. + +--- + +## 2. Installation + +```bash +scp -i ~/.ssh/xpeditis_prod infra/prod/scripts/01-setup-data-node.sh \ + deploy@:/tmp/ + +ssh -i ~/.ssh/xpeditis_prod deploy@ \ + 'sudo bash /tmp/01-setup-data-node.sh' +``` + +Le script : +1. formate (si vierge) et monte le volume Hetzner sur `/var/lib/xpeditis/pgdata`, + avec `nofail` pour que le serveur démarre même si le volume manque ; +2. installe Docker Engine depuis le dépôt officiel et le durcit + (`no-new-privileges`, `icc: false`, rotation des journaux) ; +3. génère le certificat TLS interne de PostgreSQL ; +4. installe `age` ; +5. installe les timers systemd de sauvegarde. + +Il **ne démarre pas** la base : les secrets ne sont pas encore là. + +### Vérifier le volume + +```bash +ssh deploy@ 'df -h /var/lib/xpeditis/pgdata; lsblk' +``` + +Le point de montage doit apparaître avec la taille du volume (50 Go), pas celle +du disque système. Si le volume n'a pas été détecté, `/var/lib/xpeditis/pgdata` +est resté sur le disque système : corrigez **avant** d'écrire la moindre donnée. + +--- + +## 3. Copier la configuration + +```bash +# Depuis le dépôt, sur votre poste +rsync -az -e "ssh -i ~/.ssh/xpeditis_prod" \ + infra/prod/data-node/ \ + deploy@:/tmp/data-node/ + +ssh -i ~/.ssh/xpeditis_prod deploy@ ' + sudo mkdir -p /opt/xpeditis/data-node + sudo rsync -a /tmp/data-node/ /opt/xpeditis/data-node/ + sudo chown -R root:root /opt/xpeditis/data-node + sudo chmod +x /opt/xpeditis/data-node/backup/*.sh + rm -rf /tmp/data-node +' +``` + +Relancez ensuite le script d'installation. Il est idempotent : cette seconde +passe ne refait rien de ce qui est déjà en place, mais elle trouve désormais les +fichiers `backup/` et installe les timers systemd. + +```bash +ssh -i ~/.ssh/xpeditis_prod deploy@ \ + 'sudo bash /tmp/01-setup-data-node.sh' +``` + +--- + +## 4. Secrets + +### 4.1 Générer les mots de passe + +Sur **votre poste**, jamais sur le serveur (l'historique du shell garde tout) : + +```bash +echo "POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -d '\n/+=' | head -c 40)" +echo "REDIS_PASSWORD=$(openssl rand -base64 32 | tr -d '\n/+=' | head -c 40)" +# WAL-G attend 32 octets en HEXADÉCIMAL (64 caractères), pas en base64. +echo "WALG_LIBSODIUM_KEY=$(openssl rand -hex 32)" +``` + +Générez aussi la paire age dédiée au chiffrement des dumps : + +```bash +age-keygen -o /tmp/backup-age.key +grep 'public key' /tmp/backup-age.key # → BACKUP_AGE_RECIPIENT +``` + +> `WALG_LIBSODIUM_KEY` et la clé privée `backup-age.key` sont **les deux clés +> sans lesquelles aucune restauration n'est possible**. Sauvegardez-les dans +> votre gestionnaire de mots de passe **et** hors ligne, avant d'aller plus +> loin. Une sauvegarde chiffrée dont on a perdu la clé est un fichier inutile. + +### 4.2 Composer le fichier d'environnement + +```bash +cp infra/prod/env/data-node.env.example /tmp/.env.data +$EDITOR /tmp/.env.data +``` + +Renseignez : `POSTGRES_PASSWORD`, `REDIS_PASSWORD`, les identifiants WAL-G +(bucket créé en [07](./07-stockage-objet-s3.md)), `WALG_LIBSODIUM_KEY`, +`BACKUP_AGE_RECIPIENT`, les coordonnées de la Storage Box, les webhooks. + +### 4.3 Chiffrer pour Git, déposer en clair sur le serveur + +```bash +# Version chiffrée, versionnée +cd infra/prod +sops -e --input-type dotenv --output-type dotenv /tmp/.env.data \ + > data-node/data-node.sops.env + +# Version en clair, uniquement sur db-01 +scp -i ~/.ssh/xpeditis_prod /tmp/.env.data deploy@:/tmp/ +ssh -i ~/.ssh/xpeditis_prod deploy@ ' + sudo install -m 600 -o root -g root /tmp/.env.data /opt/xpeditis/data-node/.env.data + shred -u /tmp/.env.data +' + +# Effacer la copie locale +shred -u /tmp/.env.data +``` + +### 4.4 Clés privées sur db-01 + +```bash +# Clé age de déchiffrement des dumps +scp -i ~/.ssh/xpeditis_prod /tmp/backup-age.key deploy@:/tmp/ +ssh -i ~/.ssh/xpeditis_prod deploy@ ' + sudo mkdir -p /root/.config/xpeditis && sudo chmod 700 /root/.config/xpeditis + sudo install -m 600 -o root -g root /tmp/backup-age.key /root/.config/xpeditis/backup-age.key + shred -u /tmp/backup-age.key +' +shred -u /tmp/backup-age.key + +# Clé SSH vers la Storage Box +scp -i ~/.ssh/xpeditis_prod ~/.ssh/xpeditis_storagebox deploy@:/tmp/sb.key +ssh -i ~/.ssh/xpeditis_prod deploy@ ' + sudo install -m 600 -o root -g root /tmp/sb.key /root/.ssh/storagebox + shred -u /tmp/sb.key + # Enregistre l empreinte de la Storage Box : StrictHostKeyChecking=yes + # échouerait sinon, et le rsync de sauvegarde avec lui. + sudo ssh-keyscan -p 23 uXXXXXX.your-storagebox.de | sudo tee -a /root/.ssh/known_hosts +' +``` + +--- + +## 5. Démarrage + +```bash +ssh -i ~/.ssh/xpeditis_prod deploy@ +cd /opt/xpeditis/data-node +sudo docker compose -f docker-compose.data.yml --env-file .env.data up -d --build +``` + +La construction de l'image PostgreSQL + WAL-G prend deux à trois minutes. + +```bash +sudo docker compose -f docker-compose.data.yml --env-file .env.data ps +sudo docker compose -f docker-compose.data.yml --env-file .env.data logs postgres | tail -40 +``` + +Attendu dans les journaux : +``` +database system is ready to accept connections +``` + +Si PostgreSQL refuse de démarrer, la cause est presque toujours l'une de ces +trois : + +| Message | Cause | Correction | +|---|---|---| +| `could not load server certificate file` | droits du certificat | `chown 999:999` et `chmod 600` sur `/var/lib/xpeditis/certs/server.key` | +| `unrecognized configuration parameter` | faute de frappe dans `postgresql.conf` | corriger, puis `docker compose restart postgres` | +| `data directory has wrong ownership` | volume monté après le premier démarrage | `chown -R 999:999 /var/lib/xpeditis/pgdata` | + +--- + +## 6. Vérifications + +### 6.1 TLS obligatoire + +```bash +# Depuis db-01 : connexion locale (socket) — doit fonctionner +sudo docker compose exec -u postgres postgres psql -c 'SELECT version();' + +# Le chiffrement est-il actif ? +sudo docker compose exec -u postgres postgres \ + psql -c "SELECT name, setting FROM pg_settings WHERE name IN ('ssl','password_encryption');" +``` + +Attendu : `ssl = on`, `password_encryption = scram-sha-256`. + +### 6.2 Depuis app-01, la connexion doit passer en TLS et **uniquement** en TLS + +```bash +ssh deploy@ +sudo apt-get install -y postgresql-client + +# Doit RÉUSSIR +PGPASSWORD='' psql "host=10.10.1.20 port=5432 dbname=xpeditis_prod user=xpeditis sslmode=require" -c '\conninfo' + +# Doit ÉCHOUER — c'est le résultat attendu +PGPASSWORD='' psql "host=10.10.1.20 port=5432 dbname=xpeditis_prod user=xpeditis sslmode=disable" -c 'SELECT 1;' +``` + +Le second doit renvoyer : +``` +FATAL: no pg_hba.conf entry for host "10.10.1.10", ... SSL off +``` + +Si la connexion **sans** TLS réussit, `pg_hba.conf` n'a pas été pris en compte : +vérifiez que le conteneur est bien lancé avec `-c hba_file=/etc/postgresql/pg_hba.conf`. + +### 6.3 Redis + +```bash +# Depuis app-01 +redis-cli -h 10.10.1.20 -a '' --no-auth-warning ping # PONG +redis-cli -h 10.10.1.20 --no-auth-warning ping # NOAUTH +``` + +### 6.4 Rien n'est exposé publiquement + +**Depuis un autre réseau :** +```bash +nmap -Pn -p 5432,6379,9187 +``` +Les trois doivent être `filtered`. + +--- + +## 7. Première sauvegarde + +Elle doit être prise **avant** que la moindre donnée réelle n'existe : cela +valide la chaîne complète pendant que l'enjeu est nul. + +```bash +ssh deploy@ 'sudo systemctl start xpeditis-backup.service' +ssh deploy@ 'sudo journalctl -u xpeditis-backup -n 60 --no-pager' +``` + +Attendu : +``` +WAL-G basebackup : termine +pg_dump : NNNNN octets +Envoi vers la Storage Box ... +Sauvegarde terminee. +``` + +Vérifiez les deux destinations : + +```bash +# WAL-G sur Object Storage +ssh deploy@ \ + 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres wal-g backup-list --detail' + +# Dumps sur la Storage Box +ssh -p 23 -i ~/.ssh/xpeditis_storagebox uXXXXXX@uXXXXXX.your-storagebox.de \ + 'ls -lh xpeditis-prod/dumps/' +``` + +### Timers actifs + +```bash +ssh deploy@ "systemctl list-timers 'xpeditis-*' --no-pager" +``` + +Attendu : `xpeditis-backup.timer` (quotidien 02h30) et +`xpeditis-backup-verify.timer` (dimanche 04h00). + +--- + +## 8. Réglages à connaître + +| Paramètre | Valeur | Conséquence | +|---|---|---| +| `shared_buffers` | 2 Go | Sur 8 Go de RAM, dont 1,5 Go plafonnés pour Redis. | +| `max_connections` | 150 | 2 replicas backend + marge d'exploitation. Une alerte se déclenche à 80 %. | +| `statement_timeout` | 5 min | Borne les requêtes folles tout en laissant passer les imports de grilles CSV. | +| `archive_timeout` | 5 min | **Plafonne la perte de données à 5 minutes** en cas de perte totale du serveur. | +| `log_min_duration_statement` | 500 ms | Toute requête lente est tracée dans Loki. | +| `log_statement` | `ddl` | Chaque changement de schéma (migration) laisse une trace. | +| Redis `maxmemory-policy` | `volatile-lru` | Seules les clés à TTL sont évincées. Voir l'avertissement ci-dessous. | + +> **Redis et l'éviction.** `volatile-lru` n'évince que les clés porteuses d'un +> TTL — les cotations tarifaires, qui sont sacrifiables. Mais les entrées de +> révocation de jetons ont elles aussi un TTL : sous saturation mémoire, l'une +> d'elles pourrait être évincée, redonnant validité à un jeton révoqué. +> `maxmemory` est fixé à 1 Go pour 0,5 Go estimé, et une alerte se déclenche à +> 75 % d'occupation. Si elle se déclenche, augmentez `maxmemory` — ne changez +> pas la politique d'éviction. + +--- + +## 9. Contrôle + +``` +[ ] Volume Hetzner monté sur /var/lib/xpeditis/pgdata (bonne taille) +[ ] PostgreSQL démarré, ssl = on, password_encryption = scram-sha-256 +[ ] Connexion TLS depuis app-01 : OK +[ ] Connexion NON TLS depuis app-01 : REFUSÉE +[ ] Redis répond avec mot de passe, refuse sans +[ ] 5432 / 6379 / 9187 filtered depuis Internet +[ ] Première sauvegarde réussie, visible sur Object Storage ET Storage Box +[ ] Timers xpeditis-backup et xpeditis-backup-verify actifs +[ ] WALG_LIBSODIUM_KEY et backup-age.key sauvegardées hors ligne +[ ] .env.data en clair effacé du poste et de /tmp du serveur +``` + +→ **Suite : [05 — Cluster k3s](./05-cluster-k3s.md)** diff --git a/docs/mise-en-prod/05-cluster-k3s.md b/docs/mise-en-prod/05-cluster-k3s.md new file mode 100644 index 0000000..4c6cfbc --- /dev/null +++ b/docs/mise-en-prod/05-cluster-k3s.md @@ -0,0 +1,181 @@ +# 05 — Cluster k3s + +**Durée : environ 1 h 30.** + +k3s est une distribution Kubernetes complète en un seul binaire. Sur un nœud, il +consomme environ 500 Mo de RAM — comparable à Docker Swarm, pour un modèle de +déploiement bien plus riche (sondes, rolling updates sans coupure, politiques +réseau, quotas). + +--- + +## 1. Installation + +```bash +scp -i ~/.ssh/xpeditis_prod infra/prod/scripts/02-setup-k3s-server.sh \ + deploy@:/tmp/ + +ssh -i ~/.ssh/xpeditis_prod deploy@ \ + "sudo K3S_VERSION=v1.31.5+k3s1 PUBLIC_IP= PRIVATE_IP=10.10.1.10 \ + bash /tmp/02-setup-k3s-server.sh" +``` + +Comptez trois à cinq minutes. + +### Ce que le script durcit, et pourquoi + +| Option | Effet | +|---|---| +| `--secrets-encryption` | Les `Secret` sont chiffrés dans la base d'état de k3s. Sans cela, un instantané de disque ou un vol de volume livre **tous** les secrets en clair. | +| `--protect-kernel-defaults` | kubelet refuse de démarrer si les `sysctl` attendus ne sont pas posés. Échec bruyant plutôt que dérive silencieuse. | +| `audit-log-*` | Journal d'audit de l'API, 30 jours. Sans lui, « qui a supprimé ce déploiement ? » reste sans réponse. La politique fournie ne journalise **jamais** le contenu des `Secret`. | +| `--write-kubeconfig-mode=0600` | Le kubeconfig n'est pas lisible par tous les utilisateurs du serveur. | +| `--advertise-address=10.10.1.10` | Le cluster s'annonce sur le réseau privé. | +| `--etcd-expose-metrics=false` | Réduit la surface exposée. | + +### Traefik + +Le script écrit `/var/lib/rancher/k3s/server/manifests/traefik-config.yaml` +**avant** l'installation, pour que k3s applique la configuration dès le premier +démarrage : + +- redirection HTTP → HTTPS au niveau de l'*entrypoint* — aucun Ingress ne peut + l'oublier ; +- **IP Cloudflare déclarées de confiance** pour `X-Forwarded-For` : sans cela, + la limitation de débit et les `audit_logs` verraient tous l'IP de Cloudflare, + et un seul abus bloquerait tout le monde ; +- journaux d'accès en JSON, en-têtes filtrés (seuls `User-Agent` et + `Cf-Connecting-Ip` sont conservés) — les autres peuvent contenir des jetons ; +- **tableau de bord Traefik désactivé** ; +- métriques Prometheus activées. + +--- + +## 2. Récupérer le kubeconfig + +```bash +ssh -i ~/.ssh/xpeditis_prod deploy@ 'cat ~/.kube/config' \ + | sed "s/127.0.0.1//" > ~/.kube/xpeditis-prod.yaml +chmod 600 ~/.kube/xpeditis-prod.yaml + +export KUBECONFIG=~/.kube/xpeditis-prod.yaml +kubectl get nodes -o wide +``` + +> Ce fichier donne **les pleins pouvoirs** sur le cluster. Traitez-le comme une +> clé privée : `chmod 600`, jamais dans Git, jamais dans un secret GitHub. +> Il n'est utilisable que depuis vos IP d'administration (firewall Hetzner). + +Pour l'utiliser en permanence : +```bash +echo 'export KUBECONFIG=~/.kube/xpeditis-prod.yaml' >> ~/.zshrc +``` + +--- + +## 3. Vérifications + +```bash +kubectl get nodes +# NAME STATUS ROLES VERSION +# xpeditis-prod-app-01 Ready control-plane,master v1.31.5+k3s1 + +kubectl -n kube-system get pods +# coredns, local-path-provisioner, metrics-server, traefik : tous Running + +# Chiffrement des Secrets au repos +ssh deploy@ 'sudo k3s secrets-encrypt status' +# Encryption Status: Enabled + +# Journal d'audit alimenté +ssh deploy@ 'sudo tail -2 /var/log/k3s/audit.log | head -c 300' + +# Traefik écoute bien sur 80 et 443 +ssh deploy@ 'sudo ss -tlnp | grep -E ":(80|443) "' +``` + +--- + +## 4. Composants du cluster + +Depuis votre poste, kubeconfig chargé : + +```bash +cd infra/prod +REGISTRY_TOKEN='' bash scripts/03-install-cluster-addons.sh +``` + +Le script installe : +1. les namespaces `xpeditis-prod` et `monitoring`, avec l'*admission de sécurité + des pods* — `restricted` pour l'application, `baseline` pour la supervision + (Promtail doit lire les journaux de l'hôte) ; +2. **cert-manager** depuis son manifeste officiel épinglé (pas de Helm à + maintenir) ; +3. le `Secret` d'accès au registre Scaleway (`regcred`) ; +4. le `ClusterIssuer` Let's Encrypt — seulement si le secret Cloudflare existe + déjà, sinon il vous le rappelle. C'est normal à ce stade : il sera appliqué + après [06 — Secrets](./06-secrets-sops.md). + +```bash +kubectl -n cert-manager get pods # 3 pods Running +kubectl get ns # xpeditis-prod, monitoring, cert-manager +kubectl -n xpeditis-prod get secret regcred +``` + +--- + +## 5. Éprouver les garde-fous + +Cette étape est facultative mais rassurante : elle vérifie que les protections +mordent réellement. + +```bash +# Un pod privilégié doit être REFUSÉ par le namespace restricted +kubectl -n xpeditis-prod run test-privilegie --image=alpine --restart=Never \ + --overrides='{"spec":{"containers":[{"name":"c","image":"alpine","securityContext":{"privileged":true}}]}}' \ + -- sleep 10 +# Attendu : "violates PodSecurity restricted:latest" + +# Un pod root doit être REFUSÉ +kubectl -n xpeditis-prod run test-root --image=alpine --restart=Never \ + --overrides='{"spec":{"containers":[{"name":"c","image":"alpine","securityContext":{"runAsUser":0}}]}}' \ + -- sleep 10 +# Attendu : refus également +``` + +Si l'un des deux est **accepté**, les étiquettes du namespace n'ont pas été +appliquées : +```bash +kubectl apply -f infra/prod/k8s/base/00-namespaces.yaml +kubectl get ns xpeditis-prod -o jsonpath='{.metadata.labels}' | jq +``` + +--- + +## 6. Ce qu'il ne faut pas faire + +| À éviter | Pourquoi | +|---|---| +| Ouvrir 6443 à `0.0.0.0/0` | L'API Kubernetes exposée publiquement est une cible permanente. Terraform la restreint à vos IP. | +| Mettre le kubeconfig dans un secret GitHub | Un dépôt compromis donnerait le contrôle total du cluster. La CI passe par SSH avec une clé restreinte à un script. | +| Installer un tableau de bord Kubernetes | Surface d'attaque considérable pour un gain nul face à `kubectl` et Grafana. | +| Exécuter des charges de travail en `hostNetwork` | Seul `node-exporter` le fait, et c'est déjà un compromis assumé. | +| Désactiver les `NetworkPolicy` | Elles empêchent un pod compromis de balayer le réseau privé. | + +--- + +## 7. Contrôle + +``` +[ ] Nœud Ready, k3s v1.31.x +[ ] secrets-encrypt status = Enabled +[ ] Journal d'audit alimenté dans /var/log/k3s/audit.log +[ ] Traefik écoute sur 80 et 443, tableau de bord désactivé +[ ] cert-manager : 3 pods Running +[ ] Namespaces xpeditis-prod (restricted) et monitoring (baseline) créés +[ ] Secret regcred présent +[ ] Un pod privilégié est refusé +[ ] kubeconfig récupéré en chmod 600, hors de Git +``` + +→ **Suite : [06 — Secrets SOPS](./06-secrets-sops.md)** diff --git a/docs/mise-en-prod/06-secrets-sops.md b/docs/mise-en-prod/06-secrets-sops.md new file mode 100644 index 0000000..4d13bfc --- /dev/null +++ b/docs/mise-en-prod/06-secrets-sops.md @@ -0,0 +1,251 @@ +# 06 — Secrets (SOPS + age) + +**Durée : environ 1 h.** + +--- + +## 1. Pourquoi SOPS et pas autre chose + +| Approche | Pourquoi elle a été écartée | +|---|---| +| Secrets en clair dans le YAML | C'est ce que fait la preprod aujourd'hui. Résultat : mot de passe de base, `JWT_SECRET` et **clé SMTP Brevo** publiés dans l'historique Git. | +| Sealed Secrets | Bonne solution, mais elle ne chiffre **que** des `Secret` Kubernetes. Le fichier `.env.data` de db-01 (hors cluster) resterait sans solution. | +| Vault / OpenBao | Excellent, et une pièce d'infrastructure de plus à héberger, sauvegarder et desceller après chaque redémarrage. Disproportionné ici. | +| Secrets GitHub uniquement | Le dépôt deviendrait le coffre-fort de la production. Un jeton d'accès compromis livrerait les mots de passe de la base. | + +**SOPS + age** couvre les deux mondes avec une seule clé, sans composant à +héberger, et produit des différentiels lisibles en revue (seules les valeurs +sont chiffrées, pas les noms de clés). + +--- + +## 2. Créer les clés + +### Clé principale + +```bash +mkdir -p ~/.config/sops/age +age-keygen -o ~/.config/sops/age/keys.txt +chmod 600 ~/.config/sops/age/keys.txt +grep 'public key' ~/.config/sops/age/keys.txt +# public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p +``` + +### Clé de secours + +**Ne sautez pas cette étape.** Sans elle, perdre votre poste signifie perdre +définitivement l'accès à tous les secrets de production. + +```bash +age-keygen -o /tmp/xpeditis-backup-key.txt +cat /tmp/xpeditis-backup-key.txt +``` + +Trois destinations, au moins deux d'entre elles : +- imprimée sur papier, dans un coffre ou un lieu physique distinct ; +- sur une clé USB chiffrée, rangée ailleurs que votre poste ; +- dans un gestionnaire de mots de passe **différent** de celui du quotidien. + +```bash +shred -u /tmp/xpeditis-backup-key.txt +``` + +### Déclarer les deux clés + +```bash +cd infra/prod +$EDITOR .sops.yaml +``` + +Remplacez `AGE_RECIPIENT_PRIMARY` et `AGE_RECIPIENT_BACKUP` par les deux clés +publiques (`age1…`). + +--- + +## 3. Composer les secrets + +```bash +cp k8s/base/03-secrets.template.yaml /tmp/secrets.yaml +$EDITOR /tmp/secrets.yaml +``` + +### Générer les valeurs + +```bash +# JWT_SECRET — minimum 32 caractères (validé par Joi au démarrage) +openssl rand -base64 64 | tr -d '\n' + +# DOCUMENT_PASSWORD_SECRET — minimum 16 +openssl rand -base64 32 | tr -d '\n' +``` + +### Correspondances à ne pas manquer + +| Secret Kubernetes | Doit être identique à | +|---|---| +| `DATABASE_PASSWORD` | `POSTGRES_PASSWORD` de `/opt/xpeditis/data-node/.env.data` | +| `REDIS_PASSWORD` | `REDIS_PASSWORD` du même fichier | + +Une divergence produit un backend qui démarre puis boucle en +`CrashLoopBackOff` avec `password authentication failed`. + +### Trois pièges + +**1. Les identifiants de tarif Stripe.** Le code lit +`STRIPE_SILVER_*`, `STRIPE_GOLD_*`, `STRIPE_PLATINIUM_*`. Le stack de preprod +définit `STRIPE_STARTER_*`, `STRIPE_PRO_*`, `STRIPE_ENTERPRISE_*` — **des noms +que personne ne lit**, si bien qu'en preprod ces identifiants sont en réalité +absents. Utilisez les noms attendus par le code, ceux du gabarit. + +**2. `DOCUMENT_PASSWORD_SECRET`.** S'il est vide, le code retombe sur +`JWT_SECRET`. Une rotation ultérieure du `JWT_SECRET` rendrait alors illisibles +tous les documents transporteurs déjà émis. **Définissez-le explicitement, et +ne le changez plus jamais** sans plan de migration. + +**3. `SWAGGER_USERNAME` / `SWAGGER_PASSWORD`.** Laissez-les **vides**. `main.ts` +désactive alors totalement `/api/docs` en production. Les renseigner expose la +documentation complète de l'API derrière une simple authentification Basic. + +--- + +## 4. Chiffrer + +```bash +cd infra/prod +sops -e /tmp/secrets.yaml > k8s/base/03-secrets.sops.yaml +shred -u /tmp/secrets.yaml +``` + +### Vérifier avant de commiter + +```bash +# Les noms de clés restent lisibles, les valeurs sont chiffrées +grep -A3 'DATABASE_PASSWORD' k8s/base/03-secrets.sops.yaml +# DATABASE_PASSWORD: ENC[AES256_GCM,data:...,tag:...] + +# Aucune valeur en clair ne subsiste +grep -c 'ENC\[' k8s/base/03-secrets.sops.yaml # doit être > 20 + +# Le déchiffrement fonctionne +sops -d k8s/base/03-secrets.sops.yaml | head -20 +``` + +```bash +make secrets-check # refuse tout fichier sensible non ignoré +git add k8s/base/03-secrets.sops.yaml +git commit -m "prod: secrets chiffres SOPS" +``` + +--- + +## 5. Appliquer sur le cluster + +```bash +export KUBECONFIG=~/.kube/xpeditis-prod.yaml +make secrets-apply +``` + +Le déchiffré ne touche jamais le disque : `sops` écrit sur la sortie standard, +`kubectl` lit sur l'entrée standard. + +```bash +kubectl -n xpeditis-prod get secrets +kubectl -n monitoring get secrets +kubectl -n cert-manager get secret cloudflare-api-token +``` + +Appliquez maintenant le `ClusterIssuer`, qui attendait ce secret : + +```bash +kubectl apply -f k8s/cluster/cluster-issuer.yaml +kubectl get clusterissuer +``` + +--- + +## 6. Modifier un secret + +```bash +sops k8s/base/03-secrets.sops.yaml # ouvre votre éditeur, rechiffre en sortie +make secrets-apply + +# Indispensable : modifier un Secret ne redémarre pas les pods +kubectl -n xpeditis-prod rollout restart deploy/xpeditis-backend +``` + +--- + +## 7. Rotation + +| Secret | Fréquence | Effet | +|---|---|---| +| `JWT_SECRET` | annuelle, ou immédiatement en cas de fuite | **Déconnecte tous les utilisateurs.** À faire hors heures ouvrées. | +| `DATABASE_PASSWORD` | annuelle | Changer d'abord dans PostgreSQL (`ALTER ROLE`), puis dans le Secret, puis redémarrer. Brève coupure. | +| `REDIS_PASSWORD` | annuelle | Vider le cache est sans conséquence, les sessions le sont davantage : prévoyez une déconnexion. | +| `STRIPE_SECRET_KEY` | sur incident | Rotation depuis le tableau de bord Stripe. | +| Clé SMTP Brevo | sur incident | **À faire maintenant** : celle de preprod est publiée. | +| `DOCUMENT_PASSWORD_SECRET` | **jamais** | Rendrait illisibles les documents déjà émis. | +| Clé age | tous les 2 ans | `sops updatekeys` sur tous les fichiers après avoir modifié `.sops.yaml`. | + +### Rotation du mot de passe PostgreSQL, sans surprise + +```bash +# 1. Nouveau mot de passe côté base +ssh deploy@ 'cd /opt/xpeditis/data-node && \ + sudo docker compose exec -T -u postgres postgres \ + psql -c "ALTER ROLE xpeditis WITH PASSWORD '"'"''"'"';"' + +# 2. Le fichier .env.data de db-01 (postgres-exporter l'utilise) +ssh deploy@ 'sudo $EDITOR /opt/xpeditis/data-node/.env.data' +ssh deploy@ 'cd /opt/xpeditis/data-node && \ + sudo docker compose --env-file .env.data up -d postgres-exporter' + +# 3. Le Secret Kubernetes +sops k8s/base/03-secrets.sops.yaml +make secrets-apply +kubectl -n xpeditis-prod rollout restart deploy/xpeditis-backend + +# 4. La version chiffrée du .env.data +sops -e --input-type dotenv --output-type dotenv > data-node/data-node.sops.env +``` + +--- + +## 8. Perte de la clé age + +Si vous perdez la clé principale **et** la clé de secours, les secrets chiffrés +dans Git sont définitivement illisibles. La production continue de tourner (les +`Secret` sont déjà dans le cluster), mais vous ne pouvez plus les modifier ni +les redéployer ailleurs. + +Récupération d'urgence, tant que le cluster fonctionne : + +```bash +# Extraire les valeurs depuis le cluster +kubectl -n xpeditis-prod get secret xpeditis-backend-secrets -o json \ + | jq -r '.data | to_entries[] | "\(.key)=\(.value|@base64d)"' +``` + +Puis recréez une paire de clés age et rechiffrez. Faites-le **immédiatement** : +si le cluster tombe entre-temps, les secrets sont perdus. + +--- + +## 9. Contrôle + +``` +[ ] Clé age principale créée, chmod 600 +[ ] Clé de secours créée et rangée dans DEUX endroits physiques distincts +[ ] .sops.yaml renseigné avec les deux clés publiques +[ ] 03-secrets.sops.yaml créé, > 20 valeurs ENC[…] +[ ] DATABASE_PASSWORD et REDIS_PASSWORD identiques à ceux de db-01 +[ ] Identifiants de tarif Stripe aux noms SILVER / GOLD / PLATINIUM +[ ] DOCUMENT_PASSWORD_SECRET défini explicitement +[ ] SWAGGER_USERNAME et SWAGGER_PASSWORD laissés vides +[ ] Secrets appliqués sur le cluster +[ ] ClusterIssuer letsencrypt-prod créé +[ ] make secrets-check passe +[ ] /tmp/secrets.yaml effacé avec shred +``` + +→ **Suite : [07 — Stockage objet](./07-stockage-objet-s3.md)** diff --git a/docs/mise-en-prod/07-stockage-objet-s3.md b/docs/mise-en-prod/07-stockage-objet-s3.md new file mode 100644 index 0000000..7d8c865 --- /dev/null +++ b/docs/mise-en-prod/07-stockage-objet-s3.md @@ -0,0 +1,173 @@ +# 07 — Stockage objet (Hetzner Object Storage) + +**Durée : environ 45 min.** + +Hetzner Object Storage remplace MinIO en production. C'est un service +compatible S3 : **aucune ligne de code applicatif ne change**, seules les +variables `AWS_S3_ENDPOINT`, `AWS_REGION` et les clés diffèrent. + +| | MinIO auto-hébergé | Hetzner Object Storage | +|---|---|---| +| Coût | « gratuit » + le disque + votre temps | ~6 €/To/mois | +| À sauvegarder | oui, un volume de plus | non, réplication incluse | +| CVE à suivre | oui | non | +| Console à protéger | oui | non | +| Perte si app-01 brûle | **totale** | aucune | + +Le dernier point suffit à trancher : les connaissements et confirmations de +réservation sont des documents contractuels. + +--- + +## 1. Créer les buckets + +Console Hetzner → projet `xpeditis-prod` → **Object Storage** → région `fsn1`. + +Créez **deux** buckets : + +| Bucket | Contenu | Visibilité | +|---|---|---| +| `xpeditis-prod-documents` | PDF, connaissements, imports CSV | **Privé** | +| `xpeditis-prod-pgbackup` | Sauvegardes WAL-G | **Privé** | + +> **Deux buckets, deux jeux de clés.** Si les clés applicatives fuitent (fuite +> de secret, faille d'injection), l'attaquant atteint les documents mais **pas +> les sauvegardes**. C'est précisément ce qui permet de se relever d'un +> rançongiciel. Ne mutualisez pas. + +Vérifiez que les deux sont bien en **Private**. Un bucket public exposerait des +documents commerciaux nominatifs à toute personne devinant une URL. + +--- + +## 2. Créer les clés d'accès + +**Object Storage → Credentials → Generate credentials**, deux fois : + +| Nom | Bucket | Destination | +|---|---|---| +| `xpeditis-app` | `xpeditis-prod-documents` | Secret Kubernetes `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | +| `xpeditis-walg` | `xpeditis-prod-pgbackup` | `.env.data` de db-01, `WALG_ACCESS_KEY_ID` / `WALG_SECRET_ACCESS_KEY` | + +Le secret n'est affiché **qu'une fois**. Copiez-le immédiatement. + +> Hetzner Object Storage ne propose pas encore de politique par bucket aussi +> fine qu'AWS IAM. La séparation repose donc sur l'usage de deux jeux de clés +> distincts, chacun n'étant présent qu'à un seul endroit. Ne copiez jamais les +> clés WAL-G dans un `Secret` Kubernetes. + +--- + +## 3. Configuration applicative + +Déjà en place dans `infra/prod/k8s/base/02-configmap-backend.yaml` : + +```yaml +AWS_REGION: "fsn1" +AWS_S3_ENDPOINT: "https://fsn1.your-objectstorage.com" +AWS_S3_BUCKET: "xpeditis-prod-documents" +``` + +Les clés vont dans le `Secret` ([06](./06-secrets-sops.md)) : +```yaml +AWS_ACCESS_KEY_ID: "" +AWS_SECRET_ACCESS_KEY: "" +``` + +Et WAL-G dans `.env.data` sur db-01 ([04](./04-noeud-donnees.md)) : +``` +WALG_S3_PREFIX=s3://xpeditis-prod-pgbackup +WALG_S3_ENDPOINT=https://fsn1.your-objectstorage.com +WALG_S3_REGION=fsn1 +WALG_ACCESS_KEY_ID= +WALG_SECRET_ACCESS_KEY= +``` + +--- + +## 4. Vérifier + +Avec le client AWS ou `s3cmd` : + +```bash +export AWS_ACCESS_KEY_ID='' +export AWS_SECRET_ACCESS_KEY='' + +# Lister +aws --endpoint-url https://fsn1.your-objectstorage.com s3 ls s3://xpeditis-prod-documents/ + +# Écrire puis relire +echo "test $(date -Is)" > /tmp/test.txt +aws --endpoint-url https://fsn1.your-objectstorage.com s3 cp /tmp/test.txt s3://xpeditis-prod-documents/ +aws --endpoint-url https://fsn1.your-objectstorage.com s3 cp s3://xpeditis-prod-documents/test.txt - +aws --endpoint-url https://fsn1.your-objectstorage.com s3 rm s3://xpeditis-prod-documents/test.txt +``` + +### Le bucket est-il vraiment privé ? + +```bash +curl -sI https://fsn1.your-objectstorage.com/xpeditis-prod-documents/test.txt | head -1 +``` +Attendu : `HTTP/1.1 403 Forbidden`. Si vous obtenez `200`, le bucket est +public : corrigez immédiatement. + +### Les clés sont-elles bien cloisonnées ? + +```bash +# Les clés applicatives ne doivent PAS voir le bucket de sauvegarde +aws --endpoint-url https://fsn1.your-objectstorage.com s3 ls s3://xpeditis-prod-pgbackup/ +``` +Un refus est le résultat attendu. S'il réussit, vous avez utilisé les mêmes +clés pour les deux : régénérez-en un jeu séparé. + +--- + +## 5. Durée de conservation + +Une règle de cycle de vie évite que les documents s'accumulent indéfiniment — +utile pour la facture comme pour le RGPD ([16](./16-rgpd-conformite.md)). + +Attention : les documents contractuels maritimes ont des obligations de +conservation longues (généralement 10 ans en France pour les pièces +comptables). **Ne posez pas de règle de suppression automatique sur +`xpeditis-prod-documents`** sans validation juridique. + +Sur `xpeditis-prod-pgbackup`, en revanche, la purge est gérée par WAL-G +(`wal-g delete retain FULL 7`, dans `pg-backup.sh`). N'ajoutez pas de règle de +cycle de vie côté bucket : les deux mécanismes se contrediraient et vous +risqueriez de supprimer des WAL encore nécessaires à une restauration. + +--- + +## 6. Et le CDN ? + +Le fichier de prévisions retient Bunny.net (0,005 €/Go, ~1 €/mois en phase 1) +comme CDN si Hetzner est choisi. + +**Ce n'est pas nécessaire au lancement.** Cloudflare met déjà en cache les +ressources statiques de Next.js (`/_next/static/*`) gratuitement, et le trafic +de la phase 1 (10 Go/mois selon les hypothèses) ne justifie pas un service +supplémentaire. + +Reconsidérez la question quand : +- le trafic sortant dépasse 500 Go/mois ; ou +- des utilisateurs hors d'Europe se plaignent de la latence ; ou +- vous servez des documents volumineux directement depuis Object Storage. + +--- + +## 7. Contrôle + +``` +[ ] Bucket xpeditis-prod-documents créé, PRIVÉ +[ ] Bucket xpeditis-prod-pgbackup créé, PRIVÉ +[ ] Deux jeux de clés DISTINCTS générés +[ ] Écriture / lecture / suppression testées sur le bucket documents +[ ] Accès HTTP anonyme : 403 +[ ] Les clés applicatives ne peuvent PAS lire le bucket de sauvegarde +[ ] Clés applicatives dans le Secret Kubernetes +[ ] Clés WAL-G uniquement dans .env.data de db-01 +[ ] Aucune règle de cycle de vie sur le bucket de sauvegarde +``` + +→ **Suite : [08 — DNS, TLS, Cloudflare](./08-dns-tls-cloudflare.md)** diff --git a/docs/mise-en-prod/08-dns-tls-cloudflare.md b/docs/mise-en-prod/08-dns-tls-cloudflare.md new file mode 100644 index 0000000..daf5caf --- /dev/null +++ b/docs/mise-en-prod/08-dns-tls-cloudflare.md @@ -0,0 +1,294 @@ +# 08 — DNS, TLS et Cloudflare + +**Durée : environ 1 h 30**, dont des délais d'attente incompressibles. + +La référence complète des réglages Cloudflare est dans +[`infra/prod/cloudflare/README.md`](../../infra/prod/cloudflare/README.md). +Ce document donne l'ordre d'exécution et les vérifications. + +--- + +## 1. Ordre à respecter + +L'ordre compte : émettre un certificat avant que le DNS ne réponde échoue, et +Let's Encrypt limite la production à **5 échecs par heure**. + +``` +1. Enregistrements DNS chez Cloudflare (propagation : quelques minutes) +2. Réglages SSL/TLS Cloudflare +3. Certificat de TEST (staging) ← valide la chaîne DNS-01 +4. Certificat de PRODUCTION +5. Vérifications +6. Règles WAF et cache +``` + +--- + +## 2. Enregistrements DNS + +```bash +cd infra/prod/terraform && terraform output dns_records_to_create +``` + +Créez dans Cloudflare, **tous proxifiés (nuage orange)** : + +| Type | Nom | Valeur | +|---|---|---| +| A | `xpeditis.com` | `` | +| A | `www` | `` | +| A | `app` | `` | +| A | `api` | `` | +| A | `grafana` | `` | + +**Aucun enregistrement ne pointe vers db-01.** + +> Le proxy n'est pas optionnel : le firewall Hetzner n'accepte 80/443 que depuis +> les rangs Cloudflare. Un enregistrement en nuage gris (DNS only) donnerait un +> domaine injoignable — comportement voulu, mais déroutant si on l'a oublié. + +### E-mail — à faire maintenant + +``` +TXT xpeditis.com v=spf1 include:spf.brevo.com -all +TXT mail._domainkey +TXT _dmarc v=DMARC1; p=quarantine; rua=mailto:dmarc@xpeditis.com; pct=100 +``` + +Sans SPF/DKIM/DMARC, les confirmations de réservation et les liens magiques +transporteurs partent en indésirables. C'est un défaut de production silencieux +et coûteux : personne ne se plaint, les clients pensent simplement que le +service ne marche pas. + +### Vérifier + +```bash +dig +short app.xpeditis.com # doit renvoyer des IP Cloudflare (104.x, 172.6x…) +dig +short TXT xpeditis.com +dig +short TXT _dmarc.xpeditis.com +``` + +--- + +## 3. Réglages SSL/TLS Cloudflare + +**SSL/TLS → Overview** + +| Réglage | Valeur | +|---|---| +| Mode de chiffrement | **Full (strict)** | +| Always Use HTTPS | Activé | +| Minimum TLS Version | 1.2 | +| TLS 1.3 | Activé | +| Automatic HTTPS Rewrites | Activé | +| HSTS | **Pas encore** — voir §6 | + +> **« Full (strict) » et rien d'autre.** En mode *Flexible*, Cloudflare parle en +> clair à votre serveur : le cadenas s'affiche dans le navigateur alors que le +> trafic circule en clair sur Internet. C'est pire que pas de HTTPS du tout, +> puisque personne ne s'en aperçoit. + +--- + +## 4. Certificat de test + +On valide d'abord la chaîne DNS-01 avec l'émetteur *staging*, qui n'a pas de +quota serré. + +```bash +export KUBECONFIG=~/.kube/xpeditis-prod.yaml + +kubectl -n xpeditis-prod patch certificate xpeditis-wildcard --type=merge \ + -p '{"spec":{"issuerRef":{"name":"letsencrypt-staging","kind":"ClusterIssuer"}}}' + +kubectl -n xpeditis-prod delete secret xpeditis-wildcard-tls --ignore-not-found + +# Suivre l'émission +kubectl -n xpeditis-prod get certificate -w +kubectl -n xpeditis-prod describe certificaterequest +kubectl -n cert-manager logs -l app=cert-manager --tail=50 -f +``` + +Deux à cinq minutes (propagation de l'enregistrement TXT `_acme-challenge`). + +`READY: True` signifie que le jeton Cloudflare, les permissions et la zone sont +corrects. Si l'émission échoue : + +| Message | Cause | +|---|---| +| `Cloudflare API error 10000` | Jeton invalide ou permissions insuffisantes (`Zone / DNS / Edit` requis). | +| `could not find zone` | Le jeton ne couvre pas `xpeditis.com`, ou la zone n'est pas active. | +| `propagation check failed` | Attendre. Si cela persiste au-delà de 10 min, vérifier qu'aucun autre DNS ne fait autorité. | + +--- + +## 5. Certificat de production + +Une fois le test au vert : + +```bash +kubectl -n xpeditis-prod patch certificate xpeditis-wildcard --type=merge \ + -p '{"spec":{"issuerRef":{"name":"letsencrypt-prod","kind":"ClusterIssuer"}}}' +kubectl -n xpeditis-prod delete secret xpeditis-wildcard-tls --ignore-not-found + +kubectl -n monitoring patch certificate grafana-tls --type=merge \ + -p '{"spec":{"issuerRef":{"name":"letsencrypt-prod","kind":"ClusterIssuer"}}}' +kubectl -n monitoring delete secret grafana-tls --ignore-not-found + +kubectl get certificate -A -w +``` + +Vérifiez l'émetteur réel du certificat servi : + +```bash +echo | openssl s_client -connect api.xpeditis.com:443 -servername api.xpeditis.com 2>/dev/null \ + | openssl x509 -noout -issuer -subject -dates +``` + +Attendu : `issuer=C=US, O=Let's Encrypt, CN=R11` (ou équivalent). +Si vous lisez `(STAGING)`, le certificat de test est encore en place : +supprimez le `Secret` et relancez. + +> Avec Cloudflare en proxy, le navigateur voit le certificat **Cloudflare**, pas +> le vôtre. La commande ci-dessus interroge Cloudflare. Pour vérifier le +> certificat d'origine, il faut se connecter depuis app-01 : +> ```bash +> ssh deploy@ \ +> "echo | openssl s_client -connect 127.0.0.1:443 -servername api.xpeditis.com 2>/dev/null | openssl x509 -noout -issuer -dates" +> ``` + +--- + +## 6. HSTS + +**À faire seulement quand tous les sous-domaines servent du HTTPS valide.** + +Traefik pose déjà l'en-tête (`k8s/base/08-traefik-middlewares.yaml`, +`stsSeconds: 31536000`). Activer HSTS **aussi** chez Cloudflare le fait +appliquer avant même que la requête n'atteigne le serveur. + +**SSL/TLS → Edge Certificates → HTTP Strict Transport Security → Enable** + +| Réglage | Valeur | +|---|---| +| Max Age | 12 mois | +| Include subdomains | Oui | +| Preload | Oui | +| No-Sniff | Oui | + +> **C'est irréversible pendant un an.** Une fois HSTS envoyé, les navigateurs +> refuseront tout accès HTTP à `xpeditis.com` et à ses sous-domaines pendant la +> durée annoncée, même si vous désactivez le réglage. Un sous-domaine qui +> n'aurait pas de certificat valide deviendrait inaccessible sans recours. +> +> Commencez par `Max Age = 1 mois`, sans *preload*. Passez à 12 mois et +> *preload* après deux semaines sans incident. + +--- + +## 7. Règles WAF et cache + +Appliquez les règles décrites dans +[`infra/prod/cloudflare/README.md`](../../infra/prod/cloudflare/README.md) : + +1. **Bypass cache sur `api.xpeditis.com/*`** — la plus importante. Une réponse + d'API mise en cache servirait les données d'un utilisateur à un autre. +2. Blocage de `/api/docs`. +3. Limitation de débit sur `/api/v1/auth/login` (10 requêtes/minute/IP). +4. Restriction du webhook Stripe aux IP de Stripe. +5. Cache long sur `/_next/static/*`. + +--- + +## 8. Vérifications finales + +```bash +# Les redirections +curl -sI http://xpeditis.com | head -3 # 301 → https +curl -sI https://www.xpeditis.com | head -1 # 200 +curl -sI https://app.xpeditis.com | head -1 # 200 +curl -sI https://api.xpeditis.com/api/v1/health | head -1 # 200 + +# Les en-têtes de sécurité +curl -sI https://app.xpeditis.com | grep -iE 'strict-transport|x-frame|x-content-type|referrer-policy' + +# L'API n'est pas mise en cache +curl -sI https://api.xpeditis.com/api/v1/health | grep -i 'cf-cache-status' +# Attendu : BYPASS ou DYNAMIC, jamais HIT + +# L'origine est-elle contournable ? +curl -sS --max-time 5 --connect-to app.xpeditis.com:443::443 \ + https://app.xpeditis.com/ 2>&1 | head -2 +# Attendu : timeout — le firewall Hetzner bloque tout ce qui n'est pas Cloudflare +``` + +Analyse externe : [ssllabs.com/ssltest](https://www.ssllabs.com/ssltest/) sur +`app.xpeditis.com` — visez A ou A+. + +--- + +## 9. Dépannage + +### Le certificat ne s'émet pas + +```bash +kubectl -n xpeditis-prod describe certificate xpeditis-wildcard +kubectl -n xpeditis-prod get certificaterequest,order,challenge +kubectl -n cert-manager logs -l app=cert-manager --tail=100 +``` + +Le `Challenge` indique précisément à quelle étape cela bloque. + +### Le certificat approche de l'expiration + +cert-manager renouvelle 30 jours avant l'échéance. L'alerte +`CertificatBientotExpire` se déclenche à 15 jours — c'est-à-dire uniquement si +le renouvellement automatique a cessé de fonctionner. + +Forcer un renouvellement : +```bash +kubectl -n xpeditis-prod delete secret xpeditis-wildcard-tls +# cert-manager réémet dans la minute +``` + +### « Too many certificates already issued » + +Vous avez atteint la limite Let's Encrypt (50 par domaine et par semaine). +Elle se réinitialise glissant sur 7 jours. En attendant, utilisez +`letsencrypt-staging` — et c'est exactement pourquoi la validation se fait +d'abord en staging. + +### Erreur 521 / 522 chez Cloudflare + +Cloudflare n'atteint pas l'origine. + +```bash +ssh deploy@ 'sudo systemctl status k3s; sudo ss -tlnp | grep -E ":(80|443) "' +kubectl -n kube-system get pods -l app.kubernetes.io/name=traefik +``` + +Cause fréquente : les rangs d'IP Cloudflare ont changé. +```bash +bash infra/prod/scripts/refresh-cloudflare-ips.sh +``` + +--- + +## 10. Contrôle + +``` +[ ] 5 enregistrements A créés, tous proxifiés +[ ] Aucun enregistrement ne pointe vers db-01 +[ ] SPF, DKIM, DMARC publiés +[ ] Mode SSL : Full (strict) +[ ] Certificat de test émis avec succès (chaîne DNS-01 validée) +[ ] Certificat de production émis, Ready: True, émetteur non-staging +[ ] HTTP redirige en 301 vers HTTPS +[ ] En-têtes de sécurité présents +[ ] cf-cache-status = BYPASS sur l'API +[ ] L'origine n'est pas joignable en contournant Cloudflare +[ ] Règles WAF appliquées +[ ] Note SSL Labs ≥ A +[ ] HSTS activé (après vérification, max-age court d'abord) +``` + +→ **Suite : [09 — Déploiement applicatif](./09-deploiement-application.md)** diff --git a/docs/mise-en-prod/09-deploiement-application.md b/docs/mise-en-prod/09-deploiement-application.md new file mode 100644 index 0000000..5b83e7e --- /dev/null +++ b/docs/mise-en-prod/09-deploiement-application.md @@ -0,0 +1,337 @@ +# 09 — Déploiement de l'application + +**Durée : environ 2 h.** Premier déploiement, réalisé à la main. L'automatisation +vient à l'étape suivante — on ne débogue pas un premier déploiement à travers +une chaîne CI. + +--- + +## Le premier administrateur + +La migration `1730000000007-SeedTestUsers` créait trois comptes dont le mot de +passe est écrit **en clair dans le dépôt** — dont `admin@xpeditis.com`, rôle +ADMIN, mot de passe `Password123!`. Sur une base de production neuve, appliquer +les migrations créait donc un administrateur aux identifiants publics. + +**Trois mécanismes remplacent cela**, et fonctionnent sans intervention : + +| Migration | Effet | +|---|---| +| `1730000000007-SeedTestUsers` (modifiée) | **Ne s'exécute plus** quand `NODE_ENV=production`. Les comptes ne sont jamais créés. | +| `1756000000000-NeutralizeSeedAccountsInProduction` | Filet de sécurité : si ces comptes existent malgré tout (base migrée avant la garde, restauration ancienne), ils sont renommés, rendus inauthentifiables et désactivés. La migration **échoue** si le nettoyage est incomplet. | +| `1756000000001-BootstrapAdminFromEnv` | Crée **votre** administrateur à partir de `BOOTSTRAP_ADMIN_EMAIL`. | + +### Comment votre mot de passe est défini + +Dans le cas nominal — `BOOTSTRAP_ADMIN_EMAIL` renseigné dans le ConfigMap, +`BOOTSTRAP_ADMIN_PASSWORD_HASH` laissé **vide** — le compte est créé avec le +hash Argon2id d'un secret aléatoire immédiatement perdu. Le compte existe, il +est actif, mais **aucun mot de passe ne peut y correspondre**. Vous définissez +le vôtre via « mot de passe oublié », qui envoie un jeton à usage unique, +valable une heure, stocké haché en base. + +Conséquence : **aucun secret n'existe nulle part** — ni dans Git, ni dans le +Secret Kubernetes, ni dans l'historique du shell, ni dans les journaux de +migration. Il n'y a rien à faire fuiter. Effet de bord utile : la réception du +courriel prouve que la chaîne SMTP fonctionne. + +> **Variante, si SMTP n'est pas encore opérationnel.** Générez un hash hors +> ligne (`node apps/backend/scripts/setup/generate-admin-hash.js`) et placez-le +> dans `BOOTSTRAP_ADMIN_PASSWORD_HASH`, côté **Secret** et jamais ConfigMap. Un +> hash reste attaquable hors ligne : changez le mot de passe dès la première +> connexion, puis retirez la variable et réappliquez le Secret. Un mot de passe +> en clair placé dans cette variable fait échouer la migration. + +Garde-fous : la migration ne fait rien s'il existe déjà un administrateur actif +— elle ne peut donc pas en créer un second lors d'un déploiement ultérieur. Et +si un compte porte déjà cette adresse, il est promu ADMIN **sans que son mot de +passe ne soit touché**. + +--- + +## 1. Construire les premières images + +La chaîne de preprod produit les images `preprod-`. Pour le premier +déploiement, on les fabrique à la main. + +```bash +export SHA=$(git rev-parse --short=7 HEAD) +export REGISTRY=rg.fr-par.scw.cloud/weworkstudio + +docker login "$REGISTRY" -u nologin -p '' + +# Backend — aucune variable de build : tout est lu au démarrage +docker buildx build --platform linux/amd64 \ + -t "$REGISTRY/xpeditis-backend:prod-$SHA" \ + -f apps/backend/Dockerfile apps/backend --push + +# Frontend — les URLs sont FIGÉES ici, pas à l'exécution +docker buildx build --platform linux/amd64 \ + --build-arg NEXT_PUBLIC_API_URL=https://api.xpeditis.com \ + --build-arg NEXT_PUBLIC_APP_URL=https://app.xpeditis.com \ + -t "$REGISTRY/xpeditis-frontend:prod-$SHA" \ + -f apps/frontend/Dockerfile apps/frontend --push + +# Log exporter +docker buildx build --platform linux/amd64 \ + -t "$REGISTRY/xpeditis-log-exporter:prod-$SHA" \ + -f apps/log-exporter/Dockerfile apps/log-exporter --push +``` + +> **`next.config.js` fige `NEXT_PUBLIC_API_URL` au moment du build.** Une image +> construite avec l'URL de preprod appellera `api.preprod.xpeditis.com` en +> production, quelles que soient les variables injectées dans le pod. C'est +> pour cette raison que le frontend est **reconstruit** pour la production et +> jamais promu depuis la preprod. + +Vérifiez que l'URL de preprod n'a pas fuité dans le bundle : + +```bash +CID=$(docker create "$REGISTRY/xpeditis-frontend:prod-$SHA") +docker cp "$CID:/app/.next" /tmp/next-check && docker rm "$CID" +grep -rl "api.preprod.xpeditis.com" /tmp/next-check && echo "PROBLEME" || echo "OK" +rm -rf /tmp/next-check +``` + +--- + +## 2. Appliquer la configuration + +```bash +export KUBECONFIG=~/.kube/xpeditis-prod.yaml +cd infra/prod + +kubectl apply -f k8s/base/00-namespaces.yaml +kubectl apply -f k8s/base/01-limits.yaml +kubectl apply -f k8s/base/02-configmap-backend.yaml +kubectl apply -f k8s/base/08-traefik-middlewares.yaml +kubectl apply -f k8s/base/10-network-policies.yaml +kubectl apply -f k8s/base/11-certificate.yaml +``` + +Relisez la configuration avant d'aller plus loin : + +```bash +kubectl -n xpeditis-prod get cm xpeditis-backend-config -o yaml | grep -E 'DATABASE_HOST|DATABASE_SSL|COOKIE_DOMAIN|CORS_ORIGIN|AWS_S3' +``` + +Cinq valeurs à ne pas rater : +- `DATABASE_SSL: "true"` — `pg_hba` n'accepte que `hostssl` ; +- `COOKIE_DOMAIN: ".xpeditis.com"` — avec le point initial ; +- `CORS_ORIGIN` — doit contenir **exactement** les origines du frontend + (`credentials: true` interdit le joker `*`) ; +- `NODE_ENV: "production"` — c'est ce qui empêche `SeedTestUsers` de s'exécuter ; +- `BOOTSTRAP_ADMIN_EMAIL` — une adresse que vous **relevez réellement**, c'est + par elle que passera le lien de définition du mot de passe. + +--- + +## 3. Migrations, puis démarrage + +### 3.1 Migrations + +```bash +sed "s|__IMAGE_TAG__|prod-$SHA|g" k8s/base/07-migration-job.yaml | kubectl apply -f - +kubectl -n xpeditis-prod wait --for=condition=complete "job/xpeditis-migrate-prod-$SHA" --timeout=900s +kubectl -n xpeditis-prod logs "job/xpeditis-migrate-prod-$SHA" +``` + +40 migrations doivent s'appliquer. En cas d'échec, les journaux du Job donnent +la requête SQL fautive. + +Trois lignes à repérer dans la sortie : + +``` +SeedTestUsers ignore : NODE_ENV=production. ... +[neutralisation] Aucun compte de démonstration présent. +[amorçage admin] Administrateur ops@xpeditis.com créé (aucun mot de passe — à définir via « mot de passe oublié »). +``` + +Si vous lisez `Seeded test users successfully`, **arrêtez-vous** : `NODE_ENV` +n'est pas positionné à `production` dans le ConfigMap. Corrigez, puis exécutez +`infra/prod/scripts/harden-seed-data.sh` sur db-01 avant de continuer. + +> **Pourquoi un Job.** L'image lance déjà les migrations à chaque démarrage de +> pod (`scripts/setup/startup.js`). Avec deux replicas, deux processus migrent +> simultanément ; TypeORM ne sérialise pas entre processus et l'un des deux +> part en `CrashLoopBackOff`. Le Job (parallélisme 1) applique tout d'abord ; +> `startup.js` ne fait ensuite que constater qu'il n'y a rien à migrer. + +### 3.2 Vérifier l'état des comptes avant d'ouvrir quoi que ce soit + +```bash +ssh deploy@ 'cd /opt/xpeditis/data-node && \ + sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c \ + "SELECT email, role, is_active FROM users ORDER BY role, email;"' +``` + +Attendu : **une seule ligne**, votre administrateur, `is_active = t`. +Aucune adresse `@xpeditis.com` de démonstration ne doit apparaître. + +### 3.3 Démarrer l'application + +```bash +kubectl apply -f k8s/base/04-backend.yaml +kubectl apply -f k8s/base/05-frontend.yaml +kubectl apply -f k8s/base/06-log-exporter.yaml + +kubectl -n xpeditis-prod set image deploy/xpeditis-backend "backend=$REGISTRY/xpeditis-backend:prod-$SHA" +kubectl -n xpeditis-prod set image deploy/xpeditis-frontend "frontend=$REGISTRY/xpeditis-frontend:prod-$SHA" +kubectl -n xpeditis-prod set image deploy/xpeditis-log-exporter "log-exporter=$REGISTRY/xpeditis-log-exporter:prod-$SHA" + +kubectl -n xpeditis-prod rollout status deploy/xpeditis-backend --timeout=300s +kubectl -n xpeditis-prod rollout status deploy/xpeditis-frontend --timeout=300s +``` + +### 3.4 Exposer + +```bash +kubectl apply -f k8s/base/09-ingress.yaml +kubectl -n xpeditis-prod get ingress +``` + +--- + +## 4. Vérifications + +```bash +kubectl -n xpeditis-prod get pods -o wide +kubectl -n xpeditis-prod logs -l app.kubernetes.io/name=xpeditis-backend --tail=50 +``` + +Le backend doit afficher `PostgreSQL is ready`, `No pending migrations`, puis +la bannière de démarrage. + +```bash +# De l'extérieur +curl -s https://api.xpeditis.com/api/v1/health | jq +curl -sI https://app.xpeditis.com/ | head -1 + +# Les comptes de démonstration sont bien morts +curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api.xpeditis.com/api/v1/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"admin@xpeditis.com","password":"Password123!"}' +# Attendu : 401 + +# Swagger désactivé +curl -s -o /dev/null -w '%{http_code}\n' https://api.xpeditis.com/api/docs +# Attendu : 404 (ou 401 si vous avez choisi de le protéger) + +bash scripts/smoke-test.sh +``` + +### Symptômes fréquents + +| Symptôme | Cause probable | Vérification | +|---|---|---| +| `CrashLoopBackOff`, `password authentication failed` | `DATABASE_PASSWORD` ≠ `POSTGRES_PASSWORD` de db-01 | Comparer les deux | +| `CrashLoopBackOff`, `no pg_hba.conf entry ... SSL off` | `DATABASE_SSL` absent ou à `false` | ConfigMap | +| `CrashLoopBackOff`, `config validation error` | Une variable requise par Joi manque | Les journaux la nomment | +| `ImagePullBackOff` | `regcred` absent ou périmé | `kubectl -n xpeditis-prod get secret regcred` | +| Connexion OK mais retour sur `/login` | `COOKIE_DOMAIN` sans point initial | ConfigMap | +| Erreurs CORS dans la console navigateur | `CORS_ORIGIN` incomplet | ConfigMap | +| L'app appelle `api.preprod…` | Image frontend promue au lieu d'être reconstruite | Reconstruire | + +--- + +## 5. Définir le mot de passe de votre administrateur + +Le compte a été créé par la migration, sans mot de passe utilisable. Vous +définissez le vôtre par le flux de réinitialisation — le même que celui de vos +utilisateurs, ce qui le valide au passage. + +```bash +# 1. Demander le lien +curl -sS -X POST https://api.xpeditis.com/api/v1/auth/forgot-password \ + -H 'Content-Type: application/json' \ + -d '{"email":"ops@xpeditis.com"}' +# Réponse toujours 200, même pour une adresse inconnue (anti-énumération). +``` + +Ou simplement depuis `https://app.xpeditis.com/fr/forgot-password`. + +``` +2. Ouvrir le lien reçu par courriel (valable 1 heure, à usage unique) +3. Définir un mot de passe long et aléatoire, issu de votre gestionnaire +4. Se connecter sur https://app.xpeditis.com/fr/login +``` + +### Contrôles + +```bash +# Un seul ADMIN actif, le vôtre +ssh deploy@ 'cd /opt/xpeditis/data-node && \ + sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c \ + "SELECT email, role, is_active FROM users WHERE role = '"'"'ADMIN'"'"';"' +``` + +> Si vous avez utilisé la variante `BOOTSTRAP_ADMIN_PASSWORD_HASH` : connectez-vous, +> **changez le mot de passe depuis l'interface**, puis retirez la variable du +> Secret et réappliquez-le. Un hash qui reste dans un coffre-fort est une cible +> d'attaque hors ligne sans aucune contrepartie une fois le compte opérationnel. + +### Si le courriel n'arrive pas + +C'est la chaîne SMTP qui est en cause : + +```bash +kubectl -n xpeditis-prod logs -l app.kubernetes.io/name=xpeditis-backend | grep -i smtp +``` + +Causes habituelles : clé SMTP Brevo invalide (celle de preprod est compromise +et doit avoir été remplacée), domaine expéditeur non vérifié chez Brevo, SPF ou +DKIM absents ([08](./08-dns-tls-cloudflare.md)). + +--- + +## 6. Données de référence + +Les migrations installent déjà les ports (`SeedMajorPorts`), les transporteurs +(`SeedCarriersAndOrganizations`) et les abonnements gratuits +(`SeedFreeSubscriptions`). + +```bash +ssh deploy@ 'cd /opt/xpeditis/data-node && \ + sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c " + SELECT '\''ports'\'' AS t, count(*) FROM ports + UNION ALL SELECT '\''carriers'\'', count(*) FROM carriers + UNION ALL SELECT '\''organizations'\'', count(*) FROM organizations + UNION ALL SELECT '\''users'\'', count(*) FROM users;"' +``` + +Trois **organisations de démonstration** subsistent (`Test Freight Forwarder +Inc.`, `Demo Shipping Company`, `Sample Shipper Ltd.`). Elles ne sont pas +supprimées automatiquement : les comptes désactivés y sont rattachés et une +suppression en cascade toucherait `audit_logs`. Renommez-les ou masquez-les +depuis l'interface d'administration si elles gênent. + +### Grilles tarifaires + +Les grilles se chargent par l'interface d'administration (import CSV 33 +colonnes). Chargez au moins une grille export **avant l'ouverture** : sans +grille, la recherche de tarifs ne renvoie rien et la plateforme paraît cassée. + +--- + +## 7. Contrôle + +``` +[ ] Images prod- construites et poussées (frontend reconstruit) +[ ] Aucune URL de preprod dans le bundle frontend +[ ] ConfigMap vérifié : NODE_ENV, DATABASE_SSL, COOKIE_DOMAIN, CORS_ORIGIN, + BOOTSTRAP_ADMIN_EMAIL +[ ] Job de migration terminé, 40 migrations appliquées +[ ] Journal du Job : « SeedTestUsers ignore : NODE_ENV=production » +[ ] Journal du Job : « [amorçage admin] Administrateur ... créé » +[ ] La table users ne contient QUE votre administrateur +[ ] Connexion admin@xpeditis.com / Password123! → 401 +[ ] Backend et frontend : 2 pods Running chacun +[ ] Ingress créés, https://api.xpeditis.com/api/v1/health → 200 +[ ] Swagger inaccessible +[ ] smoke-test.sh au vert +[ ] Lien « mot de passe oublié » reçu, mot de passe défini, connexion réussie +[ ] Un seul ADMIN actif en base +[ ] Au moins une grille tarifaire chargée +``` + +→ **Suite : [10 — CI/CD](./10-cicd-github-actions.md)** diff --git a/docs/mise-en-prod/10-cicd-github-actions.md b/docs/mise-en-prod/10-cicd-github-actions.md new file mode 100644 index 0000000..a06efc0 --- /dev/null +++ b/docs/mise-en-prod/10-cicd-github-actions.md @@ -0,0 +1,260 @@ +# 10 — CI/CD GitHub Actions + +**Durée : environ 1 h 30.** + +Une fois le premier déploiement manuel réussi, on automatise. Le workflow +`.github/workflows/cd-main.yml` a été réécrit pour cette infrastructure. + +--- + +## 1. Enchaînement + +``` +push sur main + │ + ├─ Lint + type-check + tests unitaires (backend, frontend) + │ + ├─ Vérification : l'image preprod- existe-t-elle ? + │ Si non → BLOCAGE. Ce commit n'est pas passé par la preprod. + │ + ├─ Promotion du backend preprod- → prod- (aucun rebuild) + ├─ Reconstruction du frontend avec les URLs de production + │ + contrôle : aucune URL de preprod dans le bundle + │ + ├─ Déploiement (environnement protégé « production ») + │ 1. ouverture du port 22 pour l'IP du runner (firewall Hetzner dédié) + │ 2. rsync de infra/prod vers app-01 + │ 3. ssh « deploy prod- » → migrations, images, attente + │ 4. tests de fumée depuis l'extérieur + │ 5. retour arrière automatique en cas d'échec + │ 6. fermeture du firewall — TOUJOURS, même sur échec ou annulation + │ + └─ Notification Discord +``` + +--- + +## 2. Les trois décisions structurantes + +### 2.1 Le backend est promu, le frontend est reconstruit + +Promouvoir garantit que le binaire déployé est **exactement** celui qui a passé +la chaîne de preprod, au condensat près. Un rebuild casserait cette garantie. + +Mais `next.config.js` fige `NEXT_PUBLIC_API_URL` au moment du build. L'ancien +workflow re-taguait l'image frontend de preprod vers la production : le résultat +aurait été une application appelant `api.preprod.xpeditis.com` en production. + +Le frontend est donc **reconstruit** depuis le commit exact déjà vérifié, avec +les URLs de production, et une étape de contrôle échoue si la chaîne +`api.preprod.xpeditis.com` se retrouve malgré tout dans le bundle. + +### 2.2 Le déploiement passe par SSH, pas par l'API Kubernetes + +L'API k3s (6443) n'est ouverte qu'à vos IP d'administration. Les runners GitHub +n'ont pas d'IP fixe, et leurs rangs publiés sont trop vastes et trop mouvants +pour une liste blanche. + +Le job ouvre donc le port 22 pour **la seule IP du runner en cours**, via un +firewall Hetzner dédié (`xpeditis-prod-fw-cicd`), puis le referme dans une +étape `if: always()` — donc y compris si le déploiement échoue, si le job est +annulé ou s'il expire. + +### 2.3 Le kubeconfig et la clé SOPS ne sont pas dans GitHub + +- Le **kubeconfig** donne les pleins pouvoirs sur le cluster. Un dépôt compromis + ne doit pas les offrir. La CI utilise le kubeconfig local du serveur, à + travers une clé SSH restreinte à un script. +- La **clé age** déchiffre tous les secrets de production. Les secrets sont + appliqués depuis votre poste (`make secrets-apply`), jamais par la CI. + +--- + +## 3. Configurer GitHub + +### 3.1 Environnement protégé + +**Settings → Environments → New environment → `production`** + +| Réglage | Valeur | +|---|---| +| Required reviewers | **vous** (au moins une personne) | +| Deployment branches | `main` uniquement | +| Wait timer | 0 | + +> Sans `Required reviewers`, tout `push` sur `main` déploie en production sans +> validation humaine. Avec, chaque déploiement demande une confirmation +> explicite — deux secondes qui ont déjà sauvé beaucoup de vendredis soir. + +### 3.2 Secrets et variables + +La liste complète, avec la façon d'obtenir chaque valeur, est dans +[`infra/prod/env/github-secrets.md`](../../infra/prod/env/github-secrets.md). + +Résumé : + +**Secrets** : `REGISTRY_TOKEN`, `HCLOUD_TOKEN_CICD`, `PROD_SSH_HOST`, +`PROD_SSH_USER`, `PROD_SSH_KEY`, `PROD_SSH_KNOWN_HOSTS`, +`NEXT_PUBLIC_API_URL_PROD`, `NEXT_PUBLIC_APP_URL_PROD`, `DISCORD_WEBHOOK_URL`. + +**Variables** : `PROD_API_URL`, `PROD_APP_URL`, `HCLOUD_CICD_FIREWALL`. + +```bash +# Empreinte du serveur, pour PROD_SSH_KNOWN_HOSTS +ssh-keyscan -H +``` + +> L'empreinte épinglée n'est pas une formalité : sans elle, un détournement DNS +> ou BGP pourrait rediriger le déploiement — clé SSH comprise — vers une +> machine tierce. + +--- + +## 4. Préparer le serveur + +### 4.1 Répertoire de travail + +```bash +ssh -i ~/.ssh/xpeditis_prod deploy@ ' + sudo mkdir -p /opt/xpeditis/infra-prod + sudo chown -R deploy:deploy /opt/xpeditis +' +``` + +### 4.2 Clé de déploiement restreinte + +```bash +# Clé publique de la CI (générée en 01-prerequis.md) +cat ~/.ssh/xpeditis_ci.pub +``` + +Sur app-01, ajoutez-la **avec des restrictions** : + +```bash +ssh -i ~/.ssh/xpeditis_prod deploy@ ' + cat >> ~/.ssh/authorized_keys <` (avec validation +stricte du tag), `rollback` et `status`. Tout le reste est refusé et journalisé. + +### 4.3 Premier envoi manuel du wrapper + +Le wrapper doit exister avant que la clé restreinte ne puisse servir : + +```bash +rsync -az -e "ssh -i ~/.ssh/xpeditis_prod" \ + infra/prod/ deploy@:/opt/xpeditis/infra-prod/ +ssh -i ~/.ssh/xpeditis_prod deploy@ \ + 'chmod +x /opt/xpeditis/infra-prod/scripts/*.sh' +``` + +### 4.4 Tester la clé restreinte + +```bash +# Autorisé +ssh -i ~/.ssh/xpeditis_ci deploy@ "status" + +# Refusé — c'est le résultat attendu +ssh -i ~/.ssh/xpeditis_ci deploy@ "cat /opt/xpeditis/data-node/.env.data" +ssh -i ~/.ssh/xpeditis_ci deploy@ # pas de shell +ssh -i ~/.ssh/xpeditis_ci deploy@ "deploy ; rm -rf /" +``` + +Les refus sont tracés : +```bash +ssh deploy@ 'sudo journalctl -t xpeditis-ssh-deploy -n 20' +``` + +--- + +## 5. Premier déploiement automatique + +```bash +git checkout preprod && git merge main && git push # chaîne preprod +# ... attendre que cd-preprod.yml passe au vert ... + +git checkout main && git merge preprod && git push +``` + +Suivez l'exécution dans l'onglet Actions. Le job `deploy` attendra votre +approbation. + +### Vérifier que le firewall s'est bien refermé + +```bash +hcloud firewall describe xpeditis-prod-fw-cicd +``` + +Attendu : **aucune règle**. S'il en reste une, une exécution a été interrompue +d'une façon qui a contourné le `if: always()` : + +```bash +echo '[]' > /tmp/empty.json +hcloud firewall replace-rules xpeditis-prod-fw-cicd --rules-file /tmp/empty.json +``` + +Ajoutez ce contrôle à votre routine hebdomadaire. + +--- + +## 6. Le workflow de retour arrière + +`.github/workflows/rollback.yml` existe déjà. Vérifiez qu'il cible bien la +nouvelle infrastructure ; à défaut, le retour arrière manuel reste disponible : + +```bash +ssh -i ~/.ssh/xpeditis_ci deploy@ "rollback" +# ou +make -C infra/prod rollback +``` + +> **Le retour arrière ne défait pas les migrations.** Si la version retirée +> contenait une migration destructrice (colonne supprimée, type modifié), +> l'ancienne version applicative peut ne plus fonctionner contre le schéma +> courant. Voir +> [15 § Retour arrière avec migration](./15-exploitation-incidents.md#retour-arriere-avec-migration). + +--- + +## 7. Ce que la chaîne ne fait pas + +Elle est volontairement conservatrice. Elle **ne fait pas** : + +- de tests end-to-end contre la production (Playwright tourne sur la preprod) ; +- de déploiement bleu-vert ou canari — inutile à cette échelle, le + `maxUnavailable: 0` suffit à éviter toute coupure ; +- de sauvegarde avant déploiement — la sauvegarde quotidienne et l'archivage + continu couvrent le besoin. Avant une migration risquée, prenez une + sauvegarde manuelle ([12](./12-sauvegardes-restauration.md)) ; +- d'analyse de vulnérabilité des images. À ajouter (Trivy) quand vous en aurez + le temps ; ce n'est pas bloquant pour le lancement. + +--- + +## 8. Contrôle + +``` +[ ] Environnement GitHub « production » créé, Required reviewers actif +[ ] Branches de déploiement limitées à main +[ ] 9 secrets renseignés +[ ] 3 variables renseignées +[ ] /opt/xpeditis/infra-prod créé et appartenant à deploy +[ ] Clé CI ajoutée avec restrict + command= +[ ] « status » fonctionne, tout le reste est refusé +[ ] Refus visibles dans journalctl -t xpeditis-ssh-deploy +[ ] Premier déploiement automatique réussi +[ ] Firewall CI vide après exécution +[ ] Notification Discord reçue +``` + +→ **Suite : [11 — Observabilité](./11-observabilite.md)** diff --git a/docs/mise-en-prod/11-observabilite.md b/docs/mise-en-prod/11-observabilite.md new file mode 100644 index 0000000..763c375 --- /dev/null +++ b/docs/mise-en-prod/11-observabilite.md @@ -0,0 +1,273 @@ +# 11 — Observabilité + +**Durée : environ 2 h.** + +Principe : **ce qui n'est pas surveillé n'existe pas.** Une panne détectée par +un client est une panne détectée trop tard. + +--- + +## 1. Les composants, et pourquoi chacun est là + +| Composant | Rôle | Pourquoi lui | +|---|---|---| +| **Loki** | Journaux | Indexe des étiquettes, pas le texte intégral : ~10× moins gourmand qu'Elasticsearch, largement suffisant ici. | +| **Promtail** | Collecte | Lit `/var/log/pods` (containerd, pas Docker : k3s n'utilise pas Docker). Découpe le JSON pino en champs. | +| **Prometheus** | Métriques | 15 jours de rétention. Sans métriques, on ne voit pas une dégradation venir. | +| **node-exporter** | Métriques système | Présent pour une alerte avant tout : **disque plein**, la panne la plus fréquente d'un serveur laissé seul. | +| **Alertmanager** | Routage | Envoie sur Discord. Règles d'inhibition pour éviter le déluge. | +| **Grafana** | Consultation | Une interface pour les deux sources. | +| **healthchecks.io** ou **BetterStack** | Externe | **Indispensable** : voir §5. | + +Coût : 0 €. Empreinte : environ 1,5 Go de RAM sur les 16 du serveur. + +--- + +## 2. Déployer + +```bash +export KUBECONFIG=~/.kube/xpeditis-prod.yaml +cd infra/prod +make deploy-monitoring +``` + +Le script vérifie d'abord que les secrets `grafana-admin` et +`alertmanager-secrets` existent, puis déploie les six composants et attend leur +démarrage. + +```bash +kubectl -n monitoring get pods,pvc +``` + +--- + +## 3. Grafana + +`https://grafana.xpeditis.com` — identifiants dans le `Secret` +`monitoring/grafana-admin`. + +### Si vous obtenez un 403 + +C'est le middleware Traefik `admin-ip-allowlist`. Renseignez votre IP : + +```bash +$EDITOR k8s/base/08-traefik-middlewares.yaml # section admin-ip-allowlist +kubectl apply -f k8s/base/08-traefik-middlewares.yaml +``` + +> Grafana donne accès à l'intégralité de vos journaux applicatifs, jetons +> tronqués et identifiants clients compris. Trois couches le protègent : le +> firewall Hetzner (IP Cloudflare uniquement), le filtrage par IP Traefik, et +> l'authentification Grafana. Si votre IP est dynamique, remplacez la deuxième +> par **Cloudflare Access** (Zero Trust, gratuit) plutôt que d'élargir la liste. + +### Vérifier les sources de données + +**Connections → Data sources** : `Loki` et `Prometheus` doivent afficher +« Data source is working ». + +### Premières requêtes utiles + +```logql +# Toutes les erreurs du backend sur 1 h +{namespace="xpeditis-prod", service="xpeditis-backend"} | json | level="error" + +# Échecs d'authentification +{namespace="xpeditis-prod"} |= "Unauthorized" or "Invalid credentials" + +# Requêtes PostgreSQL lentes (> 500 ms) +{namespace="xpeditis-prod"} |= "duration:" | json | duration > 500 + +# Suivre une requête de bout en bout par son identifiant +{namespace="xpeditis-prod"} | json | reqId="" +``` + +```promql +# Taux d'erreur 5xx +sum(rate(traefik_service_requests_total{code=~"5.."}[5m])) + / sum(rate(traefik_service_requests_total[5m])) + +# Latence P95 par service +histogram_quantile(0.95, sum(rate(traefik_service_request_duration_seconds_bucket[5m])) by (le, service)) + +# Connexions PostgreSQL utilisées +sum(pg_stat_database_numbackends) / on() pg_settings_max_connections + +# Espace disque restant +node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} +``` + +--- + +## 4. Les alertes + +Définies dans `k8s/monitoring/03-prometheus.yaml`. + +| Alerte | Seuil | Gravité | +|---|---|---| +| `BackendIndisponible` | Aucune instance saine, 2 min | critique | +| `FrontendIndisponible` | Aucune instance saine, 2 min | critique | +| `TauxErreur5xxEleve` | > 5 % pendant 5 min | critique | +| `LatenceElevee` | P95 > 2 s pendant 10 min | avertissement | +| `DisqueBientotPlein` | < 15 % libre, 10 min | critique | +| `MemoireNoeudSaturee` | > 90 %, 10 min | avertissement | +| `ConteneurRedemarreEnBoucle` | > 3 démarrages en 15 min | critique | +| `PostgresInjoignable` | `pg_up == 0`, 2 min | critique | +| `ConnexionsPostgresSaturees` | > 80 %, 5 min | avertissement | +| `CertificatBientotExpire` | < 15 jours | critique | + +Les critiques sont répétées toutes les heures tant qu'elles ne sont pas +résolues ; les avertissements toutes les 12 h. + +### Tester le routage — avant d'en avoir besoin + +```bash +kubectl -n monitoring port-forward svc/alertmanager 9093:9093 & + +curl -XPOST http://localhost:9093/api/v2/alerts \ + -H 'Content-Type: application/json' \ + -d '[{"labels":{"alertname":"TestDeRoutage","severity":"critique"}, + "annotations":{"summary":"Test de la chaine d alerte"}}]' +``` + +Le message doit arriver sur Discord en moins d'une minute. **S'il n'arrive pas, +toutes les alertes ci-dessus sont décoratives.** Vérifiez alors : + +```bash +kubectl -n monitoring logs deploy/alertmanager --tail=50 +kubectl -n monitoring get secret alertmanager-secrets -o jsonpath='{.data.discord-webhook}' | base64 -d +``` + +### Vérifier que Prometheus collecte bien + +```bash +kubectl -n monitoring port-forward svc/prometheus 9090:9090 & +open http://localhost:9090/targets +``` + +Toutes les cibles doivent être `UP`. La plus fragile est `postgres` +(`10.10.1.20:9187`) : elle passe par le réseau privé. + +```bash +# Si postgres est DOWN +ssh deploy@ 'cd /opt/xpeditis/data-node && sudo docker compose ps postgres-exporter' +kubectl -n monitoring exec deploy/prometheus -- wget -qO- http://10.10.1.20:9187/metrics | head -3 +``` + +--- + +## 5. Supervision externe — la seule qui compte quand tout brûle + +Prometheus, Alertmanager et Grafana tournent **sur le serveur qu'ils +surveillent**. Si app-01 s'éteint, ils s'éteignent avec lui : personne ne vous +prévient. C'est le point aveugle structurel de toute supervision interne. + +Il faut donc un observateur **extérieur**. + +### 5.1 Disponibilité + +Sur [healthchecks.io](https://healthchecks.io) (gratuit) ou BetterStack : + +| Contrôle | URL | Fréquence | Alerte après | +|---|---|---|---| +| API | `https://api.xpeditis.com/api/v1/health` | 1 min | 2 échecs | +| Application | `https://app.xpeditis.com/` | 1 min | 2 échecs | +| Vitrine | `https://xpeditis.com/` | 5 min | 2 échecs | + +Notification par e-mail **et** SMS (ou notification téléphonique) : un e-mail +n'est pas lu à 3 h du matin. + +### 5.2 Battement de cœur des sauvegardes + +C'est le **silence** qui doit alerter. Une alerte « la sauvegarde n'a pas +tourné » évaluée par un composant hébergé sur la même machine ne se déclenche +pas quand la machine est éteinte — précisément quand elle serait utile. + +1. Créez un *check* de type heartbeat, période 1 jour, marge 6 h. +2. Copiez son URL de ping dans `.env.data` de db-01 : + +```bash +ssh deploy@ 'sudo $EDITOR /opt/xpeditis/data-node/.env.data' +# BACKUP_HEARTBEAT_URL=https://hc-ping.com/ +``` + +3. Déclenchez une sauvegarde pour vérifier : + +```bash +ssh deploy@ 'sudo systemctl start xpeditis-backup.service' +``` + +Le *check* doit passer au vert sur healthchecks.io. + +### 5.3 Expiration du certificat + +Ajoutez un contrôle TLS externe sur `app.xpeditis.com` (alerte à 14 jours). +Il double l'alerte Prometheus, mais fonctionne même quand le cluster est mort. + +--- + +## 6. Sentry + +Sentry apporte ce que les journaux ne donnent pas : la pile d'appels, les +variables locales et le regroupement des erreurs identiques. + +Le backend embarque `@sentry/node`. Ajoutez `SENTRY_DSN` au `Secret` puis +redémarrez : + +```bash +sops k8s/base/03-secrets.sops.yaml # SENTRY_DSN: "https://...@sentry.io/..." +make secrets-apply +kubectl -n xpeditis-prod rollout restart deploy/xpeditis-backend +``` + +> Vérifiez que le DSN est bien lu au démarrage. Le code appelle +> `configService.get('SENTRY_DSN')` : si l'initialisation n'est pas câblée dans +> `main.ts`, la variable est ignorée en silence. Provoquez une erreur de test et +> vérifiez qu'elle apparaît dans Sentry — sinon la variable ne sert à rien. + +Configurez la **purge des données personnelles** côté Sentry (Settings → +Security & Privacy → Data Scrubbing) : sans elle, des e-mails et jetons clients +partent chez un sous-traitant américain — ce qui a des conséquences RGPD +([16](./16-rgpd-conformite.md)). + +--- + +## 7. Ce qu'il faut regarder chaque semaine + +Quinze minutes, le lundi : + +```bash +make -C infra/prod status +``` + +1. **Erreurs** — Grafana, `{namespace="xpeditis-prod"} | json | level="error"` + sur 7 jours. Une erreur récurrente est un défaut, pas du bruit. +2. **Latence** — le P95 dérive-t-il ? Une dégradation lente précède la panne. +3. **Disque** — `node_filesystem_avail_bytes`. Projetez : dans combien de + semaines l'alerte se déclenchera-t-elle ? +4. **Sauvegardes** — le *check* heartbeat est-il vert ? La vérification + hebdomadaire de restauration est-elle passée ? + ```bash + ssh deploy@ 'sudo journalctl -u xpeditis-backup-verify -n 30 --no-pager' + ``` +5. **Firewall CI** — `hcloud firewall describe xpeditis-prod-fw-cicd` : vide ? +6. **Certificats** — `kubectl get certificate -A` : `Ready: True` partout ? + +--- + +## 8. Contrôle + +``` +[ ] 6 composants de supervision Running +[ ] Grafana accessible, sources Loki et Prometheus fonctionnelles +[ ] Toutes les cibles Prometheus UP, y compris postgres +[ ] Alerte de test reçue sur Discord +[ ] Contrôles de disponibilité externes configurés (API, app, vitrine) +[ ] Battement de cœur des sauvegardes configuré et vert +[ ] Contrôle externe d'expiration du certificat +[ ] Sentry connecté et erreur de test visible +[ ] Purge des données personnelles activée dans Sentry +[ ] Routine hebdomadaire notée dans l'agenda +``` + +→ **Suite : [12 — Sauvegardes et restauration](./12-sauvegardes-restauration.md)** diff --git a/docs/mise-en-prod/12-sauvegardes-restauration.md b/docs/mise-en-prod/12-sauvegardes-restauration.md new file mode 100644 index 0000000..4dc3198 --- /dev/null +++ b/docs/mise-en-prod/12-sauvegardes-restauration.md @@ -0,0 +1,315 @@ +# 12 — Sauvegardes et restauration + +**Durée : environ 2 h, dont un test de restauration réel.** + +> **Une sauvegarde qui n'a jamais été restaurée n'est pas une sauvegarde.** +> C'est la seule phrase de ce dossier qui mérite d'être affichée au mur. + +--- + +## 1. Objectifs + +| Indicateur | Cible | Ce que cela signifie | +|---|---|---| +| **RPO** (perte maximale) | **5 minutes** | `archive_timeout = 300` force la clôture d'un segment WAL toutes les 5 min, même sans activité. | +| **RTO** (temps de remise en service) | **< 2 h** | Reconstruire db-01 et restaurer via WAL-G. | +| Rétention PITR | 7 jours | 7 sauvegardes complètes + les WAL associés. | +| Rétention des dumps | 7 j locaux, 30 j distants | Storage Box. | + +Pour une plateforme de réservation, perdre 5 minutes signifie perdre au plus +quelques réservations, identifiables dans les journaux Loki et rejouables +manuellement. C'est un compromis raisonnable à ce stade. + +--- + +## 2. Trois mécanismes, volontairement redondants + +``` + db-01 + │ + ┌───────────────────────┼───────────────────────┐ + │ │ │ + WAL-G pg_dump -Fc Snapshots + continu quotidien Hetzner + │ │ │ + │ chiffré libsodium │ chiffré age │ (image disque) + ▼ ▼ ▼ +Object Storage Storage Box Hetzner Cloud +xpeditis-prod-pgbackup uXXXXXX/dumps (rétention 7 j) +``` + +**Pourquoi trois.** + +| Panne | Ce qui vous sauve | +|---|---| +| « J'ai supprimé les réservations de mars » | **WAL-G / PITR** — retour à l'instant précédant l'erreur. | +| Corruption logique découverte 3 jours après | WAL-G ou dump du jour concerné. | +| Compte Object Storage compromis ou supprimé | **Dump sur Storage Box** — autre service, autre identifiant, autre protocole. | +| Bug WAL-G, ou montée de version majeure de PostgreSQL | **Dump logique** — restaurable dans n'importe quelle version. | +| Perte totale du serveur | WAL-G + snapshot Hetzner. | +| Rançongiciel sur db-01 | **Snapshots de la Storage Box** — voir avertissement ci-dessous. | + +> **Le point faible d'un rançongiciel.** db-01 possède les identifiants d'écriture +> vers Object Storage **et** vers la Storage Box. Un attaquant ayant obtenu +> root peut donc chiffrer ou effacer les deux. La seule protection réelle est +> l'**instantané côté fournisseur** : activez les snapshots de la Storage Box +> (gratuits, dans son interface) et gardez les sauvegardes Hetzner Cloud +> activées. Ils ne sont pas accessibles depuis le serveur. + +--- + +## 3. Ce qui tourne automatiquement + +| Unité | Quand | Fait quoi | +|---|---|---| +| `xpeditis-backup.timer` | tous les jours 02h30 | WAL-G `backup-push`, purge (7 rétentions), `pg_dump` chiffré, envoi Storage Box, purge locale, battement de cœur. | +| `xpeditis-backup-verify.timer` | dimanche 04h00 | Restaure le dernier dump dans une base jetable et contrôle sa cohérence. | +| `archive_command` | en continu | Chaque segment WAL part sur Object Storage dès sa clôture. | + +```bash +ssh deploy@ "systemctl list-timers 'xpeditis-*' --no-pager" +ssh deploy@ 'sudo journalctl -u xpeditis-backup -n 40 --no-pager' +``` + +### Contrôles de sécurité intégrés + +Le script `pg-backup.sh` refuse de considérer une sauvegarde comme réussie si : +- le conteneur PostgreSQL n'est pas démarré ; +- `wal-g backup-push` échoue ; +- `pg_dump` échoue ; +- le dump fait moins de 50 Ko (base vide ou erreur silencieuse) ; +- le chiffrement `age` échoue ; +- le `rsync` vers la Storage Box échoue. + +Chaque échec part sur Discord **et** le battement de cœur n'est pas envoyé, ce +qui déclenche l'alerte externe. + +--- + +## 4. Le test de restauration — à faire maintenant + +C'est l'étape que tout le monde saute et qui coûte le plus cher le jour venu. + +### 4.1 Vérification automatique (sans impact) + +```bash +ssh deploy@ +sudo /opt/xpeditis/data-node/backup/pg-restore.sh verify +``` + +Ce que fait le script : déchiffre le dernier dump, crée +`xpeditis_restore_check`, restaure avec `--exit-on-error`, compte les tables et +les lignes de `users`, `organizations`, `csv_bookings`, `migrations`, échoue si +moins de 10 tables ont été restaurées, puis supprime la base jetable. + +Attendu : `SAUVEGARDE VALIDE`. + +### 4.2 Restauration à un instant T — le vrai test + +**Faites-le avant l'ouverture au public**, pendant que l'enjeu est nul. + +```bash +# 1. Marqueur horodaté +ssh deploy@ 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres \ + psql -d xpeditis_prod -c "CREATE TABLE test_pitr (t timestamptz DEFAULT now(), note text); + INSERT INTO test_pitr(note) VALUES (:'"'"'avant'"'"'); + SELECT * FROM test_pitr;"' + +# Notez l'horodatage renvoyé. +sleep 360 # au-delà de archive_timeout, pour garantir l'archivage du WAL + +# 2. Destruction volontaire +ssh deploy@ 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres \ + psql -d xpeditis_prod -c "DROP TABLE test_pitr;"' + +# 3. Arrêter l'application (sinon les écritures pendant la restauration sont perdues) +kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0 + +# 4. Restaurer à l'instant précédant la suppression +ssh deploy@ +sudo /opt/xpeditis/data-node/backup/pg-restore.sh pitr "2026-09-01 14:32:00" + +# 5. Suivre le rejeu des WAL +cd /opt/xpeditis/data-node && sudo docker compose logs -f postgres +# Attendre : "database system is ready to accept connections" + +# 6. Vérifier +sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c 'SELECT * FROM test_pitr;' +# La table doit être revenue. + +# 7. Nettoyer et redémarrer +sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c 'DROP TABLE test_pitr;' +kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2 +``` + +**Chronométrez.** Le temps mesuré ici est votre RTO réel, pas celui du tableau. + +Le script conserve l'ancien répertoire de données dans +`/var/lib/xpeditis/pgdata-avant-pitr-`. Supprimez-le **seulement** +après avoir validé la restauration — c'est votre filet. + +--- + +## 5. Restauration + +### 5.1 Suppression accidentelle de données — PITR + +Le plus fréquent, et le plus stressant. + +```bash +# 1. NE RIEN FAIRE D'AUTRE. Chaque écriture supplémentaire complique le retour. +kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0 + +# 2. Déterminer l'instant précis, dans les journaux +# Grafana : {namespace="xpeditis-prod"} |= "DELETE" ou "DROP" + +# 3. Restaurer à la seconde qui précède +ssh deploy@ +sudo /opt/xpeditis/data-node/backup/pg-restore.sh pitr "AAAA-MM-JJ HH:MM:SS" + +# 4. Vérifier AVANT de rouvrir +sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod \ + -c 'SELECT count(*) FROM csv_bookings; SELECT max(created_at) FROM csv_bookings;' + +# 5. Rouvrir +kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2 +``` + +> **La PITR ramène toute la base à l'instant T.** Les données créées *après* +> cet instant sont perdues aussi. Si seules quelques lignes sont concernées, +> préférez une restauration logique dans une base séparée (§5.2), puis copiez +> uniquement ce qui manque. + +### 5.2 Récupérer quelques lignes sans toucher à la production + +```bash +ssh deploy@ +sudo /opt/xpeditis/data-node/backup/pg-restore.sh logical \ + /var/lib/xpeditis/dumps/xpeditis-20260901T023000Z.dump.age \ + xpeditis_recuperation + +# Copier ce qui manque, et rien d'autre +sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c " + INSERT INTO csv_bookings + SELECT * FROM dblink('dbname=xpeditis_recuperation', + 'SELECT * FROM csv_bookings WHERE id = ''''') + AS t(...);" +``` + +Plus simple sans `dblink` : exporter les lignes en CSV depuis la base de +récupération, puis les réimporter. + +La production n'est jamais touchée. C'est presque toujours la bonne approche. + +### 5.3 Perte totale de db-01 + +RTO visé : moins de 2 h. + +```bash +# 1. Recréer le serveur +cd infra/prod/terraform +terraform apply -replace=hcloud_server.db +# Le volume pgdata a prevent_destroy : il survit et sera rattaché. + +# 2. Réinstaller +scp infra/prod/scripts/00-bootstrap-common.sh deploy@:/tmp/ +ssh deploy@ 'sudo APP_PRIVATE_IP=10.10.1.10 bash /tmp/00-bootstrap-common.sh data' +scp infra/prod/scripts/01-setup-data-node.sh deploy@:/tmp/ +ssh deploy@ 'sudo bash /tmp/01-setup-data-node.sh' + +# 3. Redéposer configuration, .env.data (SOPS), clés age et Storage Box +# → 04-noeud-donnees.md, sections 3 et 4 + +# 4a. Si le volume a survécu : démarrer, c'est tout +sudo docker compose -f docker-compose.data.yml --env-file .env.data up -d --build + +# 4b. Si le volume est perdu : restaurer depuis WAL-G +sudo docker compose run --rm -u postgres postgres \ + wal-g backup-fetch /var/lib/postgresql/data/pgdata LATEST +sudo docker compose run --rm -u postgres postgres bash -c \ + "touch /var/lib/postgresql/data/pgdata/recovery.signal + echo \"restore_command = 'wal-g wal-fetch \\\"%f\\\" \\\"%p\\\"'\" \ + >> /var/lib/postgresql/data/pgdata/postgresql.auto.conf" +sudo docker compose up -d postgres + +# 5. Vérifier puis rouvrir +kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2 +``` + +> Si l'IP privée du nouveau serveur diffère de `10.10.1.20`, mettez à jour +> `DATABASE_HOST` et `REDIS_HOST` dans le ConfigMap, la NetworkPolicy +> `allow-egress-to-data-node`, `pg_hba.conf` et la cible Prometheus. +> Terraform réutilise l'IP fixe déclarée : cela ne devrait pas arriver. + +### 5.4 Perte totale de app-01 + +Beaucoup plus simple : **aucune donnée n'y réside**. + +```bash +cd infra/prod/terraform && terraform apply -replace=hcloud_server.app +# Puis : 03-durcissement-serveurs.md → 05-cluster-k3s.md → 06 → 09 +# Mettez à jour les enregistrements DNS Cloudflare avec la nouvelle IP. +``` + +Comptez une heure. C'est le bénéfice direct d'avoir gardé le nœud applicatif +entièrement sans état. + +--- + +## 6. Sauvegarde manuelle avant opération risquée + +Avant une migration lourde ou une montée de version majeure : + +```bash +ssh deploy@ 'sudo systemctl start xpeditis-backup.service' +ssh deploy@ 'sudo journalctl -u xpeditis-backup -f' +``` + +Attendez la fin **avant** de lancer l'opération. Notez l'horodatage : c'est +votre point de retour. + +--- + +## 7. Ce qui n'est pas sauvegardé, et pourquoi + +| Élément | Sauvegardé ? | Justification | +|---|---|---| +| PostgreSQL | oui, 3 fois | données métier | +| Documents Object Storage | par Hetzner (réplication) | pas de version antérieure : **une suppression est définitive**. Voir ci-dessous. | +| Redis | AOF sur disque local | cache + sessions. Une perte déconnecte les utilisateurs, sans plus. | +| Journaux Loki / métriques Prometheus | non | 31 j / 15 j de rétention, sans valeur au-delà. | +| Configuration k3s | non | entièrement reconstructible depuis `infra/prod/`. | +| État Terraform | manuellement | à chiffrer SOPS ou à stocker sur un backend S3. | + +> **Les documents ne sont pas versionnés.** Hetzner Object Storage réplique +> mais ne conserve pas d'historique : un connaissement supprimé par erreur — ou +> par une faille applicative — est définitivement perdu. Si ces documents ont +> une valeur contractuelle (ils en ont), ajoutez une synchronisation +> hebdomadaire vers la Storage Box : +> +> ```bash +> # Sur db-01, tâche hebdomadaire +> rclone sync hetzner:xpeditis-prod-documents \ +> storagebox:xpeditis-prod/documents --backup-dir storagebox:xpeditis-prod/documents-anciens/$(date +%F) +> ``` +> À mettre en place dans le premier mois d'exploitation. + +--- + +## 8. Contrôle + +``` +[ ] Timers xpeditis-backup et xpeditis-backup-verify actifs +[ ] Une sauvegarde complète réussie, visible sur Object Storage ET Storage Box +[ ] pg-restore.sh verify : SAUVEGARDE VALIDE +[ ] TEST PITR RÉEL EFFECTUÉ ET RÉUSSI +[ ] RTO réel chronométré et noté +[ ] Snapshots de la Storage Box activés +[ ] Sauvegardes Hetzner Cloud activées sur les deux serveurs +[ ] Battement de cœur externe vert +[ ] WALG_LIBSODIUM_KEY et backup-age.key sauvegardées HORS LIGNE +[ ] État Terraform sauvegardé +[ ] Synchronisation des documents planifiée (dans le premier mois) +``` + +→ **Suite : [13 — Sécurité](./13-securite-durcissement.md)** diff --git a/docs/mise-en-prod/13-securite-durcissement.md b/docs/mise-en-prod/13-securite-durcissement.md new file mode 100644 index 0000000..4dcc8ce --- /dev/null +++ b/docs/mise-en-prod/13-securite-durcissement.md @@ -0,0 +1,310 @@ +# 13 — Sécurité + +**Durée : environ 3 h.** C'est le document à relire avant chaque revue et après +chaque incident. + +--- + +## Rotation obligatoire avant la mise en production + +`infra/preprod/docker-stack.preprod.yml` est **versionné dans Git** et contient +en clair : + +| Secret | Nature | Action | +|---|---|---| +| Mot de passe PostgreSQL preprod | interne | Ne jamais réutiliser en prod. Changer aussi en preprod. | +| Mot de passe Redis preprod | interne | Idem. | +| `JWT_SECRET` preprod | interne | Idem. | +| Identifiants MinIO preprod | interne | Idem. | +| **Clé SMTP Brevo** | **tiers, active** | **Révoquer immédiatement.** | +| Clés Stripe de test | tiers, test | Faire tourner par précaution. | +| Mot de passe admin Grafana | interne | Changer. | + +Ils sont dans l'historique Git : les retirer du fichier ne les efface pas. +Toute personne ayant eu accès au dépôt — actuel ou ancien collaborateur, fork, +sauvegarde, outil d'analyse — les possède. + +### La clé Brevo d'abord + +C'est le seul identifiant qui donne un pouvoir **hors de votre infrastructure** : +envoyer des e-mails signés `noreply@xpeditis.com`. Un hameçonnage envoyé depuis +votre domaine à vos propres clients est un scénario nettement plus grave qu'un +accès à une base de preprod. + +``` +1. Brevo → SMTP & API → révoquer la clé publiée +2. Créer une clé PRODUCTION → Secret Kubernetes +3. Créer une clé PREPROD distincte → stack preprod +4. Vérifier les envois : Brevo → Statistiques → Transactionnel +``` + +### Puis les autres + +```bash +# Preprod : nouveaux mots de passe partout, puis migration du stack vers SOPS +# Production : les secrets sont déjà générés à neuf en 06-secrets-sops.md +``` + +### Empêcher la récidive + +```bash +# Détection de secrets dans le dépôt +brew install gitleaks +gitleaks detect --source . --verbose + +# Crochet de pré-commit +cat > .git/hooks/pre-commit <<'EOF' +#!/bin/sh +if command -v gitleaks >/dev/null; then + gitleaks protect --staged --redact || { + echo "Un secret a ete detecte dans les fichiers indexes. Commit refuse." + exit 1 + } +fi +EOF +chmod +x .git/hooks/pre-commit +``` + +Migrez également la preprod vers SOPS, sur le modèle de la production. + +--- + +## 1. Modèle de menace + +Ce à quoi une plateforme B2B de réservation maritime est réellement exposée : + +| Menace | Vraisemblance | Impact | Ce qui la contre | +|---|---|---|---| +| Balayage automatisé / force brute SSH | permanente | faible | Clé uniquement, fail2ban, firewall par IP | +| Réutilisation d'identifiants clients | élevée | fort | Argon2, limitation de débit à 3 niveaux, jetons courts | +| Fuite de secret par le dépôt | **avérée** | **fort** | SOPS, gitleaks, rotation | +| Injection SQL | faible | critique | TypeORM paramétré, `class-validator` | +| Documents accessibles sans autorisation | moyenne | fort | Bucket privé, URLs pré-signées | +| Déni de service | moyenne | moyen | Cloudflare, limitation Traefik | +| Rançongiciel sur db-01 | faible | **critique** | Snapshots côté fournisseur (hors de portée du serveur) | +| Compromission de la chaîne CI | faible | critique | Environnement protégé, clé SSH restreinte, promotion d'images | +| Erreur humaine | **élevée** | fort | PITR, confirmations explicites, `preflight-check.sh` | + +L'erreur humaine et la fuite de secret sont les deux plus probables. C'est là +que porte l'essentiel des mesures. + +--- + +## 2. Défenses en place, par couche + +### Réseau +- Firewall Hetzner : SSH et 6443 sur vos IP ; 80/443 sur les IP Cloudflare. +- db-01 : **aucun port applicatif public**. Réseau privé uniquement. +- UFW sur les deux nœuds (le firewall Hetzner ne filtre pas le réseau privé). +- `NetworkPolicy` k8s : refus par défaut, sortie Internet sans les plages + privées — un pod compromis ne peut pas balayer `10.10.0.0/16`. +- Firewall CI vide au repos, ouvert deux minutes pour une seule IP. + +### Transport +- TLS 1.2 minimum, HSTS 1 an, Full (strict) chez Cloudflare. +- PostgreSQL : `hostssl` uniquement, une connexion en clair est refusée. +- Certificats ECDSA, rotation de clé à chaque renouvellement. + +### Système +- SSH par clé, root interdit, 3 tentatives, algorithmes modernes. +- fail2ban, bannissement permanent des récidivistes. +- Mises à jour de sécurité automatiques, redémarrage nocturne si nécessaire. +- auditd sur les fichiers sensibles et les commandes root. +- Compte root verrouillé (la console Hetzner ne donne donc pas de session). + +### Kubernetes +- `Secret` chiffrés au repos (`--secrets-encryption`). +- Journal d'audit de l'API, 30 jours, sans jamais journaliser le contenu des + `Secret`. +- Pod Security Admission `restricted` : pas de root, pas de privilèges, pas de + capability. +- Quotas et `LimitRange` : une fuite mémoire n'emporte pas Traefik. +- Tableau de bord Traefik désactivé. + +### Application +- Argon2 pour les mots de passe. +- JWT 15 min / rafraîchissement 7 j, cookies `httpOnly`. +- Helmet, CORS en liste blanche stricte (pas de joker, `credentials: true`). +- Limitation de débit à trois niveaux indépendants : Cloudflare, Traefik, NestJS. +- Swagger désactivé en production. +- Journaux expurgés (`authorization`, `x-api-key`, mots de passe). + +### Données +- Bucket privé, clés d'accès cloisonnées application / sauvegardes. +- Sauvegardes chiffrées côté client (libsodium et age) : un bucket qui fuite ne + livre rien. +- PITR à 5 minutes. + +--- + +## 3. Revue avant ouverture + +```bash +export KUBECONFIG=~/.kube/xpeditis-prod.yaml +cd infra/prod +DB_PUBLIC_IP= bash scripts/preflight-check.sh +``` + +### Contrôles manuels complémentaires + +```bash +# 1. Rien d'exposé sur db-01 (depuis un AUTRE réseau) +nmap -Pn -p- --min-rate 1000 + +# 2. Rien d'inattendu sur app-01 +nmap -Pn -p- --min-rate 1000 + +# 3. Qualité TLS +# https://www.ssllabs.com/ssltest/analyze.html?d=app.xpeditis.com → A ou A+ + +# 4. En-têtes de sécurité +# https://securityheaders.com/?q=https%3A%2F%2Fapp.xpeditis.com → A ou A+ + +# 5. Comptes de démonstration morts +for c in admin manager user; do + echo -n "$c: " + curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api.xpeditis.com/api/v1/auth/login \ + -H 'Content-Type: application/json' \ + -d "{\"email\":\"$c@xpeditis.com\",\"password\":\"Password123!\"}" +done +# Attendu : 401 trois fois + +# 6. Un seul administrateur +ssh deploy@ 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres \ + psql -d xpeditis_prod -c "SELECT email, role, is_active FROM users WHERE role = '"'"'ADMIN'"'"';"' + +# 7. Documents inaccessibles sans autorisation +curl -sI https://fsn1.your-objectstorage.com/xpeditis-prod-documents/ | head -1 # 403 + +# 8. Aucun secret en clair dans le dépôt +gitleaks detect --source . --verbose + +# 9. Une route protégée refuse l'anonyme +curl -s -o /dev/null -w '%{http_code}\n' https://api.xpeditis.com/api/v1/bookings # 401 +``` + +### Analyse des images + +```bash +brew install trivy +trivy image --severity HIGH,CRITICAL rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:latest +trivy image --severity HIGH,CRITICAL rg.fr-par.scw.cloud/weworkstudio/xpeditis-frontend:latest +``` + +Les images `node:20-alpine` remontent régulièrement des CVE de la bibliothèque +système. Traitez les CRITICAL avec vecteur réseau ; les autres peuvent attendre +la prochaine reconstruction. Reconstruisez les images **au moins une fois par +mois** pour absorber les correctifs de base. + +--- + +## 4. Ce que cette configuration ne couvre pas + +Soyez lucide sur les limites : + +| Non couvert | Conséquence | Quand le traiter | +|---|---|---| +| **Panne d'un nœud** | app-01 en panne = site hors ligne (1 h de reconstruction). | Phase 2 : second nœud + Load Balancer. | +| **PostgreSQL non redondé** | Panne de db-01 = coupure jusqu'à restauration (< 2 h). | Phase 2 : standby en réplication. | +| **Panne du datacenter fsn1** | Coupure jusqu'à reconstruction ailleurs. | Phase 3 : bi-région. | +| **2FA sur les comptes utilisateurs** | Une réutilisation d'identifiants suffit. Le schéma a une colonne `totp_secret` inutilisée. | Dans les 3 mois. | +| **Analyse de vulnérabilité en continu** | Trivy est manuel. | Ajouter à la CI quand possible. | +| **SOC 2** | Bloquant pour les grands comptes. | Quand un client l'exige (Vanta, ~800 €/mois). | +| **Test d'intrusion externe** | Aucun regard extérieur. | Avant les premiers clients importants (3 à 8 k€). | +| **Documents non versionnés** | Une suppression est définitive. | [12 § 7](./12-sauvegardes-restauration.md#7--ce-qui-nest-pas-sauvegarde-et-pourquoi) | + +--- + +## 5. Réponse à incident + +### 5.1 Suspicion de compromission + +**Ne redémarrez rien.** Un redémarrage détruit les preuves en mémoire et +l'attaquant a probablement déjà un moyen de revenir. + +```bash +# 1. ISOLER — bloquer tout le trafic entrant sauf votre IP +cat > /tmp/isolement.json </32"]}] +JSON +hcloud firewall replace-rules xpeditis-prod-fw-app --rules-file /tmp/isolement.json +# Le site devient injoignable : c'est l'effet recherché. Pour ne couper que le +# trafic public sans perdre l'accès, préférez le mode « Under Attack » de +# Cloudflare, qui laisse vos IP passer. + +# 2. CONSTATER +ssh deploy@ ' + last -20 # connexions récentes + sudo journalctl -t xpeditis-ssh-deploy -n 100 + sudo ausearch -k rootcmd -ts today | tail -50 + sudo grep -i "accepted" /var/log/auth.log | tail -30 +' +kubectl -n xpeditis-prod get events --sort-by=.lastTimestamp +ssh deploy@ 'sudo grep -E "\"verb\":\"(create|delete|patch)\"" /var/log/k3s/audit.log | tail -50' + +# 3. PRÉSERVER +ssh deploy@ 'sudo tar czf /tmp/preuves.tgz /var/log/auth.log* /var/log/k3s/audit.log* /var/log/audit/' +scp deploy@:/tmp/preuves.tgz ./incident-$(date +%F).tgz + +# 4. ÉVALUER : la base a-t-elle été touchée ? +ssh deploy@ 'cd /opt/xpeditis/data-node && sudo docker compose logs postgres | grep -iE "connection authorized|FATAL" | tail -50' +``` + +### 5.2 Compromission avérée + +``` +1. Couper l'accès public (firewall, ou Cloudflare en mode « Under Attack ») +2. Faire tourner TOUS les secrets (06-secrets-sops.md) +3. Révoquer les jetons tiers : Scaleway, Hetzner, Cloudflare, Stripe, Brevo +4. Reconstruire app-01 à partir de zéro — ne jamais « nettoyer » un serveur +5. Restaurer la base à un instant ANTÉRIEUR à la compromission (PITR) +6. Forcer la déconnexion de tous les utilisateurs (rotation du JWT_SECRET) +7. Analyser les journaux : quelles données ont été consultées ? +8. Notifier la CNIL sous 72 h si des données personnelles sont concernées (16) +9. Informer les clients concernés +``` + +> **Point 4 : reconstruire, pas nettoyer.** Un serveur compromis ne se +> désinfecte pas — on ne peut jamais prouver l'absence de porte dérobée. +> L'architecture est faite pour cela : app-01 est sans état, sa reconstruction +> prend une heure. + +### 5.3 Fuite de données personnelles + +Le RGPD impose une notification à la CNIL **sous 72 heures** à compter de la +prise de connaissance. Voir [16 § Violation de données](./16-rgpd-conformite.md). + +--- + +## 6. Entretien + +| Fréquence | Action | +|---|---| +| **Hebdomadaire** | Revue Grafana (erreurs, latence, disque). Vérifier que le firewall CI est vide. Vérifier le résultat de la vérification de sauvegarde. | +| **Mensuelle** | `trivy` sur les images, reconstruire pour absorber les correctifs de base. Relire les nouveaux comptes ADMIN. `gitleaks detect`. | +| **Trimestrielle** | `refresh-cloudflare-ips.sh`. Test de restauration PITR complet. Revue des accès (Hetzner, Cloudflare, GitHub, Stripe). Mise à jour de k3s. | +| **Annuelle** | Rotation `JWT_SECRET`, mots de passe base et Redis, clé age. Revue du modèle de menace. Test d'intrusion externe si le chiffre d'affaires le permet. | + +--- + +## 7. Contrôle + +``` +[ ] Clé SMTP Brevo publiée RÉVOQUÉE, nouvelle clé de production en place +[ ] Mots de passe base / Redis / JWT / MinIO de preprod changés +[ ] Mot de passe admin Grafana changé +[ ] gitleaks : aucun secret détecté +[ ] Crochet de pré-commit gitleaks installé +[ ] preflight-check.sh : aucun point bloquant +[ ] nmap depuis l'extérieur : db-01 entièrement filtré +[ ] SSL Labs ≥ A +[ ] securityheaders.com ≥ A +[ ] Comptes de démonstration : 401 sur les trois +[ ] Un seul ADMIN actif, le vôtre +[ ] Bucket documents : 403 en anonyme +[ ] trivy : aucune CRITICAL exploitable à distance +[ ] Procédure de réponse à incident lue et comprise +[ ] Entretien planifié dans l'agenda +``` + +→ **Suite : [14 — Runbook de mise en ligne](./14-runbook-go-live.md)** diff --git a/docs/mise-en-prod/14-runbook-go-live.md b/docs/mise-en-prod/14-runbook-go-live.md new file mode 100644 index 0000000..4aee62a --- /dev/null +++ b/docs/mise-en-prod/14-runbook-go-live.md @@ -0,0 +1,256 @@ +# 14 — Runbook de mise en ligne + +Le déroulé du dernier jour et de l'ouverture. **Imprimez-le ou gardez-le ouvert +à côté de vous** — ce n'est pas le moment de chercher une commande dans un +autre fichier. + +--- + +## Choisir le moment + +| Bon moment | Mauvais moment | +|---|---| +| **Mardi ou mercredi matin**, 9 h–10 h | Vendredi, quel que soit l'argument | +| Vous êtes disponible les 48 h suivantes | La veille de vos congés | +| Aucune migration lourde en attente | Pendant une maintenance Hetzner annoncée | + +Mardi matin laisse trois jours ouvrés pour découvrir et corriger ce qui n'a pas +été anticipé. + +--- + +## J-1 : répétition générale + +### 1. Contrôle complet (30 min) + +```bash +export KUBECONFIG=~/.kube/xpeditis-prod.yaml +cd infra/prod +DB_PUBLIC_IP= make preflight +``` + +**Aucun point bloquant ne doit subsister.** Consignez les avertissements dans un +registre des risques daté, avec pour chacun une décision explicite : accepté, +ou corrigé avant l'ouverture. + +### 2. Parcours fonctionnels de bout en bout (1 h) + +À faire **dans un navigateur, comme un vrai client**, pas en `curl`. + +``` +[ ] Inscription d'un nouveau compte +[ ] Réception de l'e-mail de validation (vérifier : boîte principale, pas spam) +[ ] Validation, puis connexion +[ ] Recherche de tarifs → au moins un résultat s'affiche +[ ] Création d'une réservation +[ ] Réception de l'e-mail de confirmation +[ ] Génération et téléchargement d'un PDF +[ ] Lien magique transporteur : réception, ouverture, acceptation +[ ] Souscription à un abonnement en mode Live (petit montant, puis remboursement) +[ ] Réception du webhook Stripe (Dashboard Stripe → Webhooks → Tentatives) +[ ] Notification temps réel visible dans l'interface +[ ] Déconnexion, mot de passe oublié, réinitialisation +[ ] Espace d'administration accessible avec votre compte ADMIN +[ ] Import d'une grille tarifaire CSV +[ ] Navigation sur mobile +``` + +> **Le test Stripe en mode Live est indispensable.** Les clés de test et de +> production ont des comportements différents, et le secret de webhook n'est pas +> le même. Une souscription à 1 €, immédiatement remboursée, vaut mieux que la +> découverte du problème par le premier client. + +> **La notification temps réel** peut ne pas arriver : c'est attendu avec deux +> replicas. Voir [15 § Points de vigilance](./15-exploitation-incidents.md#points-de-vigilance-connus). + +### 3. Répétition du retour arrière (20 min) + +Exercez-vous pendant que l'enjeu est nul. + +```bash +# Déployer sciemment une version antérieure +kubectl -n xpeditis-prod set image deploy/xpeditis-backend \ + backend=rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:prod- +kubectl -n xpeditis-prod rollout status deploy/xpeditis-backend + +# Puis revenir +make rollback +bash scripts/smoke-test.sh +``` + +**Chronométrez.** C'est votre temps de retour réel. + +### 4. Sauvegarde manuelle (10 min) + +```bash +ssh deploy@ 'sudo systemctl start xpeditis-backup.service' +ssh deploy@ 'sudo journalctl -u xpeditis-backup -n 30 --no-pager' +``` + +Notez l'horodatage : c'est votre point de retour du jour J. + +### 5. Décision go / no-go + +| Critère | Bloquant ? | +|---|---| +| `preflight-check.sh` sans point bloquant | **oui** | +| Test PITR réussi ([12](./12-sauvegardes-restauration.md)) | **oui** | +| Clé SMTP Brevo compromise révoquée | **oui** | +| Comptes de démonstration neutralisés (401) | **oui** | +| Un seul ADMIN actif | **oui** | +| Parcours d'inscription et de réservation fonctionnels | **oui** | +| Paiement Stripe testé en Live | **oui** | +| Alerte de test reçue sur Discord | **oui** | +| Supervision externe active | **oui** | +| Retour arrière répété | **oui** | +| SSL Labs ≥ A | non — corriger sous 7 j | +| Notifications temps réel fiables | non — dégradation connue | +| Sentry connecté | non | + +**Un seul « oui » en échec = report.** Reporter d'une semaine coûte infiniment +moins cher que d'ouvrir avec un administrateur au mot de passe public. + +--- + +## J0 : ouverture + +### T-30 min + +```bash +export KUBECONFIG=~/.kube/xpeditis-prod.yaml +cd infra/prod + +make status # tous les pods Running, certificats Ready +make smoke # tout au vert + +# Sauvegarde de dernière minute +ssh deploy@ 'sudo systemctl start xpeditis-backup.service' +``` + +Ouvrez et gardez sous les yeux : +- Grafana : `https://grafana.xpeditis.com` +- Le canal Discord `#alertes` +- Le tableau de bord Stripe +- Un terminal avec `make -C infra/prod logs` + +### T-0 : rendre public + +Si vous aviez restreint l'accès pendant la préparation (règle Cloudflare, +liste d'IP), retirez-la maintenant. + +```bash +# Vérification finale, depuis un réseau extérieur (partage de connexion mobile) +curl -sI https://xpeditis.com/ | head -1 +curl -sI https://app.xpeditis.com/ | head -1 +curl -s https://api.xpeditis.com/api/v1/health | jq +``` + +Publiez : site vitrine, réseaux sociaux, e-mail aux premiers clients. + +### T+15 min + +```bash +make smoke +kubectl -n xpeditis-prod get pods +kubectl -n xpeditis-prod top pods 2>/dev/null || true +``` + +Dans Grafana : +- taux d'erreur 5xx — doit rester à 0 ; +- latence P95 — sous 1 s ; +- mémoire des pods — stable, pas de croissance continue. + +### T+1 h, T+4 h, puis fin de journée + +Les mêmes contrôles. Ce que vous cherchez : + +| Signal | Interprétation | +|---|---| +| Mémoire du backend en croissance continue | Fuite mémoire → surveiller, redémarrer si besoin | +| Erreurs 401 en rafale | Problème de cookie (`COOKIE_DOMAIN`) ou de CORS | +| Requêtes PostgreSQL lentes | Index manquant sur une table qui grossit | +| Connexions PostgreSQL en hausse | Pool non libéré | +| Espace disque en baisse rapide | Journaux ou WAL — vérifier que l'archivage fonctionne | + +--- + +## Si ça tourne mal + +### Le site est cassé pour tout le monde + +```bash +make -C infra/prod rollback +make -C infra/prod smoke +``` + +Si le retour arrière ne suffit pas : voir +[15 § Le site est hors ligne](./15-exploitation-incidents.md#le-site-est-hors-ligne). + +### Il faut refermer temporairement + +Cloudflare → Security → Settings → **Under Attack Mode**. +Les visiteurs passent par une page d'interstitiel ; vos IP restent autorisées. +C'est réversible en un clic, contrairement à une modification de firewall. + +### Perte ou corruption de données + +Ne rien faire d'autre, et suivre +[12 § Restauration](./12-sauvegardes-restauration.md#5-restauration). +Chaque écriture supplémentaire complique la remise en état. + +--- + +## J+1 à J+7 + +### Chaque jour + +```bash +make -C infra/prod status +``` + +``` +[ ] Aucune alerte non traitée sur Discord +[ ] Sauvegarde de la nuit réussie (battement de cœur vert) +[ ] Taux d'erreur stable +[ ] Aucun pod redémarré sans raison +[ ] Aucun paiement en échec inexpliqué côté Stripe +``` + +### J+7 : bilan + +``` +[ ] Vérification hebdomadaire de restauration passée (dimanche 04h00) +[ ] Consommation réelle vs dimensionnement — le CPX41 est-il bien calibré ? +[ ] Coût réel vs prévision (Xpeditis_Previsions_Couts.xlsx) +[ ] Registre des risques relu : les avertissements acceptés sont-ils traités ? +[ ] Retours utilisateurs : quelque chose casse-t-il de façon récurrente ? +[ ] HSTS : passer de 1 mois à 12 mois + preload si tout est stable +``` + +--- + +## Contacts et accès en urgence + +À remplir et à garder **hors du dépôt** (gestionnaire de mots de passe, ou +papier dans un tiroir) : + +``` +Hetzner Cloud console.hetzner.cloud compte : ____________ +Hetzner Robot robot.hetzner.com (Storage Box) +Cloudflare dash.cloudflare.com compte : ____________ +Registrar ____________ +Scaleway console.scaleway.com +Stripe dashboard.stripe.com +Brevo app.brevo.com +GitHub github.com/____________ + +SSH admin ssh -i ~/.ssh/xpeditis_prod deploy@ +SSH base ssh -i ~/.ssh/xpeditis_prod deploy@ +Kubeconfig ~/.kube/xpeditis-prod.yaml +Clé age ~/.config/sops/age/keys.txt (+ copie hors ligne : ________) +Clé de secours emplacement physique : ____________ +``` + +--- + +→ **Suite : [15 — Exploitation et incidents](./15-exploitation-incidents.md)** diff --git a/docs/mise-en-prod/15-exploitation-incidents.md b/docs/mise-en-prod/15-exploitation-incidents.md new file mode 100644 index 0000000..b96e7cc --- /dev/null +++ b/docs/mise-en-prod/15-exploitation-incidents.md @@ -0,0 +1,405 @@ +# 15 — Exploitation et incidents + +Document de référence, à consulter au coup par coup. + +--- + +## Points de vigilance connus + +Trois limites identifiées dans le code. Aucune n'empêche l'ouverture, mais +toutes doivent être connues avant qu'un client ne les découvre. + +### 1. Notifications temps réel — dégradées avec 2 replicas + +**Constat.** `notifications.gateway.ts` conserve la correspondance +`userId → sockets` **en mémoire du processus** et n'utilise pas +`@socket.io/redis-adapter`. + +**Conséquence.** Avec deux replicas backend, une notification émise par le +replica A n'atteint pas un utilisateur connecté au replica B. En pratique, +environ **une notification temps réel sur deux se perd**. + +Les sessions collantes sont configurées (`k8s/base/04-backend.yaml`), ce qui +règle la négociation du handshake Socket.IO — mais **pas** la diffusion entre +instances. + +**Atténuation actuelle.** Les notifications restent visibles au rechargement +(elles sont lues via l'API REST) et les e-mails partent normalement. Seul le +« temps réel » est affecté. + +**Solution de contournement immédiate**, si le temps réel est critique : + +```bash +kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=1 +kubectl -n xpeditis-prod patch hpa xpeditis-backend --type=merge \ + -p '{"spec":{"minReplicas":1,"maxReplicas":1}}' +``` +Prix à payer : plus de haute disponibilité, et une brève coupure à chaque +déploiement. + +**Correctif de fond** (à faire dans le premier mois) : + +```bash +cd apps/backend && npm i @socket.io/redis-adapter ioredis +``` + +```ts +// src/infrastructure/websocket/redis-io.adapter.ts +import { IoAdapter } from '@nestjs/platform-socket.io'; +import { createAdapter } from '@socket.io/redis-adapter'; +import { Redis } from 'ioredis'; +import type { ServerOptions } from 'socket.io'; + +export class RedisIoAdapter extends IoAdapter { + private adapterConstructor: ReturnType; + + async connectToRedis(): Promise { + const opts = { + host: process.env.REDIS_HOST, + port: Number(process.env.REDIS_PORT ?? 6379), + password: process.env.REDIS_PASSWORD, + }; + const pubClient = new Redis(opts); + const subClient = pubClient.duplicate(); + this.adapterConstructor = createAdapter(pubClient, subClient); + } + + createIOServer(port: number, options?: ServerOptions): unknown { + const server = super.createIOServer(port, options); + (server as { adapter: (a: unknown) => void }).adapter(this.adapterConstructor); + return server; + } +} +``` + +```ts +// main.ts, avant app.listen() +const redisIoAdapter = new RedisIoAdapter(app); +await redisIoAdapter.connectToRedis(); +app.useWebSocketAdapter(redisIoAdapter); +``` + +La carte `userSockets` en mémoire reste utile localement ; le diffuseur Redis +prend en charge la propagation entre instances. + +### 2. La sonde de disponibilité ne teste rien + +`health.controller.ts` renvoie `{status: 'ready', checks: {database: 'ok', +redis: 'ok'}}` **en dur**, sans interroger quoi que ce soit. + +**Conséquence.** Un pod incapable de joindre PostgreSQL est déclaré prêt et +Traefik lui envoie du trafic. Les utilisateurs prennent des 500 au lieu d'être +redirigés vers un pod sain. + +**Correctif** (`@nestjs/terminus`) : + +```bash +cd apps/backend && npm i @nestjs/terminus +``` + +```ts +@Get('ready') +@HealthCheck() +check() { + return this.health.check([ + () => this.db.pingCheck('database', { timeout: 3000 }), + () => this.redis.checkHealth('redis'), + ]); +} +``` + +En attendant, la sonde de vivacité (`/health/live`) et les alertes Prometheus +couvrent l'essentiel : un pod réellement mort est bien détecté. + +### 3. Retour arrière et migrations + +`rollback` revient à l'image précédente, **pas au schéma précédent**. Voir +[§ Retour arrière avec migration](#retour-arriere-avec-migration). + +--- + +## Le site est hors ligne + +### Diagnostic, du plus extérieur au plus intérieur + +```bash +# 1. Est-ce Cloudflare ou nous ? +curl -sI https://app.xpeditis.com/ | head -3 +# 521/522 → Cloudflare n'atteint pas l'origine +# 523 → problème DNS +# 200 → ce n'est pas l'infrastructure : voir l'application + +# 2. Le serveur répond-il ? +ping -c3 +ssh deploy@ 'uptime; df -h /; free -m' + +# 3. k3s tourne-t-il ? +ssh deploy@ 'sudo systemctl status k3s --no-pager | head -20' + +# 4. Les pods ? +kubectl -n xpeditis-prod get pods +kubectl -n xpeditis-prod get events --sort-by=.lastTimestamp | tail -20 + +# 5. La base ? +ssh deploy@ 'cd /opt/xpeditis/data-node && sudo docker compose ps' +``` + +### Causes fréquentes + +| Symptôme | Cause | Correctif | +|---|---|---| +| 521/522, serveur joignable | Les rangs IP Cloudflare ont changé | `bash scripts/refresh-cloudflare-ips.sh --write && terraform apply` | +| Pods `Evicted`, `df` proche de 100 % | Disque plein | Voir § Disque plein | +| Pods `Pending` | Ressources insuffisantes | `kubectl describe pod` ; réduire les replicas ou agrandir le serveur | +| `CrashLoopBackOff` | Erreur applicative | `kubectl logs --previous` | +| `ImagePullBackOff` | `regcred` périmé | Recréer le secret ([05 § 4](./05-cluster-k3s.md)) | +| Erreurs de connexion base | db-01 en panne | `docker compose up -d` sur db-01 | +| k3s ne démarre pas | `--protect-kernel-defaults` | `sysctl --system` puis `systemctl restart k3s` | + +### Disque plein + +La panne la plus fréquente d'un serveur laissé seul. + +```bash +ssh deploy@ ' + df -h + sudo du -sh /var/lib/rancher/k3s/agent/containerd 2>/dev/null + sudo du -sh /var/log/* 2>/dev/null | sort -h | tail -10 + sudo du -sh /var/lib/xpeditis/* 2>/dev/null +' + +# Sur app-01 : purger les images inutilisées +ssh deploy@ 'sudo k3s crictl rmi --prune' + +# Journaux systemd +ssh deploy@ 'sudo journalctl --vacuum-size=500M' + +# Sur db-01 : WAL qui s'accumulent = archivage cassé +ssh deploy@ 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres \ + psql -c "SELECT * FROM pg_stat_archiver;"' +# Si failed_count augmente : WAL-G ne peut plus écrire sur Object Storage. +# Vérifier les identifiants dans .env.data et la joignabilité du service. +``` + +> Des WAL qui s'accumulent finissent par remplir le disque et **arrêter +> PostgreSQL**. Un `failed_count` qui monte dans `pg_stat_archiver` est une +> urgence, pas un avertissement. + +--- + +## Retour arrière + +### Cas simple, sans migration + +```bash +make -C infra/prod rollback +make -C infra/prod smoke +``` + +Ou pour une version précise : +```bash +kubectl -n xpeditis-prod rollout history deploy/xpeditis-backend +kubectl -n xpeditis-prod rollout undo deploy/xpeditis-backend --to-revision=3 +``` + +### Retour arrière avec migration + +**`rollout undo` ne défait pas les migrations.** Si la version retirée +contenait une migration destructrice, l'ancienne version applicative peut ne +plus fonctionner contre le schéma courant. + +Trois situations : + +| Migration | Peut-on revenir en arrière simplement ? | +|---|---| +| Ajout de table ou de colonne nullable | **Oui.** L'ancien code ignore ce qu'il ne connaît pas. | +| Ajout de colonne NOT NULL sans défaut | **Non.** L'ancien code fera échouer les insertions. | +| Suppression ou renommage de colonne | **Non.** L'ancien code interrogera une colonne absente. | +| Changement de type | **Non.** | + +Pour les cas non réversibles : + +```bash +# 1. Arrêter les écritures +kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0 + +# 2. Annuler la dernière migration +kubectl -n xpeditis-prod run migration-revert --rm -it --restart=Never \ + --image=rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:prod- \ + --overrides='{"spec":{"imagePullSecrets":[{"name":"regcred"}],"containers":[{ + "name":"migration-revert", + "image":"rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:prod-", + "command":["node","./node_modules/typeorm/cli.js","migration:revert","-d", + "dist/infrastructure/persistence/typeorm/data-source.js"], + "envFrom":[{"configMapRef":{"name":"xpeditis-backend-config"}}, + {"secretRef":{"name":"xpeditis-backend-secrets"}}]}]}}' + +# 3. Revenir à l'image précédente +kubectl -n xpeditis-prod set image deploy/xpeditis-backend backend=...:prod- +kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2 +``` + +Si `down()` n'est pas implémentée ou échoue, il ne reste que la restauration +PITR ([12](./12-sauvegardes-restauration.md)). + +> **Écrivez des migrations réversibles.** Pour supprimer une colonne, procédez +> en deux temps : d'abord une version qui cesse de l'utiliser, puis, une fois +> celle-ci stabilisée en production, une seconde qui supprime la colonne. Le +> retour arrière reste possible à chaque étape. + +--- + +## Montée en charge + +### Signaux + +| Signal | Seuil | Action | +|---|---|---| +| CPU backend soutenu | > 70 % | Le HPA monte à 4. Au-delà, agrandir le serveur. | +| Mémoire nœud | > 85 % | Passer en CPX51 (16 vCPU / 32 Go). | +| Connexions PostgreSQL | > 80 % | Augmenter `max_connections` ou ajouter PgBouncer. | +| Latence P95 | > 1 s | Chercher la cause avant d'ajouter des ressources. | +| Disque db-01 | > 70 % | Agrandir le volume (à chaud). | + +### Agrandir un serveur + +```bash +kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=1 # app-01 seulement +ssh deploy@ 'sudo shutdown -h now' +# Console Hetzner → Rescale → CPX51 → Power on +# Ou : $EDITOR terraform.tfvars puis terraform apply +``` + +Quelques minutes de coupure. Le disque ne peut qu'être **agrandi**, jamais +réduit : la manœuvre est irréversible. + +### Agrandir le volume PostgreSQL + +Sans coupure : + +```bash +cd infra/prod/terraform +$EDITOR terraform.tfvars # db_volume_size = 100 +terraform apply + +ssh deploy@ ' + sudo resize2fs /dev/disk/by-id/scsi-0HC_Volume_* + df -h /var/lib/xpeditis/pgdata +' +``` + +### Passer à deux nœuds (phase 2) + +Quand un seul nœud ne suffit plus, ou que la coupure lors d'une panne devient +inacceptable : + +1. Ajouter un `hcloud_server.app2` dans Terraform (mêmes firewalls, même réseau). +2. L'installer en agent k3s : + ```bash + curl -sfL https://get.k3s.io | K3S_URL=https://10.10.1.10:6443 \ + K3S_TOKEN=$(ssh deploy@ 'sudo cat /var/lib/rancher/k3s/server/node-token') sh - + ``` +3. Ajouter un Load Balancer Hetzner LB11 (6 €/mois) devant les deux nœuds. +4. Les `topologySpreadConstraints` déjà présents répartiront automatiquement les + pods — ils sont en `ScheduleAnyway`, ils deviennent effectifs sans + modification. +5. Ajouter un standby PostgreSQL en réplication sur un troisième serveur. + +Budget correspondant : ligne « 1 000 utilisateurs » du fichier de prévisions. + +--- + +## Mises à jour + +### Application + +Chaîne normale : `preprod` → validation → `main` → déploiement automatique. + +### Système (automatique) + +`unattended-upgrades` applique les correctifs de sécurité chaque nuit et +redémarre à 04h30 si le noyau l'exige. + +```bash +ssh deploy@ 'sudo journalctl -u unattended-upgrades -n 30 --no-pager' +``` + +> Le redémarrage automatique d'app-01 provoque **quelques minutes de coupure** : +> k3s redémarre, puis les pods. C'est un choix assumé — un noyau non corrigé est +> un risque plus grand que quelques minutes d'indisponibilité nocturne. Avec +> deux nœuds (phase 2), décalez les fenêtres pour supprimer la coupure. + +### k3s (trimestriel) + +```bash +# Sauvegarde de l'état du cluster +ssh deploy@ 'sudo k3s etcd-snapshot save --name avant-maj' + +# Mise à jour +ssh deploy@ 'curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.32.x+k3s1 sh -' +kubectl get nodes +make -C infra/prod smoke +``` + +Restez sur une version mineure de retard par rapport à la dernière : les +correctifs de régression arrivent vite. + +### PostgreSQL + +Les correctifs (15.x → 15.y) sont sans risque : +```bash +ssh deploy@ 'cd /opt/xpeditis/data-node && \ + sudo docker compose --env-file .env.data pull postgres && \ + sudo docker compose --env-file .env.data up -d postgres' +``` + +Une montée de version **majeure** (15 → 16) impose un `pg_dump` / `pg_restore` +et une fenêtre de maintenance planifiée. Ne l'improvisez pas. + +--- + +## Commandes de tous les jours + +```bash +cd infra/prod + +make status # vue d'ensemble +make logs # journaux backend en direct +make events # derniers événements Kubernetes +make smoke # l'extérieur voit-il un service sain ? +make restart # redémarrer les pods (après changement de secret) +make rollback # revenir à la version précédente + +# Base de données +ssh deploy@ +cd /opt/xpeditis/data-node +sudo docker compose exec -u postgres postgres psql -d xpeditis_prod + +# Requêtes en cours +sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c " + SELECT pid, now()-query_start AS duree, state, left(query, 80) + FROM pg_stat_activity WHERE state <> 'idle' ORDER BY duree DESC;" + +# Tuer une requête bloquante +sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c \ + "SELECT pg_cancel_backend();" # doux + # puis, si nécessaire : pg_terminate_backend() +``` + +--- + +## Journal des incidents + +Tenez-le. Après trois mois, il vous dira où porter vos efforts bien mieux que +n'importe quelle intuition. + +```markdown +## AAAA-MM-JJ — Titre court + +**Détecté** : par qui / quoi, à quelle heure +**Durée** : de HH:MM à HH:MM +**Impact** : combien d'utilisateurs, quelles fonctions +**Cause** : ce qui s'est réellement passé +**Correctif** : ce qui a été fait +**Prévention** : ce qui empêche la récidive — et si rien, pourquoi +``` diff --git a/docs/mise-en-prod/16-rgpd-conformite.md b/docs/mise-en-prod/16-rgpd-conformite.md new file mode 100644 index 0000000..e985651 --- /dev/null +++ b/docs/mise-en-prod/16-rgpd-conformite.md @@ -0,0 +1,241 @@ +# 16 — RGPD et conformité + +Xpeditis traite des données personnelles de professionnels : noms, adresses +e-mail, numéros de téléphone, adresses, SIRET, documents de transport nominatifs. +Le RGPD s'applique intégralement. + +> Ce document décrit ce que l'infrastructure met en place et ce qu'il reste à +> faire. Il ne remplace pas un conseil juridique. Faites relire vos mentions +> légales, votre politique de confidentialité et vos contrats de sous-traitance +> par un professionnel avant de démarcher des grands comptes. + +--- + +## 1. Ce que l'infrastructure apporte déjà + +| Exigence | Mise en œuvre | +|---|---| +| Hébergement dans l'UE | Hetzner, Falkenstein (Allemagne). Terraform refuse toute région hors UE. | +| Chiffrement en transit | TLS 1.2+ partout, y compris entre l'application et PostgreSQL. | +| Chiffrement au repos | Sauvegardes chiffrées côté client (libsodium, age). `Secret` k8s chiffrés dans l'état de k3s. | +| Contrôle d'accès | RBAC applicatif, Argon2, JWT courts, cookies `httpOnly`. | +| Traçabilité | `audit_logs` applicatif, journal d'audit Kubernetes, auditd système. | +| Minimisation dans les journaux | pino expurge `authorization`, `x-api-key` et les mots de passe. Traefik ne conserve que `User-Agent` et `Cf-Connecting-Ip`. | +| Durée de conservation des journaux | Loki 31 jours, Prometheus 15 jours, audit k8s 30 jours. | +| Sécurité des sauvegardes | Chiffrées, cloisonnées, testées. | +| Résilience | PITR 5 min, RTO < 2 h — l'article 32 exige de pouvoir « rétablir la disponibilité ». | + +> **Le chiffrement du disque n'est pas activé.** Les volumes Hetzner ne sont pas +> chiffrés au repos par défaut. En pratique, la protection repose sur la sécurité +> physique du datacenter (certifié ISO 27001) et sur la destruction sécurisée des +> supports. Si un client l'exige contractuellement, il faut mettre en place LUKS +> sur le volume PostgreSQL — au prix d'une clé à saisir ou à stocker à chaque +> démarrage, ce qui déplace le problème plus qu'il ne le résout sur un serveur +> distant. À arbitrer, pas à improviser. + +--- + +## 2. Registre des sous-traitants + +À tenir à jour. C'est la première chose que demande un client grand compte, et +c'est obligatoire au titre de l'article 30. + +| Sous-traitant | Rôle | Données | Localisation | Transfert hors UE | DPA | +|---|---|---|---|---|---| +| **Hetzner Online GmbH** | Hébergement, stockage, sauvegardes | Toutes | Allemagne | non | [DPA Hetzner](https://www.hetzner.com/AV/DPA_en.pdf) à signer | +| **Cloudflare Inc.** | DNS, WAF, CDN | IP, en-têtes, métadonnées de requête | Réseau mondial | **oui** (États-Unis) | DPA + CCT, incluses aux CGU | +| **Stripe Inc.** | Paiements | Nom, e-mail, données de facturation | UE + États-Unis | **oui** | DPA Stripe | +| **Brevo (Sendinblue SAS)** | E-mails transactionnels | Nom, e-mail, contenu des messages | France | non | DPA Brevo | +| **Functional Software Inc. (Sentry)** | Suivi des erreurs | Traces, potentiellement des identifiants utilisateur | États-Unis | **oui** | DPA + purge des données | +| **Pappers (SAS)** | Vérification SIRET | SIRET, raison sociale | France | non | DPA | +| **Scaleway SAS** | Registre d'images | Aucune donnée personnelle | France | non | — | +| **GitHub Inc.** | Code source, CI | Aucune donnée client | États-Unis | oui | — | + +### Actions + +``` +[ ] Signer le DPA Hetzner (formulaire dans la console) +[ ] Récupérer et archiver les DPA Cloudflare, Stripe, Brevo, Sentry, Pappers +[ ] Activer la purge des données personnelles dans Sentry + Settings → Security & Privacy → Data Scrubbing → activer + + champs supplémentaires : email, phone, siret, token +[ ] Documenter ce registre dans un document versionné +``` + +> **Sentry mérite une attention particulière.** Une trace d'exception peut +> contenir l'e-mail, l'identifiant et le corps de requête d'un utilisateur, et +> partir chez un sous-traitant américain. Activez la purge **avant** de connecter +> Sentry à la production, pas après. + +--- + +## 3. Durées de conservation + +À décider, puis à appliquer techniquement. Proposition de départ : + +| Donnée | Durée proposée | Justification | +|---|---|---| +| Compte utilisateur actif | Durée de la relation contractuelle | Exécution du contrat | +| Compte inactif | 3 ans après la dernière connexion | Recommandation CNIL en prospection B2B | +| Réservations et documents de transport | **10 ans** | Obligation comptable et commerciale (art. L123-22 code de commerce) | +| Factures | 10 ans | Idem | +| `audit_logs` | 1 an | Sécurité, art. 32 | +| Journaux techniques (Loki) | 31 jours | Déjà appliqué | +| Sauvegardes | 7 j PITR, 30 j dumps | Déjà appliqué | +| Cookies analytiques | 13 mois | Recommandation CNIL | + +> **La conservation légale prime sur le droit à l'effacement.** Un client peut +> demander la suppression de son compte : les réservations qui portent une +> obligation comptable doivent être conservées, mais peuvent être +> **pseudonymisées** (retirer nom, e-mail, téléphone en gardant les montants et +> références). C'est ce que doit faire la fonction d'effacement du module GDPR. + +### À vérifier dans le code + +Le backend expose un module `gdpr`. Vérifiez qu'il fait bien ce qu'il annonce : + +```bash +# Que fait réellement l'export ? Que fait la suppression ? +grep -rn "class.*Gdpr\|anonymi\|pseudonym" apps/backend/src/application/gdpr/ +``` + +``` +[ ] L'export de données couvre : compte, organisation, réservations, documents +[ ] La suppression pseudonymise au lieu d'effacer les données à conservation légale +[ ] Une purge automatique des comptes inactifs > 3 ans existe (ou est planifiée) +``` + +--- + +## 4. Documents obligatoires + +| Document | Où | Statut | +|---|---|---| +| Politique de confidentialité | `/privacy` (route publique existante) | à rédiger | +| Mentions légales | `/terms` | à rédiger | +| Politique de cookies + bandeau de consentement | `/cookies` | à rédiger | +| CGU / CGV | `/terms` | à rédiger | +| Registre des traitements (art. 30) | interne | à créer | +| Registre des sous-traitants | interne | §2 ci-dessus | +| Procédure de violation de données | interne | §6 ci-dessous | + +Les routes `/privacy`, `/terms` et `/cookies` sont déjà publiques dans +`middleware.ts` : il ne manque que le contenu. + +### Bandeau de cookies + +Nécessaire **uniquement** si vous déposez des cookies non essentiels (mesure +d'audience, publicité). Les cookies d'authentification `httpOnly` sont +strictement nécessaires et n'exigent pas de consentement. + +Si vous ajoutez Google Analytics (`NEXT_PUBLIC_GA_ID` est prévu dans le +Dockerfile frontend), un bandeau de consentement **préalable** devient +obligatoire — le script ne doit pas se charger avant l'acceptation. + +**Alternative plus simple** : Plausible ou Matomo auto-hébergé, sans cookie ni +consentement requis. Moins de code, moins de risque juridique. + +--- + +## 5. Droits des personnes + +Vous devez répondre sous **un mois**. + +| Droit | Mise en œuvre | +|---|---| +| Accès / portabilité | Module GDPR — export JSON ou CSV | +| Rectification | Interface de profil | +| Effacement | Module GDPR — pseudonymisation, cf. §3 | +| Limitation | Désactivation du compte (`is_active = false`) | +| Opposition | Désinscription des communications | + +Publiez une adresse de contact dédiée (`privacy@xpeditis.com` ou +`dpo@xpeditis.com`) dans la politique de confidentialité, et **relevez-la**. + +### DPO + +Non obligatoire pour une PME dont le traitement de données personnelles n'est +ni massif ni sensible. Désignez néanmoins un **référent RGPD** nommément — +c'est ce que demandent les clients grands comptes. + +--- + +## 6. Violation de données + +**Notification à la CNIL sous 72 heures** à compter de la prise de connaissance +(art. 33). Si le risque pour les personnes est élevé, il faut **aussi** les +informer directement (art. 34). + +### Procédure + +``` +1. CONSTATER Suivre 13 § Réponse à incident. Préserver les preuves. +2. QUALIFIER Quelles données ? Combien de personnes ? Quel risque réel ? +3. CONTENIR Isoler, faire tourner les secrets, restaurer. +4. NOTIFIER CNIL sous 72 h : notifications.cnil.fr + Même incomplète, une notification dans les délais vaut mieux + qu'une notification complète hors délai. +5. INFORMER Les personnes concernées si le risque est élevé. +6. DOCUMENTER Journal des violations : obligatoire, même sans notification. +``` + +### Ce dont vous aurez besoin + +- Quelles données, pour combien de personnes ? +- Depuis quand, jusqu'à quand ? +- Comment cela a-t-il été découvert ? +- Quelles mesures ont été prises ? +- Quelles conséquences probables pour les personnes ? + +Les sources : `audit_logs`, Loki (31 j), journal d'audit k8s (30 j), auditd, +journaux PostgreSQL. **Ces rétentions déterminent votre capacité à répondre.** +Une intrusion découverte 45 jours après coup ne sera pas reconstituable — c'est +un argument pour porter la rétention Loki à 90 jours dès que le volume le permet. + +--- + +## 7. Ce qu'exigeront les grands comptes + +Au-delà du RGPD, attendez-vous à : + +| Demande | Statut | Coût | +|---|---|---| +| Questionnaire sécurité (50-200 questions) | ce dossier y répond en grande partie | temps | +| DPA signé de votre côté | à préparer | juridique | +| Preuve de sauvegardes testées | [12](./12-sauvegardes-restauration.md) | fait | +| Engagement de disponibilité (SLA) | pas d'engagement contractuel aujourd'hui | à arbitrer | +| Test d'intrusion récent | non fait | 3 à 8 k€ | +| SOC 2 Type II | non | ~800 €/mois (Vanta) + 15-25 k€ d'audit | +| ISO 27001 | non | 20-40 k€ | +| Assurance responsabilité civile professionnelle / cyber | **prévue au budget** (150 €/mois) | à souscrire | + +**Souscrivez la RC Pro / cyber avant l'ouverture.** Elle figure dans le fichier +de prévisions et couvre précisément ce que la technique ne peut pas couvrir : +les conséquences financières d'un incident. + +--- + +## 8. Contrôle + +``` +[ ] Hébergement UE confirmé (fsn1) +[ ] DPA Hetzner signé +[ ] DPA Cloudflare, Stripe, Brevo, Sentry, Pappers archivés +[ ] Purge des données personnelles activée dans Sentry +[ ] Registre des sous-traitants rédigé +[ ] Registre des traitements (art. 30) rédigé +[ ] Durées de conservation décidées et documentées +[ ] Module GDPR vérifié : export complet, suppression pseudonymisante +[ ] Politique de confidentialité publiée sur /privacy +[ ] Mentions légales et CGU publiées sur /terms +[ ] Politique de cookies publiée sur /cookies +[ ] Adresse privacy@ ou dpo@ publiée et relevée +[ ] Référent RGPD désigné nommément +[ ] Procédure de violation de données rédigée et accessible hors ligne +[ ] Assurance RC Pro / cyber souscrite +``` + +--- + +**Fin de la documentation de mise en production.** +Retour à l'[index](./README.md). diff --git a/docs/mise-en-prod/README.md b/docs/mise-en-prod/README.md new file mode 100644 index 0000000..7f9bab5 --- /dev/null +++ b/docs/mise-en-prod/README.md @@ -0,0 +1,174 @@ +# Mise en production — Xpeditis sur Hetzner + +Procédure complète, pas à pas, pour ouvrir Xpeditis au public de manière sûre. + +**Option retenue** : Hetzner auto-hébergé (feuille « Hetzner (auto-hébergé) » du +fichier `Xpeditis_Previsions_Couts.xlsx`), k3s sur 2 serveurs, ≈ 66 € HT/mois +d'infrastructure. + +Tous les fichiers évoqués ici vivent dans [`infra/prod/`](../../infra/prod/README.md). + +--- + +## Lisez ceci en premier + +L'analyse du dépôt a mis au jour **quatre problèmes qui auraient compromis ou +cassé la production**. Ils sont traités dans les fichiers livrés, mais deux +exigent une action de votre part. + +### 0. Une migration créait un ADMIN au mot de passe public — corrigé + +`1730000000007-SeedTestUsers` insérait `admin@xpeditis.com` (rôle **ADMIN**), +`manager@` et `user@`, tous avec le mot de passe `Password123!` — écrit en clair +dans le dépôt. Sur une base de production neuve, appliquer les migrations créait +donc un administrateur dont les identifiants sont publics. C'était le point le +plus grave du parcours. + +Trois mécanismes, tous automatiques : + +| Migration | Rôle | +|---|---| +| `1730000000007-SeedTestUsers` (modifiée) | **Ne s'exécute plus** si `NODE_ENV=production`. Les comptes ne sont jamais créés. Dev et preprod gardent les leurs. | +| `1756000000000-NeutralizeSeedAccountsInProduction` | Filet de sécurité pour toute base où ils existeraient déjà : renommage, hash inauthentifiable, désactivation. Échoue si le nettoyage est incomplet. | +| `1756000000001-BootstrapAdminFromEnv` | Crée **votre** administrateur depuis `BOOTSTRAP_ADMIN_EMAIL`, **sans aucun mot de passe stocké** : vous définissez le vôtre via « mot de passe oublié ». | + +Résultat : aucun secret n'existe nulle part — ni dans Git, ni dans le Secret +Kubernetes, ni dans l'historique du shell. `preflight-check.sh` vérifie ensuite +en conditions réelles que `Password123!` est bien refusé sur les trois adresses. + +→ **[09 — Déploiement applicatif](./09-deploiement-application.md#le-premier-administrateur)** + +### 1. Les secrets de preprod sont dans l'historique Git — action requise + +`infra/preprod/docker-stack.preprod.yml` est versionné et contient **en clair** : +mot de passe PostgreSQL, mot de passe Redis, `JWT_SECRET`, identifiants MinIO, +**clé SMTP Brevo**, clés Stripe de test, mot de passe administrateur Grafana. + +Ils sont dans l'historique Git : les réécrire ne suffirait pas, il faut les +considérer comme **compromis et les révoquer**. La clé Brevo en particulier est +un identifiant tiers actif : quiconque a lu le dépôt peut envoyer des e-mails +au nom de `noreply@xpeditis.com`. + +→ **[13-securite-durcissement.md § Rotation obligatoire](./13-securite-durcissement.md#rotation-obligatoire-avant-la-mise-en-production)** + +### 2. L'image frontend ne peut pas être promue depuis la preprod — corrigé + +`next.config.js` fige `NEXT_PUBLIC_API_URL` **au moment du build**. L'ancien +`cd-main.yml` re-taguait l'image de preprod vers la production : l'application +en production aurait appelé `api.preprod.xpeditis.com`. Le workflow réécrit +reconstruit le frontend avec les URLs de production et **vérifie que l'URL de +preprod n'est pas présente dans le bundle** avant de déployer. + +### 3. Les migrations partaient en concurrence — corrigé + +L'image backend lance les migrations à chaque démarrage de pod +(`scripts/setup/startup.js`). Avec deux replicas, deux processus migrent en même +temps ; TypeORM ne sérialise pas entre processus, l'un des deux part en +`CrashLoopBackOff`. Un **Job Kubernetes** (parallélisme 1) applique désormais les +migrations *avant* la mise à jour des images ; `startup.js` ne fait plus que +constater qu'il n'y a rien à migrer. **Aucune modification du code applicatif.** + +Un quatrième point ne bloque pas le lancement mais dégrade une fonctionnalité : +les notifications temps réel. Voir +[15-exploitation-incidents.md § Points de vigilance](./15-exploitation-incidents.md#points-de-vigilance-connus). + +--- + +## Chronologie + +| Quand | Étape | Durée | Document | +|---|---|---|---| +| **J-14** | Comptes, domaine, outils, décisions | 2-3 h | [01](./01-prerequis.md) | +| **J-10** | Serveurs, réseau, firewalls (Terraform) | 1 h | [02](./02-provisioning-hetzner.md) | +| **J-10** | Durcissement système des deux serveurs | 1 h | [03](./03-durcissement-serveurs.md) | +| **J-9** | PostgreSQL + Redis + sauvegardes | 2 h | [04](./04-noeud-donnees.md) | +| **J-8** | Cluster k3s | 1 h 30 | [05](./05-cluster-k3s.md) | +| **J-8** | Secrets SOPS | 1 h | [06](./06-secrets-sops.md) | +| **J-7** | Stockage objet | 45 min | [07](./07-stockage-objet-s3.md) | +| **J-7** | DNS, Cloudflare, TLS | 1 h 30 | [08](./08-dns-tls-cloudflare.md) | +| **J-6** | Premier déploiement applicatif | 2 h | [09](./09-deploiement-application.md) | +| **J-5** | CI/CD automatisée | 1 h 30 | [10](./10-cicd-github-actions.md) | +| **J-4** | Supervision et alertes | 2 h | [11](./11-observabilite.md) | +| **J-3** | **Test de restauration réel** | 2 h | [12](./12-sauvegardes-restauration.md) | +| **J-2** | Revue de sécurité, rotation des secrets | 3 h | [13](./13-securite-durcissement.md) | +| **J-1** | Répétition générale, go / no-go | 2 h | [14](./14-runbook-go-live.md) | +| **J0** | Ouverture | — | [14](./14-runbook-go-live.md) | +| **après** | Exploitation, incidents, montée en charge | — | [15](./15-exploitation-incidents.md) | +| **après** | RGPD et conformité | — | [16](./16-rgpd-conformite.md) | + +**Temps de travail cumulé : environ 25 heures.** Étalez-les : plusieurs étapes +comportent des délais incompressibles (propagation DNS, émission de certificat, +première sauvegarde complète). + +--- + +## Les documents + +| # | Fichier | Contenu | +|---|---|---| +| 01 | [Prérequis](./01-prerequis.md) | Comptes, outils, domaine, clés SSH, décisions à trancher | +| 02 | [Provisioning Hetzner](./02-provisioning-hetzner.md) | Terraform, serveurs, réseau privé, firewalls, volume | +| 03 | [Durcissement des serveurs](./03-durcissement-serveurs.md) | SSH, UFW, fail2ban, auditd, mises à jour automatiques | +| 04 | [Nœud de données](./04-noeud-donnees.md) | PostgreSQL 15 + TLS, Redis 7, WAL-G, timers de sauvegarde | +| 05 | [Cluster k3s](./05-cluster-k3s.md) | Installation durcie, Traefik, cert-manager, kubeconfig | +| 06 | [Secrets SOPS](./06-secrets-sops.md) | Clé age, chiffrement, application, rotation, sauvegarde de la clé | +| 07 | [Stockage objet](./07-stockage-objet-s3.md) | Hetzner Object Storage, buckets, clés cloisonnées | +| 08 | [DNS, TLS, Cloudflare](./08-dns-tls-cloudflare.md) | Enregistrements, proxy, WAF, certificat wildcard | +| 09 | [Déploiement applicatif](./09-deploiement-application.md) | Manifests, migrations, premier administrateur, données de référence | +| 10 | [CI/CD](./10-cicd-github-actions.md) | Secrets GitHub, environnement protégé, déploiement automatique | +| 11 | [Observabilité](./11-observabilite.md) | Loki, Prometheus, Grafana, alertes Discord, supervision externe | +| 12 | [Sauvegardes et restauration](./12-sauvegardes-restauration.md) | Stratégie 3-2-1, PITR, tests de restauration, RTO/RPO | +| 13 | [Sécurité](./13-securite-durcissement.md) | Rotation des secrets compromis, checklist, revue externe | +| 14 | [Runbook de mise en ligne](./14-runbook-go-live.md) | J-1, J0, go/no-go, plan de repli | +| 15 | [Exploitation et incidents](./15-exploitation-incidents.md) | Runbooks, points de vigilance, montée en charge, mises à jour | +| 16 | [RGPD et conformité](./16-rgpd-conformite.md) | Hébergement, sous-traitants, conservation, droits des personnes | + +--- + +## Architecture livrée + +``` +Internet → Cloudflare (WAF, anti-DDoS, cache) + │ le firewall Hetzner n'accepte 80/443 que depuis Cloudflare + ▼ + app-01 · CPX41 · fsn1 · k3s mono-nœud + Traefik ──► api.xpeditis.com → backend NestJS ×2 (HPA 2→4) + ├► app / www / apex → frontend Next.js ×2 + └► grafana.xpeditis.com (filtré par IP) + observabilité : Loki · Promtail · Prometheus · Alertmanager · Grafana + │ + │ réseau privé 10.10.1.0/24, PostgreSQL en TLS obligatoire + ▼ + db-01 · CPX31 · fsn1 · Docker Compose + PostgreSQL 15 + WAL-G · Redis 7 · postgres-exporter + volume dédié 50 Go + │ + ├─► Hetzner Object Storage (documents applicatifs + WAL-G) + └─► Hetzner Storage Box (dumps logiques chiffrés age) +``` + +--- + +## Principes appliqués partout + +1. **Tout secret est chiffré dans Git** (SOPS + age) ou n'y est pas du tout. +2. **La base n'est jamais joignable depuis Internet.** Réseau privé, `hostssl` + uniquement, UFW, aucun enregistrement DNS. +3. **Personne ne contourne Cloudflare.** +4. **SSH et l'API Kubernetes ne sont ouverts qu'à vos IP.** La CI ouvre une + fenêtre de deux minutes pour une seule IP, et la referme quoi qu'il arrive. +5. **Une sauvegarde non restaurée n'est pas une sauvegarde.** +6. **Ce qui n'est pas surveillé n'existe pas.** Chaque défaillance a une alerte, + et le silence des sauvegardes est lui-même surveillé — de l'extérieur. + +--- + +## En cas de problème + +| Situation | Aller directement à | +|---|---| +| Le site ne répond plus | [15 § Le site est hors ligne](./15-exploitation-incidents.md#le-site-est-hors-ligne) | +| Un déploiement a mal tourné | [15 § Retour arrière](./15-exploitation-incidents.md#retour-arriere) | +| Perte ou corruption de données | [12 § Restauration](./12-sauvegardes-restauration.md#restauration) | +| Suspicion de compromission | [13 § Réponse à incident](./13-securite-durcissement.md#reponse-a-incident) | +| Certificat expiré | [08 § Dépannage TLS](./08-dns-tls-cloudflare.md#depannage) | From a19a90cea028909658dce08b40a38ee1913d0b9b Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:50 +0200 Subject: [PATCH 3/4] feat(deploy): pipeline CD et composition Docker complete Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- .github/workflows/cd-main.yml | 303 +++++++++++++++++++++------------ docker/docker-compose.full.yml | 6 +- 2 files changed, 198 insertions(+), 111 deletions(-) diff --git a/.github/workflows/cd-main.yml b/.github/workflows/cd-main.yml index 5633e39..4b57d54 100644 --- a/.github/workflows/cd-main.yml +++ b/.github/workflows/cd-main.yml @@ -1,40 +1,52 @@ name: CD Production -# Production pipeline — Hetzner k3s. +# Pipeline de production — Hetzner k3s (infra/prod/). # -# SECURITY: Two mandatory gates before any production deployment: -# 1. quality-gate — lint + unit tests on the exact commit being deployed -# 2. verify-image — confirms preprod-SHA image EXISTS in registry, -# which proves this commit passed the full preprod -# pipeline (lint + unit + integration + docker build). -# If someone merges to main without going through preprod, -# this step fails and the deployment is blocked. +# Enchaînement : qualité → vérification → promotion/rebuild → déploiement → contrôle # -# Flow: quality-gate → verify-image → promote → deploy → notify +# TROIS RÈGLES STRUCTURANTES # -# Secrets required: -# REGISTRY_TOKEN — Scaleway registry (read/write) -# HETZNER_KUBECONFIG — base64: cat ~/.kube/kubeconfig-xpeditis-prod | base64 -w 0 -# PROD_BACKEND_URL — https://api.xpeditis.com -# PROD_FRONTEND_URL — https://app.xpeditis.com -# DISCORD_WEBHOOK_URL +# 1. Le BACKEND est PROMU depuis la preprod, jamais reconstruit. +# Promouvoir garantit que le binaire déployé en production est exactement +# celui qui a passé la chaîne de preprod (lint, tests unitaires, tests +# d'intégration, build). Un rebuild casserait cette garantie. +# +# 2. Le FRONTEND est RECONSTRUIT pour la production. +# next.config.js fige NEXT_PUBLIC_API_URL au moment du build. Promouvoir +# l'image de preprod livrerait une application qui appelle +# api.preprod.xpeditis.com en production. C'est la raison pour laquelle ce +# workflow ne peut pas se contenter de re-taguer. +# +# 3. Le déploiement passe par SSH, pas par l'API Kubernetes. +# L'API k3s (6443) n'est ouverte qu'aux IP d'administration. Les runners +# GitHub n'ont pas d'IP fixe : le job ouvre le port 22 pour la seule IP du +# runner via un firewall Hetzner dédié, puis le referme systématiquement. +# +# Secrets et variables : voir infra/prod/env/github-secrets.md on: push: branches: [main] + workflow_dispatch: + inputs: + tag: + description: "SHA court à déployer (laisser vide = HEAD de main)" + required: false concurrency: group: cd-production cancel-in-progress: false +permissions: + contents: read + env: REGISTRY: rg.fr-par.scw.cloud/weworkstudio NODE_VERSION: '20' K8S_NAMESPACE: xpeditis-prod jobs: - # ── 1. Quality Gate ────────────────────────────────────────────────── - # Runs on every prod deployment regardless of what happened in preprod. + # ═══ 1. Qualité ══════════════════════════════════════════════════════════ backend-quality: name: Backend — Lint runs-on: ubuntu-latest @@ -69,7 +81,7 @@ jobs: - run: npm run type-check backend-tests: - name: Backend — Unit Tests + name: Backend — Tests unitaires runs-on: ubuntu-latest needs: backend-quality defaults: @@ -86,7 +98,7 @@ jobs: - run: npm test -- --passWithNoTests frontend-tests: - name: Frontend — Unit Tests + name: Frontend — Tests unitaires runs-on: ubuntu-latest needs: frontend-quality defaults: @@ -102,175 +114,248 @@ jobs: - run: npm ci --legacy-peer-deps - run: npm test -- --passWithNoTests - # ── 2. Image Verification ──────────────────────────────────────────── - # Checks that preprod-SHA tags exist for this EXACT commit. - # This is the security gate: if the preprod pipeline never ran for this - # commit (or failed before the docker build step), this job fails and - # the deployment is fully blocked. + # ═══ 2. Vérification de la provenance ════════════════════════════════════ + # Si l'image preprod-SHA n'existe pas, c'est que ce commit n'est jamais passé + # par la chaîne de preprod. Le déploiement est alors bloqué net. verify-image: - name: Verify Preprod Image Exists + name: Vérifier l'image de preprod runs-on: ubuntu-latest needs: [backend-tests, frontend-tests] outputs: sha: ${{ steps.sha.outputs.short }} steps: - - name: Short SHA + - name: SHA court id: sha - run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT + run: | + RAW="${{ github.event.inputs.tag }}" + [ -n "$RAW" ] || RAW="${{ github.sha }}" + echo "short=$(echo "$RAW" | cut -c1-7)" >> $GITHUB_OUTPUT - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: nologin password: ${{ secrets.REGISTRY_TOKEN }} - - name: Check backend image preprod-SHA + - name: Image backend preprod-SHA présente run: | TAG="${{ env.REGISTRY }}/xpeditis-backend:preprod-${{ steps.sha.outputs.short }}" - echo "Verifying: $TAG" docker buildx imagetools inspect "$TAG" || { - echo "" - echo "BLOCKED: Image $TAG not found in registry." - echo "This commit was not built by the preprod pipeline." - echo "Merge to preprod first and wait for the full pipeline to succeed." + echo "::error::$TAG introuvable. Ce commit n'a pas été construit par la chaîne de preprod." + echo "Fusionnez d'abord sur preprod et attendez que le pipeline passe au vert." exit 1 } - - name: Check frontend image preprod-SHA + - name: Image log-exporter preprod-SHA présente run: | - TAG="${{ env.REGISTRY }}/xpeditis-frontend:preprod-${{ steps.sha.outputs.short }}" - echo "Verifying: $TAG" + TAG="${{ env.REGISTRY }}/xpeditis-log-exporter:preprod-${{ steps.sha.outputs.short }}" docker buildx imagetools inspect "$TAG" || { - echo "" - echo "BLOCKED: Image $TAG not found in registry." - echo "This commit was not built by the preprod pipeline." - echo "Merge to preprod first and wait for the full pipeline to succeed." + echo "::error::$TAG introuvable." exit 1 } - # ── 3. Promote Images ──────────────────────────────────────────────── - # Re-tags preprod-SHA → latest + prod-SHA within Scaleway. - # No rebuild. No layer transfer. Manifest-level operation only. - promote-images: - name: Promote Images (preprod-SHA → prod) + # ═══ 3a. Promotion du backend (aucun rebuild) ════════════════════════════ + promote-backend: + name: Promouvoir le backend runs-on: ubuntu-latest needs: verify-image steps: - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: nologin password: ${{ secrets.REGISTRY_TOKEN }} - - - name: Promote backend + - name: preprod-SHA → prod-SHA run: | SHA="${{ needs.verify-image.outputs.sha }}" + # Opération au niveau du manifeste : aucune couche n'est retransférée, + # le condensat de l'image reste identique à celui validé en preprod. docker buildx imagetools create \ - --tag ${{ env.REGISTRY }}/xpeditis-backend:latest \ --tag ${{ env.REGISTRY }}/xpeditis-backend:prod-${SHA} \ + --tag ${{ env.REGISTRY }}/xpeditis-backend:latest \ ${{ env.REGISTRY }}/xpeditis-backend:preprod-${SHA} - echo "Backend promoted: preprod-${SHA} → latest + prod-${SHA}" - - - name: Promote frontend - run: | - SHA="${{ needs.verify-image.outputs.sha }}" docker buildx imagetools create \ - --tag ${{ env.REGISTRY }}/xpeditis-frontend:latest \ - --tag ${{ env.REGISTRY }}/xpeditis-frontend:prod-${SHA} \ - ${{ env.REGISTRY }}/xpeditis-frontend:preprod-${SHA} - echo "Frontend promoted: preprod-${SHA} → latest + prod-${SHA}" + --tag ${{ env.REGISTRY }}/xpeditis-log-exporter:prod-${SHA} \ + --tag ${{ env.REGISTRY }}/xpeditis-log-exporter:latest \ + ${{ env.REGISTRY }}/xpeditis-log-exporter:preprod-${SHA} - # ── 4. Deploy to k3s ───────────────────────────────────────────────── - deploy: - name: Deploy to Production (k3s) + # ═══ 3b. Reconstruction du frontend avec les URLs de production ══════════ + build-frontend: + name: Reconstruire le frontend (URLs de production) runs-on: ubuntu-latest - needs: [verify-image, promote-images] + needs: verify-image + steps: + - uses: actions/checkout@v4 + with: + # On construit EXACTEMENT le commit vérifié, pas HEAD. + ref: ${{ github.sha }} + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: nologin + password: ${{ secrets.REGISTRY_TOKEN }} + - uses: docker/build-push-action@v5 + with: + context: ./apps/frontend + file: ./apps/frontend/Dockerfile + push: true + platforms: linux/amd64 + tags: | + ${{ env.REGISTRY }}/xpeditis-frontend:prod-${{ needs.verify-image.outputs.sha }} + ${{ env.REGISTRY }}/xpeditis-frontend:latest + cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache-prod + cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache-prod,mode=max + build-args: | + NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL_PROD }} + NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL_PROD }} + + - name: Contrôle — l'URL de preprod ne doit pas figurer dans le bundle + run: | + IMAGE="${{ env.REGISTRY }}/xpeditis-frontend:prod-${{ needs.verify-image.outputs.sha }}" + CID=$(docker create "$IMAGE") + docker cp "$CID:/app/.next" /tmp/next-check 2>/dev/null || true + docker rm "$CID" >/dev/null + if grep -rq "api.preprod.xpeditis.com" /tmp/next-check 2>/dev/null; then + echo "::error::L'URL de preprod est figée dans le bundle de production." + echo "Vérifiez le secret NEXT_PUBLIC_API_URL_PROD." + exit 1 + fi + echo "Aucune URL de preprod dans le bundle." + + # ═══ 4. Déploiement ══════════════════════════════════════════════════════ + deploy: + name: Déployer en production + runs-on: ubuntu-latest + needs: [verify-image, promote-backend, build-frontend] + # Environnement protégé : activez « Required reviewers » pour exiger une + # validation humaine avant toute mise en production. environment: name: production url: https://app.xpeditis.com steps: - - name: Configure kubectl - run: | - mkdir -p ~/.kube - echo "${{ secrets.HETZNER_KUBECONFIG }}" | base64 -d > ~/.kube/config - chmod 600 ~/.kube/config - kubectl cluster-info - kubectl get nodes -o wide + - uses: actions/checkout@v4 - - name: Deploy backend - id: deploy-backend + - name: Installer le client Hetzner + run: | + curl -fsSL https://github.com/hetznercloud/cli/releases/download/v1.49.0/hcloud-linux-amd64.tar.gz \ + | tar -xz -C /tmp hcloud + sudo install -m 0755 /tmp/hcloud /usr/local/bin/hcloud + hcloud version + + - name: Ouvrir le port 22 pour l'IP de ce runner + env: + HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN_CICD }} + run: | + RUNNER_IP="$(curl -fsS --max-time 10 https://ifconfig.me)" + echo "IP du runner : ${RUNNER_IP}" + cat > /tmp/fw-open.json < ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + # Empreinte épinglée : un détournement DNS ou BGP ne peut pas + # rediriger le déploiement vers une machine tierce. + echo "${{ secrets.PROD_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: Synchroniser infra/prod sur le serveur + run: | + rsync -az --delete \ + --exclude '.terraform' --exclude '*.tfstate*' --exclude '*.tfvars' \ + -e "ssh -o StrictHostKeyChecking=yes -i ~/.ssh/id_ed25519" \ + infra/prod/ \ + "${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }}:/opt/xpeditis/infra-prod/" + + - name: Déployer + id: deploy run: | SHA="${{ needs.verify-image.outputs.sha }}" - IMAGE="${{ env.REGISTRY }}/xpeditis-backend:prod-${SHA}" - echo "Deploying: $IMAGE" - kubectl set image deployment/xpeditis-backend backend="$IMAGE" -n ${{ env.K8S_NAMESPACE }} - kubectl rollout status deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} --timeout=300s - echo "Backend rollout complete." + ssh -o StrictHostKeyChecking=yes -i ~/.ssh/id_ed25519 \ + "${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }}" \ + "deploy prod-${SHA}" - - name: Deploy frontend - id: deploy-frontend + - name: Tests de fumée depuis l'extérieur + env: + PROD_API_URL: ${{ vars.PROD_API_URL }} + PROD_APP_URL: ${{ vars.PROD_APP_URL }} + run: bash infra/prod/scripts/smoke-test.sh + + - name: Retour arrière si le déploiement a échoué + if: failure() && steps.deploy.conclusion == 'failure' run: | - SHA="${{ needs.verify-image.outputs.sha }}" - IMAGE="${{ env.REGISTRY }}/xpeditis-frontend:prod-${SHA}" - echo "Deploying: $IMAGE" - kubectl set image deployment/xpeditis-frontend frontend="$IMAGE" -n ${{ env.K8S_NAMESPACE }} - kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=300s - echo "Frontend rollout complete." + ssh -o StrictHostKeyChecking=yes -i ~/.ssh/id_ed25519 \ + "${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }}" \ + "rollback" || true - - name: Auto-rollback on deployment failure - if: failure() + - name: Refermer le firewall + # `always()` : la fenêtre d'exposition se referme même si le + # déploiement a échoué, si le job a été annulé ou s'il a expiré. + if: always() + env: + HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN_CICD }} run: | - echo "Deployment failed — initiating rollback..." - kubectl rollout undo deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} - kubectl rollout undo deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} - kubectl rollout status deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} --timeout=120s - kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=120s - echo "Rollback complete. Previous version is live." + echo '[]' > /tmp/fw-close.json + hcloud firewall replace-rules "${{ vars.HCLOUD_CICD_FIREWALL }}" --rules-file /tmp/fw-close.json + echo "Firewall CI refermé." - # ── Notifications ──────────────────────────────────────────────────── + - name: Effacer la clé SSH + if: always() + run: shred -u ~/.ssh/id_ed25519 2>/dev/null || rm -f ~/.ssh/id_ed25519 + + # ═══ 5. Notifications ════════════════════════════════════════════════════ notify-success: - name: Notify Success + name: Notifier le succès runs-on: ubuntu-latest needs: [verify-image, deploy] if: success() steps: - run: | - curl -s -H "Content-Type: application/json" -d '{ + curl -sf -H "Content-Type: application/json" -d '{ "embeds": [{ - "title": "🚀 Production Deployed & Healthy", + "title": "Production déployée et saine", "color": 3066993, "fields": [ - {"name": "Author", "value": "${{ github.actor }}", "inline": true}, + {"name": "Auteur", "value": "${{ github.actor }}", "inline": true}, {"name": "Version", "value": "`prod-${{ needs.verify-image.outputs.sha }}`", "inline": true}, - {"name": "Cluster", "value": "Hetzner k3s — `xpeditis-prod`", "inline": false}, + {"name": "Cible", "value": "Hetzner k3s — xpeditis-prod", "inline": false}, {"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false} ], - "footer": {"text": "Xpeditis CI/CD • Production"} + "footer": {"text": "Xpeditis CI/CD - Production"} }] }' ${{ secrets.DISCORD_WEBHOOK_URL }} notify-failure: - name: Notify Failure + name: Notifier l'échec runs-on: ubuntu-latest - needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, verify-image, promote-images, deploy] + needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, verify-image, promote-backend, build-frontend, deploy] if: failure() steps: - run: | - curl -s -H "Content-Type: application/json" -d '{ - "content": "@here PRODUCTION PIPELINE FAILED", + curl -sf -H "Content-Type: application/json" -d '{ + "content": "@here ECHEC DU PIPELINE DE PRODUCTION", "embeds": [{ - "title": "🔴 Production Pipeline Failed", - "description": "Check the workflow for details. Auto-rollback was triggered if the failure was during deploy.", + "title": "Pipeline de production en échec", + "description": "Un retour arrière a été tenté si l échec est survenu pendant le déploiement. Vérifiez l état réel avant toute nouvelle tentative.", "color": 15158332, "fields": [ - {"name": "Author", "value": "${{ github.actor }}", "inline": true}, + {"name": "Auteur", "value": "${{ github.actor }}", "inline": true}, {"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false}, - {"name": "Rollback", "value": "[Run rollback workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/rollback.yml)", "inline": false} + {"name": "A vérifier", "value": "Le firewall CI est-il bien refermé ? `hcloud firewall describe xpeditis-prod-fw-cicd`", "inline": false} ], - "footer": {"text": "Xpeditis CI/CD • Production"} + "footer": {"text": "Xpeditis CI/CD - Production"} }] }' ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/docker/docker-compose.full.yml b/docker/docker-compose.full.yml index 5ba5a92..b031e76 100644 --- a/docker/docker-compose.full.yml +++ b/docker/docker-compose.full.yml @@ -2,7 +2,8 @@ # Xpeditis — Full Dev Stack (infrastructure + app + logging) # # Usage: -# docker-compose -f docker-compose.full.yml up -d +# docker network inspect xpeditis-network >/dev/null 2>&1 || docker network create xpeditis-network +# docker compose -f docker/docker-compose.full.yml up -d --build # # Exposed ports: # - Frontend: http://localhost:3000 @@ -28,7 +29,7 @@ services: POSTGRES_USER: xpeditis POSTGRES_PASSWORD: xpeditis_dev_password healthcheck: - test: ["CMD-SHELL", "pg_isready -U xpeditis"] + test: ["CMD-SHELL", "pg_isready -U xpeditis -d xpeditis_dev"] interval: 10s timeout: 5s retries: 5 @@ -252,3 +253,4 @@ volumes: networks: xpeditis-network: name: xpeditis-network + external: true From 14558d874792bd8e8c46a4aa74a69b60f3776744 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:50 +0200 Subject: [PATCH 4/4] feat(auth): amorcer l administrateur depuis l environnement et neutraliser les comptes de test Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- apps/backend/.env.example | 24 +++ .../scripts/setup/generate-admin-hash.js | 129 ++++++++++++ .../migrations/1730000000007-SeedTestUsers.ts | 31 ++- ...0000-NeutralizeSeedAccountsInProduction.ts | 119 +++++++++++ .../1756000000001-BootstrapAdminFromEnv.ts | 190 ++++++++++++++++++ 5 files changed, 490 insertions(+), 3 deletions(-) create mode 100644 apps/backend/scripts/setup/generate-admin-hash.js create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000000-NeutralizeSeedAccountsInProduction.ts create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000001-BootstrapAdminFromEnv.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index e12d10d..fa88ba5 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -91,3 +91,27 @@ STRIPE_GOLD_MONTHLY_PRICE_ID= STRIPE_GOLD_YEARLY_PRICE_ID= STRIPE_PLATINIUM_MONTHLY_PRICE_ID= STRIPE_PLATINIUM_YEARLY_PRICE_ID= + +# Premier administrateur (amorcage) - migration BootstrapAdminFromEnv +# En developpement, laissez vide : SeedTestUsers cree deja admin@xpeditis.com. +# En production, renseignez une adresse RELEVABLE : le compte est cree sans +# mot de passe utilisable et vous definissez le votre via "mot de passe oublie". +# BOOTSTRAP_ADMIN_EMAIL= +# BOOTSTRAP_ADMIN_FIRST_NAME=Admin +# BOOTSTRAP_ADMIN_LAST_NAME=Xpeditis +# BOOTSTRAP_ADMIN_ORG_NAME=Xpeditis +# BOOTSTRAP_ADMIN_ORG_STREET=A completer +# BOOTSTRAP_ADMIN_ORG_CITY=A completer +# BOOTSTRAP_ADMIN_ORG_POSTAL_CODE=00000 +# BOOTSTRAP_ADMIN_ORG_COUNTRY=FR +# Facultatif : hash Argon2id, si SMTP n'est pas encore operationnel. +# Generer avec : node scripts/setup/generate-admin-hash.js +# Jamais un mot de passe en clair - la migration le refuse. +# BOOTSTRAP_ADMIN_PASSWORD_HASH= + +# Force la neutralisation des comptes de demonstration hors production. +# FORCE_NEUTRALIZE_SEED_ACCOUNTS=true + +# Trade assistant — server only. Empty key enables guided help only. +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4.1-mini diff --git a/apps/backend/scripts/setup/generate-admin-hash.js b/apps/backend/scripts/setup/generate-admin-hash.js new file mode 100644 index 0000000..5add982 --- /dev/null +++ b/apps/backend/scripts/setup/generate-admin-hash.js @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/** + * Génère un hash Argon2id pour BOOTSTRAP_ADMIN_PASSWORD_HASH. + * + * cd apps/backend && node scripts/setup/generate-admin-hash.js + * + * Le mot de passe est saisi sans écho et ne quitte jamais votre poste : ni + * argument de ligne de commande (visible dans `ps` et dans l'historique du + * shell), ni variable d'environnement, ni fichier temporaire. + * + * RAPPEL — le mode SANS mot de passe est préférable. + * Si votre chaîne SMTP fonctionne, ne renseignez que BOOTSTRAP_ADMIN_EMAIL : + * le compte est alors créé sans mot de passe utilisable et vous le définissez + * via « mot de passe oublié ». Aucun secret n'existe nulle part, il n'y a donc + * rien à faire fuiter. Ce script n'est utile que si vous devez pouvoir vous + * connecter avant que l'envoi de courriels ne soit opérationnel. + */ + +'use strict'; + +const argon2 = require('argon2'); +const readline = require('readline'); + +// Mêmes paramètres que auth.service.ts : un hash produit ici est vérifiable +// par l'application sans aucune adaptation. +const ARGON2_OPTIONS = { + type: argon2.argon2id, + memoryCost: 65536, // 64 Mo + timeCost: 3, + parallelism: 4, +}; + +const MIN_LENGTH = 16; + +/** Saisie masquée sur un terminal ; lecture directe si l'entrée est redirigée. */ +function readSecret(prompt) { + return new Promise((resolve, reject) => { + if (!process.stdin.isTTY) { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => (data += chunk)); + process.stdin.on('end', () => resolve(data.replace(/\r?\n$/, ''))); + process.stdin.on('error', reject); + return; + } + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const onKeypress = () => { + // Réécrit la ligne sans révéler la longueur de la saisie. + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + process.stdout.write(prompt); + }; + + process.stdout.write(prompt); + process.stdin.on('data', onKeypress); + + rl.question('', answer => { + process.stdin.removeListener('data', onKeypress); + rl.close(); + process.stdout.write('\n'); + resolve(answer); + }); + }); +} + +function checkStrength(password) { + const problems = []; + if (password.length < MIN_LENGTH) { + problems.push(`au moins ${MIN_LENGTH} caractères (${password.length} fournis)`); + } + if (!/[a-z]/.test(password)) problems.push('une minuscule'); + if (!/[A-Z]/.test(password)) problems.push('une majuscule'); + if (!/[0-9]/.test(password)) problems.push('un chiffre'); + if (!/[^A-Za-z0-9]/.test(password)) problems.push('un caractère spécial'); + return problems; +} + +async function main() { + console.log(''); + console.log('Génération du hash Argon2id pour le premier administrateur.'); + console.log('La saisie n’est pas affichée.'); + console.log(''); + + const password = await readSecret('Mot de passe : '); + if (!password) { + console.error('Aucun mot de passe saisi.'); + process.exit(1); + } + + if (process.stdin.isTTY) { + const confirmation = await readSecret('Confirmation : '); + if (confirmation !== password) { + console.error('Les deux saisies diffèrent.'); + process.exit(1); + } + } + + const problems = checkStrength(password); + if (problems.length > 0) { + console.error(''); + console.error('Mot de passe refusé. Il manque : ' + problems.join(', ') + '.'); + console.error('Ce compte a tous les droits sur la plateforme : générez plutôt une'); + console.error('phrase longue et aléatoire depuis votre gestionnaire de mots de passe.'); + process.exit(1); + } + + const hash = await argon2.hash(password, ARGON2_OPTIONS); + + console.log(''); + console.log('Hash à placer dans le Secret Kubernetes (jamais dans le ConfigMap) :'); + console.log(''); + console.log(' BOOTSTRAP_ADMIN_PASSWORD_HASH: ' + JSON.stringify(hash)); + console.log(''); + console.log(' cd infra/prod && sops k8s/base/03-secrets.sops.yaml'); + console.log(''); + console.log('Après votre première connexion :'); + console.log(' 1. changez le mot de passe depuis l’interface ;'); + console.log(' 2. retirez BOOTSTRAP_ADMIN_PASSWORD_HASH du Secret et réappliquez.'); + console.log(''); + console.log('Un hash reste attaquable hors ligne : il n’a plus aucune raison'); + console.log('de rester stocké une fois le compte opérationnel.'); + console.log(''); +} + +main().catch(error => { + console.error('Échec :', error.message); + process.exit(1); +}); diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts index 93ff9dd..fae4ef9 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts @@ -1,9 +1,26 @@ /** * Seed Test Users Migration * - * Seeds test users for development and testing - * Password for all users: Password123! - * Hash generated with Argon2id + * Comptes de test pour le developpement et la preprod. + * Mot de passe commun : Password123! (hash Argon2id ci-dessous) + * + * NE S'EXECUTE JAMAIS EN PRODUCTION + * --------------------------------- + * Ce fichier contient un mot de passe en clair pour un compte ADMIN. Sur une + * base de production, l'appliquer creerait un administrateur aux identifiants + * publics, connus de quiconque a lu le depot. La garde NODE_ENV ci-dessous + * l'en empeche. + * + * Le corps de la migration a ete modifie apres son ecriture initiale, ce qui + * deroge a la regle "ne jamais modifier une migration appliquee". C'est sans + * consequence ici : TypeORM suit les migrations par NOM de classe et ne + * recalcule aucune empreinte. Les bases ou elle a deja tourne (dev, preprod) ne + * la rejouent pas et gardent leurs comptes de test ; seules les bases neuves + * voient la garde s'appliquer. + * + * Filet de securite pour les bases ou elle aurait deja tourne : + * migration 1756000000000-NeutralizeSeedAccountsInProduction. + * Creation d'un vrai administrateur : 1756000000001-BootstrapAdminFromEnv. */ import { MigrationInterface, QueryRunner } from 'typeorm'; @@ -11,6 +28,14 @@ import { DEFAULT_ORG_ID } from '../seeds/test-organizations.seed'; export class SeedTestUsers1730000000007 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { + if (process.env.NODE_ENV === 'production') { + console.log( + 'SeedTestUsers ignore : NODE_ENV=production. ' + + 'Utilisez BOOTSTRAP_ADMIN_EMAIL pour creer le premier administrateur.' + ); + return; + } + // Use fixed organization ID from seed const organizationId = DEFAULT_ORG_ID; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000000-NeutralizeSeedAccountsInProduction.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000000-NeutralizeSeedAccountsInProduction.ts new file mode 100644 index 0000000..9e152af --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000000-NeutralizeSeedAccountsInProduction.ts @@ -0,0 +1,119 @@ +/** + * Neutralise les comptes de démonstration en production. + * + * POURQUOI + * -------- + * La migration 1730000000007-SeedTestUsers insère trois comptes dont le mot de + * passe (`Password123!`) est écrit en clair dans le dépôt, dont un ADMIN. + * Sur une base de production neuve, appliquer les migrations créait donc un + * administrateur aux identifiants publics. + * + * SeedTestUsers ne s'exécute désormais plus en production (garde ajoutée dans + * cette même migration). Ce filet de sécurité couvre les cas restants : + * - une base de production migrée avant l'ajout de la garde ; + * - un environnement où NODE_ENV n'était pas correctement positionné ; + * - une restauration à partir d'une sauvegarde antérieure. + * + * Les lignes ne sont PAS supprimées : `audit_logs` et d'autres tables peuvent y + * référer, et une suppression en cascade ferait plus de dégâts que de bien. + * Les comptes sont renommés (ce qui libère `admin@xpeditis.com` pour votre vrai + * compte), rendus impossibles à authentifier, et désactivés. + * + * Idempotente : une seconde exécution ne trouve plus rien à faire. + * + * En développement et en preprod, cette migration ne fait rien — les comptes de + * test restent utilisables. Pour l'y forcer malgré tout : + * FORCE_NEUTRALIZE_SEED_ACCOUNTS=true + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; +import * as crypto from 'crypto'; +import * as argon2 from 'argon2'; + +/** Paramètres Argon2id du projet (cf. auth.service.ts). */ +const ARGON2_OPTIONS = { + type: argon2.argon2id, + memoryCost: 65536, + timeCost: 3, + parallelism: 4, +} as const; + +const SEED_ACCOUNTS = ['admin@xpeditis.com', 'manager@xpeditis.com', 'user@xpeditis.com']; + +/** + * Produit un hash Argon2id valide d'un secret aléatoire immédiatement perdu. + * + * Un hash *syntaxiquement valide* est indispensable : `auth.service.ts` appelle + * `argon2.verify()` sans try/catch, et une chaîne malformée lèverait une + * exception — donc un 500 au lieu du 401 attendu. + */ +async function unusablePasswordHash(): Promise { + return argon2.hash(crypto.randomBytes(48).toString('hex'), ARGON2_OPTIONS); +} + +export class NeutralizeSeedAccountsInProduction1756000000000 implements MigrationInterface { + name = 'NeutralizeSeedAccountsInProduction1756000000000'; + + public async up(queryRunner: QueryRunner): Promise { + const isProduction = process.env.NODE_ENV === 'production'; + const forced = process.env.FORCE_NEUTRALIZE_SEED_ACCOUNTS === 'true'; + + if (!isProduction && !forced) { + console.log('[neutralisation] NODE_ENV != production : comptes de démonstration conservés.'); + return; + } + + const rows: Array<{ id: string; email: string }> = await queryRunner.query( + `SELECT "id", "email" FROM "users" WHERE "email" = ANY($1)`, + [SEED_ACCOUNTS] + ); + + if (rows.length === 0) { + console.log('[neutralisation] Aucun compte de démonstration présent.'); + return; + } + + for (const row of rows) { + // Le nouveau libellé respecte la contrainte chk_users_email + // (LOWER(email) = email) : les UUID sont en minuscules. + const disabledEmail = `seed-disabled-${String(row.id).slice(0, 8)}@invalid.local`; + + await queryRunner.query( + `UPDATE "users" + SET "email" = $1, + "password_hash" = $2, + "is_active" = false, + "updated_at" = NOW() + WHERE "id" = $3`, + [disabledEmail, await unusablePasswordHash(), row.id] + ); + + console.log(`[neutralisation] ${row.email} -> ${disabledEmail} (désactivé)`); + } + + // Contrôle explicite : la migration échoue plutôt que de laisser croire + // que le nettoyage a eu lieu. + const remaining: Array<{ n: number }> = await queryRunner.query( + `SELECT count(*)::int AS n FROM "users" WHERE "email" = ANY($1)`, + [SEED_ACCOUNTS] + ); + + if (remaining[0].n > 0) { + throw new Error( + `Neutralisation incomplète : ${remaining[0].n} compte(s) de démonstration subsistent.` + ); + } + + console.log(`[neutralisation] ${rows.length} compte(s) neutralisé(s).`); + } + + public async down(): Promise { + // Volontairement sans effet. + // + // Restaurer des comptes dont le mot de passe est public serait une + // régression de sécurité déclenchée par un simple `migration:revert`. + // Si vous avez réellement besoin des comptes de démonstration, recréez-les + // dans un environnement non productif. + console.log('[neutralisation] down() sans effet — par conception.'); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000001-BootstrapAdminFromEnv.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000001-BootstrapAdminFromEnv.ts new file mode 100644 index 0000000..b2deb33 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000001-BootstrapAdminFromEnv.ts @@ -0,0 +1,190 @@ +/** + * Crée le premier administrateur à partir de l'environnement. + * + * Remplace le compte `admin@xpeditis.com / Password123!` de la migration de + * démonstration : on garde la commodité (une base neuve arrive avec un + * administrateur utilisable) sans le mot de passe public. + * + * DEUX MODES + * ---------- + * + * 1. SANS MOT DE PASSE — recommandé. + * BOOTSTRAP_ADMIN_EMAIL=vous@votredomaine.fr + * + * Le compte est créé avec un hash Argon2id d'un secret aléatoire + * immédiatement perdu : personne, pas même vous, ne peut s'y connecter. + * Vous définissez votre mot de passe via « mot de passe oublié », qui envoie + * un jeton à usage unique, valable 1 heure, stocké haché en base. + * + * Aucun secret n'existe donc nulle part : ni dans Git, ni dans le Secret + * Kubernetes, ni dans l'historique du shell, ni dans les journaux de + * migration. C'est la seule variante où il n'y a rien à faire fuiter. + * Effet de bord utile : la réception du courriel prouve que la chaîne SMTP + * fonctionne. + * + * 2. AVEC UN HASH PRÉ-CALCULÉ — si SMTP n'est pas encore opérationnel. + * BOOTSTRAP_ADMIN_EMAIL=vous@votredomaine.fr + * BOOTSTRAP_ADMIN_PASSWORD_HASH=$argon2id$v=19$m=65536,t=3,p=4$... + * + * Le hash se génère hors ligne : + * node apps/backend/scripts/setup/generate-admin-hash.js + * Le mot de passe en clair ne quitte jamais votre poste. Le hash, lui, reste + * sensible (attaque hors ligne possible) : utilisez un mot de passe long et + * aléatoire, changez-le après la première connexion, puis retirez la + * variable du Secret. + * + * GARDE-FOUS + * ---------- + * - Sans BOOTSTRAP_ADMIN_EMAIL, la migration ne fait rien. + * - S'il existe déjà un ADMIN actif, la migration ne fait rien : elle ne peut + * donc pas créer un second administrateur à votre insu lors d'un déploiement + * ultérieur. + * - Si un compte porte déjà cette adresse, il est promu ADMIN sans que son + * mot de passe ne soit touché. + * - Un mot de passe en clair passé par erreur dans + * BOOTSTRAP_ADMIN_PASSWORD_HASH est refusé : la migration échoue. + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; +import * as crypto from 'crypto'; +import * as argon2 from 'argon2'; + +/** Paramètres Argon2id du projet (cf. auth.service.ts). */ +const ARGON2_OPTIONS = { + type: argon2.argon2id, + memoryCost: 65536, + timeCost: 3, + parallelism: 4, +} as const; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function env(name: string, fallback = ''): string { + return (process.env[name] ?? fallback).trim(); +} + +export class BootstrapAdminFromEnv1756000000001 implements MigrationInterface { + name = 'BootstrapAdminFromEnv1756000000001'; + + public async up(queryRunner: QueryRunner): Promise { + const email = env('BOOTSTRAP_ADMIN_EMAIL').toLowerCase(); + + if (!email) { + console.log( + '[amorçage admin] BOOTSTRAP_ADMIN_EMAIL absent : aucun administrateur créé. ' + + 'Inscrivez-vous par l’interface puis promouvez le compte en base.' + ); + return; + } + + if (!EMAIL_PATTERN.test(email)) { + throw new Error(`[amorçage admin] BOOTSTRAP_ADMIN_EMAIL invalide : "${email}"`); + } + + // Ne jamais créer un second administrateur silencieusement. + const activeAdmins: Array<{ n: number }> = await queryRunner.query( + `SELECT count(*)::int AS n FROM "users" WHERE "role" = 'ADMIN' AND "is_active" = true` + ); + if (activeAdmins[0].n > 0) { + console.log( + `[amorçage admin] ${activeAdmins[0].n} administrateur(s) actif(s) déjà présent(s) : rien à faire.` + ); + return; + } + + // --- Compte déjà existant : promotion, sans toucher au mot de passe ------ + const existing: Array<{ id: string }> = await queryRunner.query( + `SELECT "id" FROM "users" WHERE "email" = $1`, + [email] + ); + + if (existing.length > 0) { + await queryRunner.query( + `UPDATE "users" + SET "role" = 'ADMIN', "is_active" = true, "updated_at" = NOW() + WHERE "id" = $1`, + [existing[0].id] + ); + console.log(`[amorçage admin] Compte existant ${email} promu ADMIN (mot de passe inchangé).`); + return; + } + + // --- Organisation de rattachement --------------------------------------- + // users.organization_id est NOT NULL avec clé étrangère : il faut une + // organisation avant de pouvoir créer l'administrateur. + const orgName = env('BOOTSTRAP_ADMIN_ORG_NAME', 'Xpeditis'); + const orgCountry = env('BOOTSTRAP_ADMIN_ORG_COUNTRY', 'FR').toUpperCase(); + + if (!/^[A-Z]{2}$/.test(orgCountry)) { + throw new Error( + `[amorçage admin] BOOTSTRAP_ADMIN_ORG_COUNTRY doit être un code ISO à 2 lettres, reçu "${orgCountry}"` + ); + } + + const org: Array<{ id: string }> = await queryRunner.query( + `INSERT INTO "organizations" + ("name", "type", "address_street", "address_city", "address_postal_code", "address_country") + VALUES ($1, 'FREIGHT_FORWARDER', $2, $3, $4, $5) + ON CONFLICT ("name") DO UPDATE SET "updated_at" = NOW() + RETURNING "id"`, + [ + orgName, + env('BOOTSTRAP_ADMIN_ORG_STREET', 'A completer'), + env('BOOTSTRAP_ADMIN_ORG_CITY', 'A completer'), + env('BOOTSTRAP_ADMIN_ORG_POSTAL_CODE', '00000'), + orgCountry, + ] + ); + const organizationId = org[0].id; + + // --- Mot de passe -------------------------------------------------------- + const providedHash = env('BOOTSTRAP_ADMIN_PASSWORD_HASH'); + let passwordHash: string; + let mode: string; + + if (providedHash) { + if (!providedHash.startsWith('$argon2')) { + throw new Error( + '[amorçage admin] BOOTSTRAP_ADMIN_PASSWORD_HASH doit contenir un hash Argon2 ' + + '(commençant par "$argon2"), jamais un mot de passe en clair. ' + + 'Générez-le avec scripts/setup/generate-admin-hash.js.' + ); + } + passwordHash = providedHash; + mode = 'hash fourni par l’environnement'; + } else { + // Hash d'un secret aléatoire immédiatement perdu : le compte existe, il + // est actif, mais aucun mot de passe ne peut y correspondre. + passwordHash = await argon2.hash(crypto.randomBytes(48).toString('hex'), ARGON2_OPTIONS); + mode = 'aucun mot de passe — à définir via « mot de passe oublié »'; + } + + await queryRunner.query( + `INSERT INTO "users" + ("organization_id", "email", "password_hash", "role", + "first_name", "last_name", "is_email_verified", "is_active") + VALUES ($1, $2, $3, 'ADMIN', $4, $5, true, true)`, + [ + organizationId, + email, + passwordHash, + env('BOOTSTRAP_ADMIN_FIRST_NAME', 'Admin'), + env('BOOTSTRAP_ADMIN_LAST_NAME', 'Xpeditis'), + ] + ); + + console.log(`[amorçage admin] Administrateur ${email} créé (${mode}).`); + if (!providedHash) { + console.log( + '[amorçage admin] Étape suivante : POST /api/v1/auth/forgot-password avec cette adresse, ' + + 'puis suivez le lien reçu par courriel pour définir le mot de passe.' + ); + } + } + + public async down(): Promise { + // Volontairement sans effet : supprimer l'unique administrateur d'une + // production sur un `migration:revert` serait pire que le problème résolu. + console.log('[amorçage admin] down() sans effet — par conception.'); + } +}