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:
parent
44713e4ca0
commit
b22f4e0b74
@ -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
|
||||
|
||||
163
docs/mise-en-prod/01-prerequis.md
Normal file
163
docs/mise-en-prod/01-prerequis.md
Normal 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)**
|
||||
211
docs/mise-en-prod/02-provisioning-hetzner.md
Normal file
211
docs/mise-en-prod/02-provisioning-hetzner.md
Normal 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)**
|
||||
180
docs/mise-en-prod/03-durcissement-serveurs.md
Normal file
180
docs/mise-en-prod/03-durcissement-serveurs.md
Normal 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)**
|
||||
337
docs/mise-en-prod/04-noeud-donnees.md
Normal file
337
docs/mise-en-prod/04-noeud-donnees.md
Normal 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)**
|
||||
181
docs/mise-en-prod/05-cluster-k3s.md
Normal file
181
docs/mise-en-prod/05-cluster-k3s.md
Normal 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)**
|
||||
251
docs/mise-en-prod/06-secrets-sops.md
Normal file
251
docs/mise-en-prod/06-secrets-sops.md
Normal 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)**
|
||||
173
docs/mise-en-prod/07-stockage-objet-s3.md
Normal file
173
docs/mise-en-prod/07-stockage-objet-s3.md
Normal 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)**
|
||||
294
docs/mise-en-prod/08-dns-tls-cloudflare.md
Normal file
294
docs/mise-en-prod/08-dns-tls-cloudflare.md
Normal 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)**
|
||||
337
docs/mise-en-prod/09-deploiement-application.md
Normal file
337
docs/mise-en-prod/09-deploiement-application.md
Normal 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)**
|
||||
260
docs/mise-en-prod/10-cicd-github-actions.md
Normal file
260
docs/mise-en-prod/10-cicd-github-actions.md
Normal 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)**
|
||||
273
docs/mise-en-prod/11-observabilite.md
Normal file
273
docs/mise-en-prod/11-observabilite.md
Normal 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)**
|
||||
315
docs/mise-en-prod/12-sauvegardes-restauration.md
Normal file
315
docs/mise-en-prod/12-sauvegardes-restauration.md
Normal 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)**
|
||||
310
docs/mise-en-prod/13-securite-durcissement.md
Normal file
310
docs/mise-en-prod/13-securite-durcissement.md
Normal 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)**
|
||||
256
docs/mise-en-prod/14-runbook-go-live.md
Normal file
256
docs/mise-en-prod/14-runbook-go-live.md
Normal 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)**
|
||||
405
docs/mise-en-prod/15-exploitation-incidents.md
Normal file
405
docs/mise-en-prod/15-exploitation-incidents.md
Normal 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
|
||||
```
|
||||
241
docs/mise-en-prod/16-rgpd-conformite.md
Normal file
241
docs/mise-en-prod/16-rgpd-conformite.md
Normal 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
174
docs/mise-en-prod/README.md
Normal 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) |
|
||||
Loading…
Reference in New Issue
Block a user