docs: procedure de mise en production pas a pas

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
This commit is contained in:
David 2026-09-07 21:40:50 +02:00
parent 44713e4ca0
commit b22f4e0b74
18 changed files with 4398 additions and 3 deletions

View File

@ -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

View File

@ -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)**

View File

@ -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 = "<token terraform-prod>"
ssh_public_key = "ssh-ed25519 AAAA... xpeditis-prod-admin" # cat ~/.ssh/xpeditis_prod.pub
admin_ip_allowlist = ["<votre IP>/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@<app_public_ipv4> 'hostname; uptime'
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> '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@<ip> 'ls -l /var/log/cloud-init-xpeditis-done'
```
Le réseau privé doit fonctionner dans les deux sens :
```bash
ssh deploy@<app_public_ipv4> '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 <db_public_ipv4>
```
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 <app_public_ipv4>
```
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)**

View File

@ -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@<app_public_ipv4>:/tmp/
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> \
'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@<db_public_ipv4>:/tmp/
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> \
'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@<ip> '
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@<ip> '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@<ip> '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@<ip> '
sudo mkdir -p /etc/xpeditis
echo "<webhook_discord_alertes>" | 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@<ip> '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@<ip> '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)**

View File

@ -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@<db_public_ipv4>:/tmp/
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> \
'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@<db_public_ipv4> '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@<db_public_ipv4>:/tmp/data-node/
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> '
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@<db_public_ipv4> \
'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@<db_public_ipv4>:/tmp/
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> '
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@<db_public_ipv4>:/tmp/
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> '
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@<db_public_ipv4>:/tmp/sb.key
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> '
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@<db_public_ipv4>
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@<app_public_ipv4>
sudo apt-get install -y postgresql-client
# Doit RÉUSSIR
PGPASSWORD='<POSTGRES_PASSWORD>' 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='<POSTGRES_PASSWORD>' 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 '<REDIS_PASSWORD>' --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 <db_public_ipv4>
```
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@<db_public_ipv4> 'sudo systemctl start xpeditis-backup.service'
ssh deploy@<db_public_ipv4> '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@<db_public_ipv4> \
'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@<db_public_ipv4> "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)**

View File

@ -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@<app_public_ipv4>:/tmp/
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> \
"sudo K3S_VERSION=v1.31.5+k3s1 PUBLIC_IP=<app_public_ipv4> 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@<app_public_ipv4> 'cat ~/.kube/config' \
| sed "s/127.0.0.1/<app_public_ipv4>/" > ~/.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@<app_public_ipv4> 'sudo k3s secrets-encrypt status'
# Encryption Status: Enabled
# Journal d'audit alimenté
ssh deploy@<app_public_ipv4> 'sudo tail -2 /var/log/k3s/audit.log | head -c 300'
# Traefik écoute bien sur 80 et 443
ssh deploy@<app_public_ipv4> 'sudo ss -tlnp | grep -E ":(80|443) "'
```
---
## 4. Composants du cluster
Depuis votre poste, kubeconfig chargé :
```bash
cd infra/prod
REGISTRY_TOKEN='<jeton Scaleway>' 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)**

View File

@ -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@<db_ip> 'cd /opt/xpeditis/data-node && \
sudo docker compose exec -T -u postgres postgres \
psql -c "ALTER ROLE xpeditis WITH PASSWORD '"'"'<nouveau>'"'"';"'
# 2. Le fichier .env.data de db-01 (postgres-exporter l'utilise)
ssh deploy@<db_ip> 'sudo $EDITOR /opt/xpeditis/data-node/.env.data'
ssh deploy@<db_ip> '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 <fichier> > 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)**

View File

@ -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: "<clé xpeditis-app>"
AWS_SECRET_ACCESS_KEY: "<secret xpeditis-app>"
```
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=<clé xpeditis-walg>
WALG_SECRET_ACCESS_KEY=<secret xpeditis-walg>
```
---
## 4. Vérifier
Avec le client AWS ou `s3cmd` :
```bash
export AWS_ACCESS_KEY_ID='<clé xpeditis-app>'
export AWS_SECRET_ACCESS_KEY='<secret xpeditis-app>'
# 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)**

View File

@ -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` | `<app_public_ipv4>` |
| A | `www` | `<app_public_ipv4>` |
| A | `app` | `<app_public_ipv4>` |
| A | `api` | `<app_public_ipv4>` |
| A | `grafana` | `<app_public_ipv4>` |
**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 <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 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@<app_ip> \
> "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:<app_public_ipv4>: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@<app_ip> '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)**

View File

@ -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-<sha>`. 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 '<REGISTRY_TOKEN>'
# 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@<db_public_ipv4> '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@<db_public_ipv4> '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@<db_public_ipv4> '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-<sha> 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)**

View File

@ -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-<sha> existe-t-elle ?
│ Si non → BLOCAGE. Ce commit n'est pas passé par la preprod.
│
├─ Promotion du backend preprod-<sha> → prod-<sha> (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-<sha> » → 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 <app_public_ipv4>
```
> 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@<app_public_ipv4> '
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@<app_public_ipv4> '
cat >> ~/.ssh/authorized_keys <<EOF
restrict,pty,command="/opt/xpeditis/infra-prod/scripts/ssh-deploy-wrapper.sh" ssh-ed25519 AAAA... github-actions-prod
EOF
chmod 600 ~/.ssh/authorized_keys
'
```
`restrict` désactive le transfert de ports, d'agent et X11.
`command=` force l'exécution du script quelle que soit la commande demandée :
**une clé volée ne donne pas un shell**.
Le wrapper (`infra/prod/scripts/ssh-deploy-wrapper.sh`) n'accepte que quatre
choses : le `rsync` de `infra/prod`, `deploy prod-<sha>` (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@<app_public_ipv4>:/opt/xpeditis/infra-prod/
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> \
'chmod +x /opt/xpeditis/infra-prod/scripts/*.sh'
```
### 4.4 Tester la clé restreinte
```bash
# Autorisé
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> "status"
# Refusé — c'est le résultat attendu
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> "cat /opt/xpeditis/data-node/.env.data"
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> # pas de shell
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> "deploy ; rm -rf /"
```
Les refus sont tracés :
```bash
ssh deploy@<app_public_ipv4> '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@<app_public_ipv4> "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)**

View File

@ -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="<uuid>"
```
```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@<db_ip> '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@<db_ip> 'sudo $EDITOR /opt/xpeditis/data-node/.env.data'
# BACKUP_HEARTBEAT_URL=https://hc-ping.com/<uuid>
```
3. Déclenchez une sauvegarde pour vérifier :
```bash
ssh deploy@<db_ip> '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@<db_ip> '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)**

View File

@ -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@<db_ip> "systemctl list-timers 'xpeditis-*' --no-pager"
ssh deploy@<db_ip> '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@<db_public_ipv4>
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@<db_ip> '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@<db_ip> '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@<db_ip>
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-<horodatage>`. 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@<db_ip>
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@<db_ip>
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 = ''<uuid>''')
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@<nouvelle_ip>:/tmp/
ssh deploy@<nouvelle_ip> '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@<nouvelle_ip>:/tmp/
ssh deploy@<nouvelle_ip> '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@<db_ip> 'sudo systemctl start xpeditis-backup.service'
ssh deploy@<db_ip> '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)**

View File

@ -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=<ip_db> 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 <db_public_ipv4>
# 2. Rien d'inattendu sur app-01
nmap -Pn -p- --min-rate 1000 <app_public_ipv4>
# 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@<db_ip> '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 <<JSON
[{"direction":"in","protocol":"tcp","port":"22","source_ips":["<votre_ip>/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@<app_ip> '
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@<app_ip> 'sudo grep -E "\"verb\":\"(create|delete|patch)\"" /var/log/k3s/audit.log | tail -50'
# 3. PRÉSERVER
ssh deploy@<app_ip> 'sudo tar czf /tmp/preuves.tgz /var/log/auth.log* /var/log/k3s/audit.log* /var/log/audit/'
scp deploy@<app_ip>:/tmp/preuves.tgz ./incident-$(date +%F).tgz
# 4. ÉVALUER : la base a-t-elle été touchée ?
ssh deploy@<db_ip> '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)**

View File

@ -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=<ip_db> 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-<sha_precedent>
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@<db_ip> 'sudo systemctl start xpeditis-backup.service'
ssh deploy@<db_ip> '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@<db_ip> '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@<app_ip>
SSH base ssh -i ~/.ssh/xpeditis_prod deploy@<db_ip>
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)**

View File

@ -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<typeof createAdapter>;
async connectToRedis(): Promise<void> {
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 <app_public_ipv4>
ssh deploy@<app_public_ipv4> 'uptime; df -h /; free -m'
# 3. k3s tourne-t-il ?
ssh deploy@<app_public_ipv4> '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@<db_ip> '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@<ip> '
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@<app_ip> 'sudo k3s crictl rmi --prune'
# Journaux systemd
ssh deploy@<ip> 'sudo journalctl --vacuum-size=500M'
# Sur db-01 : WAL qui s'accumulent = archivage cassé
ssh deploy@<db_ip> '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-<sha_actuel> \
--overrides='{"spec":{"imagePullSecrets":[{"name":"regcred"}],"containers":[{
"name":"migration-revert",
"image":"rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:prod-<sha_actuel>",
"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-<sha_precedent>
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@<ip> '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@<db_ip> '
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@<app1> '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@<ip> '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@<app_ip> 'sudo k3s etcd-snapshot save --name avant-maj'
# Mise à jour
ssh deploy@<app_ip> '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@<db_ip> '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@<db_ip>
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(<pid>);" # doux
# puis, si nécessaire : pg_terminate_backend(<pid>)
```
---
## 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
```

View File

@ -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).

174
docs/mise-en-prod/README.md Normal file
View File

@ -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) |