feat(infra): provisionnement Hetzner, k3s et secrets SOPS

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

43
infra/prod/.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# === Garde-fou : rien de dechiffre ne doit entrer dans Git ===================
# Tout secret est versionne UNIQUEMENT sous forme chiffree SOPS (*.sops.yaml).
# Fichiers d'environnement en clair
*.env
.env
.env.*
!*.env.example
!.env.example
# Secrets Kubernetes dechiffres (sortie de `sops -d`)
*.dec.yaml
*.decrypted.yaml
secrets-plain.yaml
# Cles
*.key
*.pem
*.age
age.key
age-key.txt
id_ed25519*
id_rsa*
# Kubeconfig
kubeconfig*
*.kubeconfig
# Terraform
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.*
*.tfvars
!*.tfvars.example
crash.log
tfplan*
# Dumps et backups locaux
*.dump
*.sql.gz
*.tar.zst
backups/

40
infra/prod/.sops.yaml Normal file
View File

@ -0,0 +1,40 @@
# =============================================================================
# Regles de chiffrement SOPS (age)
# =============================================================================
#
# Un seul mecanisme de secrets pour toute la prod : SOPS + age.
# Il couvre a la fois les Secrets Kubernetes (noeud app) ET les fichiers .env
# du noeud de donnees (Docker Compose) -- ce qu'un controleur type Sealed
# Secrets ne saurait pas faire.
#
# Generation de la cle (UNE SEULE FOIS, cf. docs/mise-en-prod/06-secrets-sops.md) :
# age-keygen -o ~/.config/sops/age/keys.txt
# grep 'public key' ~/.config/sops/age/keys.txt
#
# Puis remplacer AGE_RECIPIENT_PRIMARY ci-dessous par la cle publique (age1...).
# La cle PRIVEE ne quitte jamais : votre poste + le coffre-fort (1Password /
# Bitwarden) + le secret GitHub Actions SOPS_AGE_KEY.
#
# Ajoutez une 2e recipient (cle de secours, stockee hors ligne, sur papier ou
# YubiKey) : sans elle, perdre le poste = perdre tous les secrets de prod.
creation_rules:
# Secrets Kubernetes : on ne chiffre QUE les valeurs sous `data`/`stringData`,
# les metadonnees restent lisibles pour pouvoir relire un diff en revue.
- path_regex: k8s/.*\.sops\.yaml$
encrypted_regex: '^(data|stringData)$'
age: >-
AGE_RECIPIENT_PRIMARY,
AGE_RECIPIENT_BACKUP
# Fichiers d'environnement du noeud de donnees (docker compose) : tout chiffre.
- path_regex: (data-node|env)/.*\.sops\.(yaml|env)$
age: >-
AGE_RECIPIENT_PRIMARY,
AGE_RECIPIENT_BACKUP
# Filet de securite : tout autre .sops.yaml du dossier prod.
- path_regex: \.sops\.yaml$
age: >-
AGE_RECIPIENT_PRIMARY,
AGE_RECIPIENT_BACKUP

140
infra/prod/Makefile Normal file
View File

@ -0,0 +1,140 @@
# =============================================================================
# Xpeditis - production Hetzner
# =============================================================================
# Toutes les cibles s'executent depuis infra/prod/.
# make help
#
# Les cibles qui touchent la production affichent ce qu'elles vont faire et
# demandent confirmation. Aucune ne s'execute par accident.
SHELL := /bin/bash
.DEFAULT_GOAL := help
KUBECONFIG ?= $(HOME)/.kube/xpeditis-prod.yaml
NAMESPACE ?= xpeditis-prod
REGISTRY ?= rg.fr-par.scw.cloud/weworkstudio
export KUBECONFIG
BOLD := \033[1m
RESET := \033[0m
.PHONY: help
help: ## Affiche cette aide
@printf '$(BOLD)Xpeditis - production$(RESET)\n\n'
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}'
@printf '\nKUBECONFIG : $(KUBECONFIG)\n'
# --- Infrastructure ---------------------------------------------------------
.PHONY: tf-init
tf-init: ## Terraform : initialisation
cd terraform && terraform init
.PHONY: tf-plan
tf-plan: ## Terraform : previsualise les changements d'infrastructure
cd terraform && terraform plan
.PHONY: tf-apply
tf-apply: ## Terraform : applique (cree/modifie les serveurs)
@printf '$(BOLD)Cette commande modifie l infrastructure de PRODUCTION.$(RESET)\n'
@read -p 'Continuer ? [oui/non] ' r; [ "$$r" = oui ]
cd terraform && terraform apply
.PHONY: tf-output
tf-output: ## Terraform : affiche les IP et les enregistrements DNS a creer
cd terraform && terraform output
.PHONY: cloudflare-ips
cloudflare-ips: ## Compare les rangs IP Cloudflare avec firewall.tf
bash scripts/refresh-cloudflare-ips.sh
# --- Secrets ----------------------------------------------------------------
.PHONY: secrets-edit
secrets-edit: ## Edite les secrets chiffres (SOPS ouvre votre editeur)
sops k8s/base/03-secrets.sops.yaml
.PHONY: secrets-apply
secrets-apply: ## Applique les secrets sur le cluster
bash scripts/secrets-apply.sh
.PHONY: secrets-check
secrets-check: ## Verifie qu'aucun secret en clair n'est pret a etre commite
@echo '>>> Fichiers suspects dans infra/prod :'
@! git ls-files --others --cached --exclude-standard . \
| grep -E '\.(env|key|pem)$$|secrets\.yaml$$|tfvars$$' \
| grep -v '\.example$$' \
| grep -v '\.sops\.' \
|| (echo 'ARRET : des fichiers sensibles sont suivis ou non ignores.'; exit 1)
@echo 'Aucun fichier sensible detecte.'
# --- Deploiement ------------------------------------------------------------
.PHONY: deploy
deploy: ## Deploie une version (make deploy TAG=prod-a1b2c3d)
@[ -n "$(TAG)" ] || (echo 'Usage: make deploy TAG=prod-a1b2c3d'; exit 1)
bash scripts/deploy.sh $(TAG)
.PHONY: deploy-monitoring
deploy-monitoring: ## Deploie ou met a jour la pile d'observabilite
bash scripts/deploy-monitoring.sh
.PHONY: rollback
rollback: ## Revient a la version precedente (backend + frontend)
@printf '$(BOLD)Retour arriere de la PRODUCTION.$(RESET)\n'
@read -p 'Continuer ? [oui/non] ' r; [ "$$r" = oui ]
kubectl -n $(NAMESPACE) rollout undo deploy/xpeditis-backend
kubectl -n $(NAMESPACE) rollout undo deploy/xpeditis-frontend
kubectl -n $(NAMESPACE) rollout status deploy/xpeditis-backend
kubectl -n $(NAMESPACE) rollout status deploy/xpeditis-frontend
.PHONY: restart
restart: ## Redemarre les pods applicatifs (prise en compte des secrets)
kubectl -n $(NAMESPACE) rollout restart deploy/xpeditis-backend deploy/xpeditis-frontend
# --- Controles --------------------------------------------------------------
.PHONY: preflight
preflight: ## Controle go / no-go avant ouverture au public
bash scripts/preflight-check.sh
.PHONY: smoke
smoke: ## Tests de fumee sur les URLs publiques
bash scripts/smoke-test.sh
.PHONY: status
status: ## Vue d'ensemble de la production
@printf '\n$(BOLD)Noeuds$(RESET)\n'; kubectl get nodes -o wide
@printf '\n$(BOLD)Application$(RESET)\n'; kubectl -n $(NAMESPACE) get pods,deploy,hpa
@printf '\n$(BOLD)Ingress$(RESET)\n'; kubectl -n $(NAMESPACE) get ingress
@printf '\n$(BOLD)Certificats$(RESET)\n'; kubectl get certificate -A
@printf '\n$(BOLD)Observabilite$(RESET)\n'; kubectl -n monitoring get pods
.PHONY: logs
logs: ## Journaux du backend en direct
kubectl -n $(NAMESPACE) logs -l app.kubernetes.io/name=xpeditis-backend -f --tail=100 --max-log-requests=6
.PHONY: logs-frontend
logs-frontend: ## Journaux du frontend en direct
kubectl -n $(NAMESPACE) logs -l app.kubernetes.io/name=xpeditis-frontend -f --tail=100 --max-log-requests=6
.PHONY: events
events: ## Derniers evenements Kubernetes (diagnostic)
kubectl -n $(NAMESPACE) get events --sort-by=.lastTimestamp | tail -40
# --- Validation locale ------------------------------------------------------
.PHONY: validate
validate: ## Valide les manifests sans rien appliquer
@echo '>>> Validation cote serveur (dry-run)'
@for f in k8s/base/*.yaml k8s/monitoring/*.yaml k8s/cluster/*.yaml; do \
case "$$f" in *secrets.template.yaml|*migration-job.yaml) continue;; esac; \
printf ' %s\n' "$$f"; \
kubectl apply --dry-run=server -f "$$f" >/dev/null || exit 1; \
done
@echo '>>> Terraform'
@cd terraform && terraform validate
@echo '>>> Scripts shell'
@command -v shellcheck >/dev/null && shellcheck -S warning scripts/*.sh data-node/backup/*.sh || echo ' shellcheck absent, ignore'
@echo 'Validation terminee.'

152
infra/prod/README.md Normal file
View File

@ -0,0 +1,152 @@
# infra/prod — Production Hetzner
Tout ce qui est nécessaire pour déployer et exploiter Xpeditis en production.
> **La procédure pas à pas est dans [`docs/mise-en-prod/`](../../docs/mise-en-prod/README.md).**
> Ce README décrit *ce que contient le dossier* ; la doc décrit *quoi faire, dans quel ordre*.
---
## Architecture cible
```
Internet
|
Cloudflare (DNS + WAF + anti-DDoS)
| seules les IP Cloudflare passent le firewall
v
┌─────────────────────────────────────────────┐
│ app-01 CPX41 8 vCPU / 16 Go fsn1 │
│ │
│ k3s (mono-nœud) │
│ Traefik ──┬── api.xpeditis.com → backend │
│ ├── app / www / apex → frontend│
│ └── grafana.xpeditis.com │
│ backend NestJS x2 (HPA 2→4) │
│ frontend Next.js x2 │
│ log-exporter │
│ monitoring : Loki, Promtail, Prometheus, │
│ Alertmanager, Grafana │
└──────────────────┬──────────────────────────┘
│ réseau privé 10.10.1.0/24
│ TLS obligatoire (hostssl)
v
┌─────────────────────────────────────────────┐
│ db-01 CPX31 4 vCPU / 8 Go fsn1 │
│ │
│ PostgreSQL 15 + WAL-G (Docker Compose) │
│ Redis 7 │
│ postgres-exporter │
│ volume dédié 50 Go │
└──────────────────┬──────────────────────────┘
│
┌───────────┴────────────┐
v v
Hetzner Object Storage Hetzner Storage Box
(documents + WAL-G) (dumps chiffrés age)
```
**Pourquoi la base hors de Kubernetes** — PostgreSQL en StatefulSet ajoute des
volumes persistants, un ordre de démarrage et des montées de version délicates,
sans aucun gain à cette échelle. Hors cluster, les sauvegardes, la restauration
à un instant T et les tests de restauration sont triviaux, et le nœud
applicatif reste entièrement reconstructible sans toucher aux données.
---
## Contenu du dossier
| Chemin | Rôle |
|---|---|
| `terraform/` | Serveurs, réseau privé, firewalls, volume, clé SSH. La seule source de vérité de l'infrastructure. |
| `scripts/00-bootstrap-common.sh` | Durcissement système commun (SSH, UFW, fail2ban, auditd, sysctl, mises à jour auto). |
| `scripts/01-setup-data-node.sh` | Installe db-01 : volume, Docker, certificat TLS Postgres, timers de sauvegarde. |
| `scripts/02-setup-k3s-server.sh` | Installe k3s durci (secrets chiffrés au repos, audit API, Traefik configuré). |
| `scripts/03-install-cluster-addons.sh` | cert-manager, namespaces, accès registre, ClusterIssuer. |
| `scripts/secrets-apply.sh` | Déchiffre SOPS → applique sur le cluster, sans passer par le disque. |
| `scripts/harden-seed-data.sh` | Secours et audit : neutralise les comptes de démonstration (`admin@xpeditis.com` / `Password123!`) sur une base migrée avant l'ajout de la garde `NODE_ENV`. Le cas nominal est traité par les migrations. |
| `scripts/deploy.sh` | Déploiement complet : migrations → images → attente → tests → retour arrière. |
| `scripts/ssh-deploy-wrapper.sh` | Restreint la clé SSH de la CI à quatre commandes. Une clé volée ne donne pas un shell. |
| `scripts/deploy-monitoring.sh` | Pile d'observabilité. |
| `scripts/smoke-test.sh` | Vérifie ce qu'un utilisateur constate réellement, de l'extérieur. |
| `scripts/preflight-check.sh` | Contrôle go / no-go avant ouverture au public. |
| `scripts/refresh-cloudflare-ips.sh` | Met à jour les rangs IP Cloudflare (firewall + Traefik). |
| `data-node/` | Docker Compose, `postgresql.conf`, `pg_hba.conf`, `redis.conf`, sauvegardes WAL-G. |
| `k8s/base/` | Namespaces, quotas, ConfigMap, gabarit de secrets, déploiements, Ingress, middlewares, politiques réseau, certificats. |
| `k8s/cluster/` | ClusterIssuer Let's Encrypt (DNS-01 Cloudflare). |
| `k8s/monitoring/` | Loki, Promtail, Prometheus, Alertmanager, Grafana. |
| `env/` | Gabarits de variables d'environnement et liste des secrets GitHub. |
| `cloudflare/` | Enregistrements DNS et règles WAF à créer côté Cloudflare. |
| `Makefile` | Raccourcis d'exploitation (`make help`). |
---
## Démarrage rapide
```bash
cd infra/prod
make help
# Infrastructure
cp terraform/terraform.tfvars.example terraform/terraform.tfvars
$EDITOR terraform/terraform.tfvars
make tf-init && make tf-plan && make tf-apply
make tf-output # IP + enregistrements DNS à créer
# ... provisioning des serveurs : voir docs/mise-en-prod/03 et 04 ...
# Secrets
cp k8s/base/03-secrets.template.yaml /tmp/secrets.yaml
$EDITOR /tmp/secrets.yaml
sops -e /tmp/secrets.yaml > k8s/base/03-secrets.sops.yaml && shred -u /tmp/secrets.yaml
make secrets-apply
# Déploiement
make deploy TAG=prod-a1b2c3d
make deploy-monitoring
# Avant d'ouvrir au public
make preflight
```
---
## Règles de sécurité non négociables
1. **Aucun secret en clair dans Git.** Uniquement des fichiers `*.sops.yaml`
chiffrés avec age. `make secrets-check` refuse le contraire.
2. **La base de données n'est jamais joignable depuis Internet.** Réseau privé,
bind explicite sur l'IP privée, UFW, et `pg_hba` en `hostssl` seulement.
3. **Personne ne contourne Cloudflare.** Le firewall Hetzner n'accepte 80/443
que depuis les rangs Cloudflare.
4. **SSH et l'API Kubernetes ne sont ouverts qu'à vos IP d'administration.**
Jamais `0.0.0.0/0` — Terraform refuse cette valeur.
5. **Une sauvegarde non restaurée n'est pas une sauvegarde.** Le test de
restauration tourne toutes les semaines et doit être passé avant l'ouverture.
6. **Les migrations passent par un Job**, jamais en concurrence entre replicas.
7. **L'image frontend est reconstruite pour la production**, jamais promue
depuis la preprod : `NEXT_PUBLIC_API_URL` est figée au build.
8. **Aucun mot de passe d'administrateur n'est stocké nulle part.**
`SeedTestUsers` ne s'exécute plus en production, une migration de secours
neutralise ses comptes s'ils existent, et le premier administrateur est créé
sans mot de passe utilisable — vous définissez le vôtre via « mot de passe
oublié ».
---
## Coûts (référence : `Xpeditis_Previsions_Couts.xlsx`, feuille « Hetzner »)
| Poste | € HT / mois |
|---|---|
| app-01 CPX41 | 29 |
| db-01 CPX31 | 15 |
| Volume 50 Go | 2,40 |
| Sauvegardes Hetzner (20 %) | 8,80 |
| Storage Box BX11 (1 To) | 3,90 |
| Object Storage (documents + WAL-G, < 1 To) | ~6 |
| IPv4 supplémentaire | 1 |
| Cloudflare Free | 0 |
| **Total infrastructure** | **≈ 66 €** |
Les services tiers (Brevo, Stripe, Sentry, Pappers, assurance) s'ajoutent :
voir la feuille « Synthèse comparatif » du fichier de prévisions.

View File

@ -0,0 +1,171 @@
# Configuration Cloudflare — production
Cloudflare est la première ligne de défense : DNS, anti-DDoS, WAF, cache.
Le firewall Hetzner n'accepte 80/443 **que** depuis les rangs Cloudflare, donc
si le proxy est désactivé sur un enregistrement, ce domaine devient
inaccessible. C'est voulu : personne ne doit pouvoir taper l'IP d'origine.
Le plan Free suffit en phase 1 (≈ 100 utilisateurs). Le plan Pro (20 €/mois,
prévu au budget à partir de 1 000 utilisateurs) ajoute le WAF managé et
l'analyse de bots.
---
## 1. Enregistrements DNS
Toutes les valeurs `<IP_APP_01>` viennent de `make tf-output`.
| Type | Nom | Contenu | Proxy | TTL |
|---|---|---|---|---|
| A | `xpeditis.com` | `<IP_APP_01>` | **Proxifié** | Auto |
| A | `www` | `<IP_APP_01>` | **Proxifié** | Auto |
| A | `app` | `<IP_APP_01>` | **Proxifié** | Auto |
| A | `api` | `<IP_APP_01>` | **Proxifié** | Auto |
| A | `grafana` | `<IP_APP_01>` | **Proxifié** | Auto |
| AAAA | idem si IPv6 activée | `<IPv6_APP_01>` | **Proxifié** | Auto |
**Aucun enregistrement ne pointe vers db-01.** Son IP publique ne sert qu'au
SSH d'administration et ne doit apparaître nulle part en DNS.
### Messagerie (obligatoire pour que les e-mails arrivent)
| Type | Nom | Contenu |
|---|---|---|
| TXT | `xpeditis.com` | `v=spf1 include:spf.brevo.com -all` |
| TXT | `mail._domainkey` | clé DKIM fournie par Brevo |
| TXT | `_dmarc` | `v=DMARC1; p=quarantine; rua=mailto:dmarc@xpeditis.com; pct=100` |
Sans SPF/DKIM/DMARC, les confirmations de réservation et les liens magiques
transporteurs partent en spam — un défaut de production silencieux et coûteux.
Commencez à `p=quarantine`, passez à `p=reject` après deux semaines de rapports
propres.
---
## 2. Réglages SSL/TLS
| Réglage | Valeur | Pourquoi |
|---|---|---|
| Mode de chiffrement | **Full (strict)** | Cloudflare vérifie le certificat Let's Encrypt du serveur. En « Flexible », le trafic Cloudflare → origine serait en clair. |
| Always Use HTTPS | Activé | |
| Minimum TLS Version | **1.2** | TLS 1.0/1.1 sont cassés et refusés par la plupart des référentiels de conformité. |
| TLS 1.3 | Activé | |
| Automatic HTTPS Rewrites | Activé | |
| HSTS | **Activé après vérification** | Voir l'avertissement ci-dessous. |
> **HSTS est irréversible pendant un an.** Ne l'activez qu'une fois certain que
> *tous* les sous-domaines servent bien du HTTPS valide. Traefik pose déjà
> l'en-tête (`k8s/base/08-traefik-middlewares.yaml`) ; l'activer aussi côté
> Cloudflare ajoute la protection avant même que la requête n'atteigne le
> serveur.
---
## 3. Règles WAF
### 3.1 Bloquer l'accès direct aux chemins d'administration
```
(http.host eq "api.xpeditis.com" and starts_with(http.request.uri.path, "/api/docs"))
```
→ Action : **Block**
La documentation Swagger est déjà désactivée en production par `main.ts`, mais
une règle de bordure protège même en cas d'erreur de configuration.
### 3.2 Limiter les tentatives de connexion
Rate limiting (Security → WAF → Rate limiting rules) :
| Champ | Valeur |
|---|---|
| Expression | `starts_with(http.request.uri.path, "/api/v1/auth/login")` |
| Requêtes | 10 |
| Période | 1 minute |
| Par | Adresse IP |
| Action | Block, 10 minutes |
Cette limite s'ajoute à celle de Traefik (`rate-limit-auth`) et à celle de
NestJS (`CustomThrottlerGuard`). Trois couches indépendantes : une erreur de
configuration sur l'une ne laisse pas la porte ouverte.
### 3.3 Protéger le webhook Stripe
```
(http.host eq "api.xpeditis.com" and starts_with(http.request.uri.path, "/api/v1/subscriptions/webhook") and not ip.src in {IP_STRIPE})
```
→ Action : **Block**
Les rangs d'IP Stripe sont publiés sur
`https://stripe.com/files/ips/ips_webhooks.txt`. La signature du webhook est
déjà vérifiée côté applicatif ; ceci évite simplement que du bruit atteigne
l'application.
### 3.4 Filtrage géographique — à décider consciemment
Xpeditis sert des transitaires européens. Bloquer tout sauf l'Europe réduit
massivement le bruit, mais coupe aussi les transitaires asiatiques légitimes
(les connecteurs transporteurs partent du serveur, ils ne sont pas concernés).
**Recommandation** : ne pas bloquer par pays au lancement. Activer plutôt
« Bot Fight Mode » (gratuit) et surveiller les journaux un mois avant de
décider.
---
## 4. Cache
| Chemin | Règle |
|---|---|
| `api.xpeditis.com/*` | **Bypass cache** — une réponse d'API mise en cache servirait les données d'un utilisateur à un autre. |
| `app.xpeditis.com/_next/static/*` | Cache Everything, Edge TTL 1 an (contenu au nom versionné). |
| `app.xpeditis.com/*` | Standard (Next.js gère ses propres en-têtes). |
> La règle de bypass sur l'API est **critique**. Un cache mal placé sur une
> route authentifiée est une fuite de données, pas un problème de performance.
---
## 5. Jeton API pour cert-manager
`My Profile → API Tokens → Create Token → Edit zone DNS`
| Permission | Portée |
|---|---|
| Zone / DNS / Edit | Zone **xpeditis.com** uniquement |
| Zone / Zone / Read | Zone **xpeditis.com** uniquement |
N'utilisez **jamais** la clé globale du compte : elle permettrait à
cert-manager — ou à quiconque lirait le Secret — de rediriger tout votre trafic.
Le jeton alimente le Secret `cert-manager/cloudflare-api-token`
(`k8s/base/03-secrets.template.yaml`).
---
## 6. Accès à Grafana
Deux options, au choix :
- **Liste d'IP** (par défaut) — middleware Traefik `admin-ip-allowlist`,
à renseigner dans `k8s/base/08-traefik-middlewares.yaml`.
- **Cloudflare Access** (Zero Trust, gratuit jusqu'à 50 utilisateurs) —
authentification par e-mail à usage unique devant `grafana.xpeditis.com`.
Préférable si votre IP est dynamique.
---
## 7. Vérification
```bash
# Le proxy est-il bien actif ? (l'IP retournée doit être une IP Cloudflare)
dig +short app.xpeditis.com
# L'origine est-elle injoignable en direct ?
curl -sS --max-time 5 --connect-to app.xpeditis.com:443:<IP_APP_01>:443 \
https://app.xpeditis.com/ # doit échouer par timeout
# SPF / DKIM / DMARC
dig +short TXT xpeditis.com
dig +short TXT _dmarc.xpeditis.com
```

View File

@ -0,0 +1,29 @@
# =============================================================================
# PostgreSQL 15 + WAL-G
# =============================================================================
# Image postgres officielle enrichie de WAL-G, qui assure :
# - l'archivage continu des WAL vers Hetzner Object Storage (archive_command)
# - les sauvegardes physiques completes (basebackup)
# - la restauration a un instant T (PITR)
#
# On epingle une version precise de WAL-G : une mise a jour silencieuse de
# l'outil de sauvegarde est exactement ce qu'on ne veut pas decouvrir un jour
# de restauration.
FROM postgres:15-bookworm
ARG WALG_VERSION=v3.0.5
ARG TARGETARCH=amd64
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates curl; \
curl -fsSL -o /tmp/wal-g.tar.gz \
"https://github.com/wal-g/wal-g/releases/download/${WALG_VERSION}/wal-g-pg-ubuntu-22.04-${TARGETARCH}.tar.gz"; \
tar -xzf /tmp/wal-g.tar.gz -C /tmp; \
mv "/tmp/wal-g-pg-ubuntu-22.04-${TARGETARCH}" /usr/local/bin/wal-g; \
chmod 0755 /usr/local/bin/wal-g; \
rm -rf /tmp/wal-g.tar.gz /var/lib/apt/lists/*; \
wal-g --version
# Les scripts de sauvegarde / restauration sont montes depuis backup/.

View File

@ -0,0 +1,134 @@
#!/usr/bin/env bash
# =============================================================================
# Sauvegarde PostgreSQL - production Xpeditis
# =============================================================================
# Deux mecanismes complementaires, volontairement redondants :
#
# 1. WAL-G basebackup + archivage WAL continu -> Hetzner Object Storage
# Objectif : restauration a un instant T (PITR). RPO <= 5 min.
# C'est la sauvegarde qui compte en cas de corruption ou d'erreur humaine
# ("j'ai supprime les bookings de mars").
#
# 2. pg_dump logique chiffre -> Hetzner Storage Box (autre service, autre
# credential, autre protocole).
# Objectif : survivre a une compromission du compte Object Storage, a un
# bug WAL-G, ou a une montee de version majeure de PostgreSQL.
#
# Regle des 3-2-1 : 3 copies (volume + Object Storage + Storage Box), 2 supports
# distincts, 1 hors du perimetre de la VM. Cf. 12-sauvegardes-restauration.md.
#
# Execution : timer systemd `xpeditis-backup.timer`, tous les jours a 02h30.
set -euo pipefail
COMPOSE_DIR="${COMPOSE_DIR:-/opt/xpeditis/data-node}"
ENV_FILE="${ENV_FILE:-${COMPOSE_DIR}/.env.data}"
DUMP_DIR="${DUMP_DIR:-/var/lib/xpeditis/dumps}"
LOG_TAG="xpeditis-backup"
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a
log() { logger -t "$LOG_TAG" -- "$*"; printf '[%s] %s\n' "$(date -Is)" "$*"; }
fail() { log "ECHEC: $*"; notify "ECHEC sauvegarde PostgreSQL" "$*"; exit 1; }
notify() {
# Alerte Discord (meme webhook que la CI/CD). Une sauvegarde qui echoue en
# silence est pire que pas de sauvegarde du tout.
local title="$1" body="$2"
[[ -n "${DISCORD_WEBHOOK_URL:-}" ]] || return 0
curl -sf -H 'Content-Type: application/json' \
-d "$(jq -nc --arg t "$title" --arg d "$body" \
'{embeds:[{title:$t,description:$d,color:15158332}]}')" \
"$DISCORD_WEBHOOK_URL" >/dev/null || true
}
dc() { docker compose -f "${COMPOSE_DIR}/docker-compose.data.yml" --env-file "$ENV_FILE" "$@"; }
# --- 0. Preconditions --------------------------------------------------------
command -v age >/dev/null || fail "age n'est pas installe (chiffrement des dumps)"
[[ -n "${BACKUP_AGE_RECIPIENT:-}" ]] || fail "BACKUP_AGE_RECIPIENT absent de .env.data"
mkdir -p "$DUMP_DIR"
dc ps --status running --services | grep -qx postgres \
|| fail "le conteneur postgres n'est pas demarre"
# --- 1. Sauvegarde physique WAL-G (base de la PITR) --------------------------
log "WAL-G basebackup : demarrage"
if ! dc exec -T -u postgres postgres wal-g backup-push /var/lib/postgresql/data/pgdata; then
fail "wal-g backup-push a echoue"
fi
log "WAL-G basebackup : termine"
# --- 2. Retention WAL-G ------------------------------------------------------
# 7 sauvegardes completes conservees (soit ~1 semaine de PITR a raison d'une
# par jour), les WAL anterieurs sont purges avec elles.
log "WAL-G : purge des sauvegardes au-dela de 7 retentions"
dc exec -T -u postgres postgres wal-g delete retain FULL 7 --confirm \
|| log "AVERTISSEMENT: la purge WAL-G a echoue (non bloquant)"
# --- 3. Dump logique chiffre -------------------------------------------------
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
DUMP_NAME="xpeditis-${STAMP}.dump"
DUMP_PATH="${DUMP_DIR}/${DUMP_NAME}"
log "pg_dump logique : demarrage"
# -Fc = format custom : compresse, restaurable table par table avec pg_restore.
if ! dc exec -T -u postgres postgres \
pg_dump -Fc -Z6 --no-owner --no-privileges \
-d "$POSTGRES_DB" > "$DUMP_PATH"; then
rm -f "$DUMP_PATH"
fail "pg_dump a echoue"
fi
DUMP_SIZE=$(stat -c%s "$DUMP_PATH")
# Un dump anormalement petit signale une base vide ou une erreur silencieuse.
if (( DUMP_SIZE < 51200 )); then
rm -f "$DUMP_PATH"
fail "dump suspect : ${DUMP_SIZE} octets (< 50 Ko)"
fi
log "pg_dump : ${DUMP_SIZE} octets"
log "Chiffrement age"
age -r "$BACKUP_AGE_RECIPIENT" -o "${DUMP_PATH}.age" "$DUMP_PATH" \
|| fail "chiffrement age echoue"
# Le dump en clair ne survit pas a la minute : seule la version chiffree part
# sur le reseau et reste sur disque.
shred -u "$DUMP_PATH" 2>/dev/null || rm -f "$DUMP_PATH"
sha256sum "${DUMP_PATH}.age" > "${DUMP_PATH}.age.sha256"
# --- 4. Copie vers la Storage Box -------------------------------------------
if [[ -n "${STORAGEBOX_HOST:-}" ]]; then
log "Envoi vers la Storage Box ${STORAGEBOX_HOST}"
rsync -a --protect-args \
-e "ssh -p ${STORAGEBOX_PORT:-23} -i ${STORAGEBOX_SSH_KEY:-/root/.ssh/storagebox} -o StrictHostKeyChecking=yes" \
"${DUMP_PATH}.age" "${DUMP_PATH}.age.sha256" \
"${STORAGEBOX_USER}@${STORAGEBOX_HOST}:./xpeditis-prod/dumps/" \
|| fail "rsync vers la Storage Box a echoue"
else
log "AVERTISSEMENT: STORAGEBOX_HOST non configure, pas de seconde copie hors VM"
fi
# --- 5. Retention locale -----------------------------------------------------
# 7 jours en local : de quoi restaurer vite sans re-telecharger. La retention
# longue (30 jours + 12 mensuels) vit sur la Storage Box.
find "$DUMP_DIR" -name 'xpeditis-*.dump.age*' -mtime +7 -delete
log "Retention locale appliquee (7 jours)"
# --- 6. Compte rendu ---------------------------------------------------------
LATEST=$(dc exec -T -u postgres postgres wal-g backup-list --detail 2>/dev/null | tail -n 1 || echo "?")
log "Sauvegarde terminee. Derniere sauvegarde WAL-G: ${LATEST}"
# Battement de coeur externe : c'est le SILENCE qui declenche l'alerte. Une
# supervision hebergee sur cette meme machine ne dirait rien si la machine
# etait eteinte -- exactement le cas ou l'alerte compte.
if [[ -n "${BACKUP_HEARTBEAT_URL:-}" ]]; then
curl -fsS --max-time 10 --retry 3 "$BACKUP_HEARTBEAT_URL" >/dev/null \
|| log "AVERTISSEMENT: le battement de coeur n'a pas pu etre envoye"
fi
if [[ -n "${DISCORD_WEBHOOK_URL:-}" ]]; then
curl -sf -H 'Content-Type: application/json' \
-d "$(jq -nc --arg d "Dump: ${DUMP_NAME}.age ($(numfmt --to=iec "$DUMP_SIZE"))" \
'{embeds:[{title:"Sauvegarde PostgreSQL OK",description:$d,color:3066993}]}')" \
"$DISCORD_WEBHOOK_URL" >/dev/null || true
fi

View File

@ -0,0 +1,192 @@
#!/usr/bin/env bash
# =============================================================================
# Restauration PostgreSQL - production Xpeditis
# =============================================================================
# Trois modes, du moins au plus destructeur :
#
# verify <fichier.dump.age> Restaure dans une base jetable et controle
# la coherence. AUCUN impact sur la prod.
# C'est ce que lance le timer hebdomadaire.
#
# logical <fichier.dump.age> Restaure un dump logique dans une base
# nommee (par defaut une base de secours).
#
# pitr <YYYY-MM-DD HH:MM:SS> Restauration a un instant T via WAL-G.
# DETRUIT le repertoire de donnees courant.
# Reserve aux incidents majeurs.
#
# Lire docs/mise-en-prod/12-sauvegardes-restauration.md AVANT d'utiliser `pitr`.
set -euo pipefail
COMPOSE_DIR="${COMPOSE_DIR:-/opt/xpeditis/data-node}"
ENV_FILE="${ENV_FILE:-${COMPOSE_DIR}/.env.data}"
PGDATA_HOST="${PGDATA_HOST:-/var/lib/xpeditis/pgdata}"
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a
log() { printf '\n[%s] %s\n' "$(date -Is)" "$*"; }
fail() { printf '\nECHEC: %s\n' "$*" >&2; exit 1; }
dc() { docker compose -f "${COMPOSE_DIR}/docker-compose.data.yml" --env-file "$ENV_FILE" "$@"; }
confirm() {
local prompt="$1" expected="$2" answer
printf '\n%s\nTapez exactement "%s" pour confirmer : ' "$prompt" "$expected"
read -r answer
[[ "$answer" == "$expected" ]] || fail "confirmation refusee"
}
decrypt_dump() {
local encrypted="$1" out="$2"
[[ -f "$encrypted" ]] || fail "fichier introuvable : $encrypted"
[[ -f "${BACKUP_AGE_KEY_FILE:-/root/.config/xpeditis/backup-age.key}" ]] \
|| fail "cle de dechiffrement absente (BACKUP_AGE_KEY_FILE)"
age -d -i "${BACKUP_AGE_KEY_FILE:-/root/.config/xpeditis/backup-age.key}" \
-o "$out" "$encrypted" || fail "dechiffrement age echoue"
}
MODE="${1:-}"
case "$MODE" in
# ---------------------------------------------------------------------------
verify)
ENCRYPTED="${2:-$(ls -1t /var/lib/xpeditis/dumps/xpeditis-*.dump.age 2>/dev/null | head -1)}"
[[ -n "$ENCRYPTED" ]] || fail "aucun dump trouve"
SCRATCH_DB="xpeditis_restore_check"
TMP_DUMP="/tmp/xpeditis-verify-$$.dump"
log "Verification de : $ENCRYPTED"
decrypt_dump "$ENCRYPTED" "$TMP_DUMP"
trap 'rm -f "$TMP_DUMP"' EXIT
log "Creation de la base jetable ${SCRATCH_DB}"
dc exec -T -u postgres postgres psql -q -c "DROP DATABASE IF EXISTS ${SCRATCH_DB};"
dc exec -T -u postgres postgres psql -q -c "CREATE DATABASE ${SCRATCH_DB};"
log "pg_restore dans la base jetable"
dc exec -T -u postgres postgres pg_restore \
--no-owner --no-privileges --exit-on-error -d "$SCRATCH_DB" < "$TMP_DUMP" \
|| fail "pg_restore a echoue : LA SAUVEGARDE EST INEXPLOITABLE"
log "Controles de coherence"
# Une sauvegarde qui se restaure mais ne contient rien ne vaut rien : on
# verifie que les tables metier critiques sont peuplees.
dc exec -T -u postgres postgres psql -d "$SCRATCH_DB" -v ON_ERROR_STOP=1 <<'SQL'
\pset pager off
SELECT 'tables' AS objet, count(*) AS n FROM information_schema.tables WHERE table_schema='public';
SELECT 'users' AS objet, count(*) AS n FROM users;
SELECT 'organizations' AS objet, count(*) AS n FROM organizations;
SELECT 'csv_bookings' AS objet, count(*) AS n FROM csv_bookings;
SELECT 'migrations' AS objet, count(*) AS n FROM migrations;
DO $$
DECLARE t int;
BEGIN
SELECT count(*) INTO t FROM information_schema.tables WHERE table_schema='public';
IF t < 10 THEN
RAISE EXCEPTION 'Sauvegarde suspecte : seulement % tables restaurees', t;
END IF;
END $$;
SQL
log "Nettoyage"
dc exec -T -u postgres postgres psql -q -c "DROP DATABASE ${SCRATCH_DB};"
log "SAUVEGARDE VALIDE : $ENCRYPTED"
;;
# ---------------------------------------------------------------------------
logical)
ENCRYPTED="${2:?usage: $0 logical <fichier.dump.age> [base_cible]}"
TARGET_DB="${3:-${POSTGRES_DB}_restored}"
TMP_DUMP="/tmp/xpeditis-restore-$$.dump"
confirm "Restauration logique de $ENCRYPTED dans la base '${TARGET_DB}'. La base cible sera SUPPRIMEE si elle existe." "RESTAURER ${TARGET_DB}"
decrypt_dump "$ENCRYPTED" "$TMP_DUMP"
trap 'rm -f "$TMP_DUMP"' EXIT
dc exec -T -u postgres postgres psql -q -c "DROP DATABASE IF EXISTS ${TARGET_DB};"
dc exec -T -u postgres postgres psql -q -c "CREATE DATABASE ${TARGET_DB};"
dc exec -T -u postgres postgres pg_restore \
--no-owner --no-privileges --exit-on-error -j 2 -d "$TARGET_DB" < "$TMP_DUMP" \
|| fail "pg_restore a echoue"
log "Restauration terminee dans '${TARGET_DB}'."
log "La base de production n'a PAS ete touchee. Pour basculer :"
log " 1. arreter le backend : kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0"
log " 2. renommer les bases : ALTER DATABASE ${POSTGRES_DB} RENAME TO ${POSTGRES_DB}_old;"
log " ALTER DATABASE ${TARGET_DB} RENAME TO ${POSTGRES_DB};"
log " 3. relancer le backend : kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2"
;;
# ---------------------------------------------------------------------------
pitr)
TARGET_TIME="${2:?usage: $0 pitr \"YYYY-MM-DD HH:MM:SS\"}"
cat <<BANNER
############################################################
# RESTAURATION A UN INSTANT T (PITR) #
# #
# Cette operation DETRUIT le repertoire de donnees actuel #
# et rejoue les WAL jusqu'a : #
# ${TARGET_TIME}
# #
# Prerequis : #
# - backend arrete (replicas=0) : sinon ecritures perdues #
# - un dump logique frais pris AVANT (voir plus bas) #
############################################################
BANNER
confirm "Confirmez-vous la destruction de ${PGDATA_HOST} ?" "DETRUIRE ET RESTAURER"
log "Sauvegarde de securite du repertoire courant (au cas ou)"
SAFETY="/var/lib/xpeditis/pgdata-avant-pitr-$(date -u +%Y%m%dT%H%M%SZ)"
dc stop postgres
mv "${PGDATA_HOST}/pgdata" "$SAFETY" || fail "impossible de deplacer le repertoire courant"
mkdir -p "${PGDATA_HOST}/pgdata"
chown 999:999 "${PGDATA_HOST}/pgdata"
chmod 700 "${PGDATA_HOST}/pgdata"
log "Ancien repertoire conserve dans ${SAFETY} (a supprimer une fois la restauration validee)"
log "wal-g backup-fetch (derniere sauvegarde complete)"
dc run --rm --no-deps -u postgres postgres \
wal-g backup-fetch /var/lib/postgresql/data/pgdata LATEST \
|| fail "backup-fetch a echoue"
log "Configuration du rejeu des WAL jusqu'a ${TARGET_TIME}"
dc run --rm --no-deps -u postgres postgres bash -c "
set -e
D=/var/lib/postgresql/data/pgdata
touch \$D/recovery.signal
cat >> \$D/postgresql.auto.conf <<CONF
restore_command = 'wal-g wal-fetch \"%f\" \"%p\"'
recovery_target_time = '${TARGET_TIME}'
recovery_target_action = 'promote'
recovery_target_timeline = 'latest'
CONF
" || fail "configuration de la restauration echouee"
log "Redemarrage de PostgreSQL : le rejeu des WAL commence"
dc up -d postgres
log "Suivez la progression : docker compose logs -f postgres"
log "La base sort du mode restauration quand 'database system is ready to accept connections' apparait."
log "Verifiez ENSUITE les donnees avant de relancer le backend."
;;
# ---------------------------------------------------------------------------
*)
cat <<USAGE
Usage:
$0 verify [fichier.dump.age] Test de restauration sans impact
$0 logical <fichier.dump.age> [base] Restauration logique dans une base a part
$0 pitr "YYYY-MM-DD HH:MM:SS" Restauration a un instant T (DESTRUCTIF)
Lister les sauvegardes disponibles :
ls -lht /var/lib/xpeditis/dumps/
docker compose -f ${COMPOSE_DIR}/docker-compose.data.yml exec -u postgres postgres wal-g backup-list --detail
USAGE
exit 1
;;
esac

View File

@ -0,0 +1,26 @@
[Unit]
Description=Verification de restaurabilite des sauvegardes Xpeditis
Documentation=file:///opt/xpeditis/data-node/backup/pg-restore.sh
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
User=root
Environment=COMPOSE_DIR=/opt/xpeditis/data-node
Environment=ENV_FILE=/opt/xpeditis/data-node/.env.data
# `verify` restaure le dernier dump dans une base jetable puis la supprime.
# Une sauvegarde jamais restauree n'est pas une sauvegarde.
ExecStart=/opt/xpeditis/data-node/backup/pg-restore.sh verify
TimeoutStartSec=2h
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
StandardOutput=journal
StandardError=journal
SyslogIdentifier=xpeditis-backup-verify
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,12 @@
[Unit]
Description=Verification hebdomadaire des sauvegardes Xpeditis
[Timer]
# Dimanche 04h00, apres la sauvegarde de la nuit.
OnCalendar=Sun *-*-* 04:00:00
RandomizedDelaySec=600
Persistent=true
Unit=xpeditis-backup-verify.service
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,29 @@
[Unit]
Description=Sauvegarde PostgreSQL Xpeditis (WAL-G + dump logique chiffre)
Documentation=file:///opt/xpeditis/data-node/backup/pg-backup.sh
After=docker.service network-online.target
Requires=docker.service
[Service]
Type=oneshot
User=root
Environment=COMPOSE_DIR=/opt/xpeditis/data-node
Environment=ENV_FILE=/opt/xpeditis/data-node/.env.data
ExecStart=/opt/xpeditis/data-node/backup/pg-backup.sh
TimeoutStartSec=3h
# Le script ecrit dans /var/lib/xpeditis/dumps et pilote Docker : on ne peut pas
# aller beaucoup plus loin dans le confinement sans le casser.
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
StandardOutput=journal
StandardError=journal
SyslogIdentifier=xpeditis-backup
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,16 @@
[Unit]
Description=Sauvegarde PostgreSQL Xpeditis - quotidienne
Documentation=file:///opt/xpeditis/data-node/backup/pg-backup.sh
[Timer]
# 02h30 heure de Paris : creux d'activite des transitaires europeens, et avant
# la fenetre de redemarrage automatique des mises a jour de securite (04h30).
OnCalendar=*-*-* 02:30:00
# Etale le declenchement pour ne pas taper l'Object Storage a la seconde pres.
RandomizedDelaySec=300
# Rattrape la sauvegarde si le serveur etait eteint a l'heure prevue.
Persistent=true
Unit=xpeditis-backup.service
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,30 @@
# =============================================================================
# pg_hba.conf - production Xpeditis
# =============================================================================
# Principe : `hostssl` uniquement. Aucune connexion reseau en clair n'est
# possible, meme depuis le reseau prive Hetzner.
#
# Consequence a connaitre : toute application se connectant a cette base DOIT
# negocier TLS. Cote Xpeditis cela signifie DATABASE_SSL=true, honore par
# data-source.ts, par le pool applicatif et par scripts/setup/startup.js.
#
# TYPE DATABASE USER ADDRESS METHOD
# --- Socket local (administration dans le conteneur, wal-g, pg_dump) ---------
local all postgres peer
local all all scram-sha-256
# --- Noeud applicatif (k3s) --------------------------------------------------
hostssl all all 10.10.1.10/32 scram-sha-256
# --- Reseau interne du compose (postgres-exporter) ---------------------------
hostssl all all 172.28.0.0/24 scram-sha-256
# --- Replication (standby futur, phase 2 du plan de charge) ------------------
hostssl replication all 10.10.1.0/24 scram-sha-256
# --- Tout le reste est refuse explicitement ---------------------------------
# `reject` plutot qu'une absence de regle : le refus est immediat et journalise,
# ce qui rend les tentatives visibles dans Loki.
host all all 0.0.0.0/0 reject
host all all ::/0 reject

View File

@ -0,0 +1,108 @@
# =============================================================================
# PostgreSQL 15 - production Xpeditis
# Cible : Hetzner CPX31 (4 vCPU / 8 Go), partage avec Redis (1,5 Go plafonne).
# Budget memoire retenu pour PostgreSQL : ~6 Go.
# =============================================================================
# --- Connexions --------------------------------------------------------------
listen_addresses = '*' # le confinement reseau est assure par le bind
# Docker sur l'IP privee + UFW + pg_hba
port = 5432
max_connections = 150 # 2 replicas backend x pool TypeORM + marge ops
superuser_reserved_connections = 5
# --- TLS ---------------------------------------------------------------------
# Obligatoire : pg_hba n'accepte que des lignes `hostssl`. Le reseau prive
# Hetzner n'est pas chiffre par l'hyperviseur, c'est nous qui le chiffrons.
ssl = on
ssl_cert_file = '/var/lib/xpeditis/certs/server.crt'
ssl_key_file = '/var/lib/xpeditis/certs/server.key'
ssl_min_protocol_version = 'TLSv1.2'
ssl_prefer_server_ciphers = on
# --- Authentification --------------------------------------------------------
password_encryption = scram-sha-256
# --- Memoire -----------------------------------------------------------------
shared_buffers = 2GB
effective_cache_size = 5GB
maintenance_work_mem = 512MB
work_mem = 16MB # par operation de tri/hash, pas par connexion
huge_pages = try
# --- Disque (SSD NVMe Hetzner) ----------------------------------------------
random_page_cost = 1.1
effective_io_concurrency = 200
max_worker_processes = 4
max_parallel_workers = 4
max_parallel_workers_per_gather = 2
max_parallel_maintenance_workers = 2
# --- WAL / archivage ---------------------------------------------------------
wal_level = replica # suffisant pour PITR + futur standby physique
wal_compression = on
max_wal_size = 4GB
min_wal_size = 1GB
checkpoint_completion_target = 0.9
checkpoint_timeout = 15min
archive_mode = on
archive_command = 'wal-g wal-push %p'
# Force la cloture d'un segment WAL toutes les 5 min meme sans activite :
# c'est ce qui plafonne le RPO a 5 minutes de donnees perdues au maximum.
archive_timeout = 300
# Conserve de quoi rattacher un standby sans repartir d'un basebackup.
max_wal_senders = 5
wal_keep_size = 1GB
max_replication_slots = 5
# --- Autovacuum --------------------------------------------------------------
# audit_logs et csv_rates subissent beaucoup d'ecritures : on vacuume plus tot
# que les valeurs par defaut, sinon les index gonflent et les recherches
# tarifaires ralentissent.
autovacuum = on
autovacuum_max_workers = 3
autovacuum_naptime = 30s
autovacuum_vacuum_scale_factor = 0.05
autovacuum_analyze_scale_factor = 0.02
autovacuum_vacuum_cost_limit = 1000
# --- Delais de garde ---------------------------------------------------------
statement_timeout = 300000 # 5 min : borne les requetes folles
# tout en laissant passer les
# imports CSV de grilles
idle_in_transaction_session_timeout = 300000 # 5 min : libere les verrous oublies
lock_timeout = 30000
tcp_keepalives_idle = 60
tcp_keepalives_interval = 10
tcp_keepalives_count = 6
# --- Observabilite -----------------------------------------------------------
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 5000
pg_stat_statements.track = top
track_io_timing = on
track_functions = pl
logging_collector = off # les logs partent sur stdout -> Docker -> Loki
log_destination = 'stderr'
log_min_duration_statement = 500 # trace toute requete > 500 ms
log_line_prefix = '%m [%p] %q%u@%d %a '
log_checkpoints = on
log_connections = on
log_disconnections = on
log_lock_waits = on
log_temp_files = 0
log_autovacuum_min_duration = 1000
log_statement = 'ddl' # trace les changements de schema (migrations)
log_timezone = 'Europe/Paris'
# --- Localisation ------------------------------------------------------------
timezone = 'Europe/Paris'
datestyle = 'iso, mdy'
lc_messages = 'C'
lc_monetary = 'C'
lc_numeric = 'C'
lc_time = 'C'
default_text_search_config = 'pg_catalog.french'

View File

@ -0,0 +1,85 @@
# =============================================================================
# Redis 7 - production Xpeditis
# =============================================================================
# Le mot de passe n'est PAS ici : il est injecte par --requirepass depuis
# .env.data (cf. docker-compose.data.yml). Ce fichier est versionne, pas lui.
# --- Reseau ------------------------------------------------------------------
# Ecoute sur toutes les interfaces DU CONTENEUR ; l'exposition reelle est
# limitee par Docker (bind sur l'IP privee) puis par UFW.
bind 0.0.0.0
protected-mode yes
port 6379
tcp-backlog 511
tcp-keepalive 300
timeout 300
# --- Persistance -------------------------------------------------------------
# Redis ne sert pas qu'a cacher les cotations : il porte aussi des donnees de
# session et de revocation de jetons. Un redemarrage ne doit pas les perdre,
# sinon des jetons revoques redeviendraient valides.
dir /data
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec
no-appendfsync-on-rewrite no
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
aof-use-rdb-preamble yes
save 900 1
save 300 10
save 60 10000
dbfilename dump.rdb
rdbcompression yes
rdbchecksum yes
stop-writes-on-bgsave-error yes
# --- Memoire -----------------------------------------------------------------
# Estimation Excel : 0,5 Go a 100 utilisateurs. On plafonne a 1 Go, soit 2x de
# marge, sous la limite conteneur de 1,5 Go.
#
# volatile-lru et non allkeys-lru : seules les cles portant un TTL peuvent etre
# evincees. Les cotations tarifaires (TTL 15 min) sont donc sacrifiables, ce qui
# est le comportement voulu. Surveiller quand meme used_memory : une eviction
# subie sur une cle de revocation prolongerait la validite d'un jeton revoque.
# Alerte Grafana a 75 % (cf. k8s/monitoring/).
maxmemory 1gb
maxmemory-policy volatile-lru
maxmemory-samples 5
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes
# --- Durcissement ------------------------------------------------------------
# Les commandes destructrices ou de reconnaissance sont RENOMMEES, pas
# supprimees : une injection cote application ne peut plus les atteindre, mais
# un operateur qui connait le nom garde une porte de sortie en incident.
#
# Consequence connue : RedisCacheAdapter.clear() (redis-cache.adapter.ts:133)
# appelle FLUSHDB et echouera donc en production. C'est volontaire -- cette
# methode n'a aucun appelant dans le code applicatif et vider le cache de prod
# depuis une requete HTTP ne doit jamais etre possible. Pour vider le cache
# manuellement, utiliser le nom renomme ci-dessous depuis db-01.
#
# Ces noms sont publics dans Git : ils protegent d'une injection, pas d'un
# attaquant ayant deja le mot de passe Redis ET l'acces reseau. Si vous voulez
# une vraie defense, remplacez-les par des chaines aleatoires et notez-les dans
# le coffre-fort.
rename-command FLUSHALL XPD_FLUSHALL_9c1f
rename-command FLUSHDB XPD_FLUSHDB_9c1f
rename-command KEYS XPD_KEYS_9c1f
rename-command CONFIG XPD_CONFIG_9c1f
rename-command DEBUG ""
rename-command MODULE ""
# --- Journalisation ----------------------------------------------------------
logfile ""
loglevel notice
# --- Divers ------------------------------------------------------------------
databases 16
slowlog-log-slower-than 10000
slowlog-max-len 128
latency-monitor-threshold 100

View File

@ -0,0 +1,187 @@
# =============================================================================
# Noeud de donnees Xpeditis - production
# =============================================================================
# Deploye sur db-01 uniquement, dans /opt/xpeditis/data-node.
#
# docker compose -f docker-compose.data.yml --env-file .env.data up -d
#
# Regles non negociables appliquees ici :
# - Aucun port publie sur 0.0.0.0 : bind explicite sur l'IP privee.
# - Aucun mot de passe en dur : tout vient de .env.data (chiffre SOPS en Git).
# - PostgreSQL exige TLS (hostssl) pour toute connexion venant du reseau.
# - Les conteneurs ne peuvent pas escalader leurs privileges.
name: xpeditis-data
services:
postgres:
build:
context: .
dockerfile: Dockerfile.postgres
image: xpeditis/postgres-walg:15
container_name: xpeditis-postgres
restart: unless-stopped
stop_grace_period: 60s
# Bind sur l'IP privee : injoignable depuis Internet, meme si UFW tombe.
ports:
- "${PRIVATE_IP}:5432:5432"
environment:
POSTGRES_DB: "${POSTGRES_DB}"
POSTGRES_USER: "${POSTGRES_USER}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
PGDATA: /var/lib/postgresql/data/pgdata
# scram-sha-256 pour tous les mots de passe (jamais md5).
POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256 --data-checksums"
TZ: Europe/Paris
# --- WAL-G : archivage continu vers Hetzner Object Storage --------------
WALG_S3_PREFIX: "${WALG_S3_PREFIX}"
AWS_ACCESS_KEY_ID: "${WALG_ACCESS_KEY_ID}"
AWS_SECRET_ACCESS_KEY: "${WALG_SECRET_ACCESS_KEY}"
AWS_ENDPOINT: "${WALG_S3_ENDPOINT}"
AWS_REGION: "${WALG_S3_REGION}"
AWS_S3_FORCE_PATH_STYLE: "true"
# Chiffrement cote client : meme si le bucket fuite, les sauvegardes
# restent illisibles sans la cle privee libsodium.
WALG_LIBSODIUM_KEY: "${WALG_LIBSODIUM_KEY}"
WALG_COMPRESSION_METHOD: brotli
WALG_DELTA_MAX_STEPS: "6"
WALG_UPLOAD_CONCURRENCY: "2"
PGHOST: /var/run/postgresql
volumes:
- /var/lib/xpeditis/pgdata:/var/lib/postgresql/data
- ./conf/postgresql.conf:/etc/postgresql/postgresql.conf:ro
- ./conf/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro
- /var/lib/xpeditis/certs:/var/lib/xpeditis/certs:ro
- ./backup:/opt/backup:ro
- /var/lib/xpeditis/dumps:/var/lib/xpeditis/dumps
command:
- postgres
- -c
- config_file=/etc/postgresql/postgresql.conf
- -c
- hba_file=/etc/postgresql/pg_hba.conf
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -h /var/run/postgresql"]
interval: 10s
timeout: 5s
retries: 6
start_period: 30s
networks:
- internal
security_opt:
- no-new-privileges:true
shm_size: 512mb
deploy:
resources:
limits:
cpus: "3.0"
memory: 6g
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
redis:
image: redis:7.4-alpine
container_name: xpeditis-redis
restart: unless-stopped
stop_grace_period: 30s
ports:
- "${PRIVATE_IP}:6379:6379"
# requirepass passe en ligne de commande : redis.conf ne sait pas lire
# de variable d'environnement, et on refuse d'ecrire le mot de passe
# dans un fichier versionne.
command:
- redis-server
- /usr/local/etc/redis/redis.conf
- --requirepass
- "${REDIS_PASSWORD}"
volumes:
- ./conf/redis.conf:/usr/local/etc/redis/redis.conf:ro
- /var/lib/xpeditis/redis:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" --no-auth-warning ping | grep -q PONG"]
interval: 10s
timeout: 5s
retries: 6
start_period: 10s
environment:
REDIS_PASSWORD: "${REDIS_PASSWORD}"
TZ: Europe/Paris
networks:
- internal
security_opt:
- no-new-privileges:true
sysctls:
# Evite les pertes de connexion sous charge sur le backlog TCP.
net.core.somaxconn: 1024
deploy:
resources:
limits:
cpus: "1.0"
memory: 1500m
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
# Exportateur Prometheus PostgreSQL : alimente les tableaux de bord Grafana
# heberges sur app-01 (scrape via le reseau prive).
postgres-exporter:
image: prometheuscommunity/postgres-exporter:v0.15.0
container_name: xpeditis-postgres-exporter
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports:
- "${PRIVATE_IP}:9187:9187"
environment:
# Connexion par le reseau interne du compose (jamais par l'IP publiee),
# en TLS obligatoire comme toute autre connexion reseau.
DATA_SOURCE_NAME: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=require"
networks:
- internal
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
cpus: "0.25"
memory: 128m
logging:
driver: json-file
options:
max-size: "10m"
max-file: "2"
# Sous-reseau fixe : il est reference explicitement dans pg_hba.conf, il ne doit
# donc pas changer d'un `docker compose up` a l'autre.
networks:
internal:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/24

69
infra/prod/env/data-node.env.example vendored Normal file
View File

@ -0,0 +1,69 @@
# =============================================================================
# Noeud de donnees (db-01) - variables d'environnement
# =============================================================================
# Sur le serveur : /opt/xpeditis/data-node/.env.data (chmod 600, root:root)
# Dans Git : infra/prod/data-node/data-node.sops.env (chiffre SOPS)
#
# sops -e --input-type dotenv --output-type dotenv \
# /opt/xpeditis/data-node/.env.data > data-node/data-node.sops.env
# sops -d --input-type dotenv --output-type dotenv \
# data-node/data-node.sops.env > /opt/xpeditis/data-node/.env.data
#
# Generer un secret : openssl rand -base64 32 | tr -d '\n/+=' | head -c 40
# Ne JAMAIS reutiliser un secret de preprod : ceux presents dans le depot sont
# compromis (cf. docs/mise-en-prod/13-securite-durcissement.md, "Rotation
# obligatoire avant la mise en production").
# --- Reseau ------------------------------------------------------------------
# IP privee de db-01 : seule interface sur laquelle Postgres et Redis sont
# publies. Doit correspondre a var.db_private_ip cote Terraform.
PRIVATE_IP=10.10.1.20
# --- PostgreSQL --------------------------------------------------------------
POSTGRES_DB=xpeditis_prod
POSTGRES_USER=xpeditis
POSTGRES_PASSWORD=REMPLACER_40_CARACTERES_ALEATOIRES
# --- Redis -------------------------------------------------------------------
REDIS_PASSWORD=REMPLACER_40_CARACTERES_ALEATOIRES
# --- WAL-G : archivage continu vers Hetzner Object Storage -------------------
# Bucket DEDIE aux sauvegardes, distinct du bucket des documents applicatifs,
# avec son propre jeu de cles S3. Si les cles applicatives fuitent, les
# sauvegardes restent hors de portee.
WALG_S3_PREFIX=s3://xpeditis-prod-pgbackup
WALG_S3_ENDPOINT=https://fsn1.your-objectstorage.com
WALG_S3_REGION=fsn1
WALG_ACCESS_KEY_ID=REMPLACER
WALG_SECRET_ACCESS_KEY=REMPLACER
# Chiffrement cote client des sauvegardes physiques.
# WAL-G attend une cle de 32 octets encodee en HEXADECIMAL (64 caracteres) :
# openssl rand -hex 32
# Sans cette cle, aucune restauration n'est possible : conservez-la dans le
# coffre-fort ET hors ligne.
WALG_LIBSODIUM_KEY=REMPLACER_64_CARACTERES_HEXADECIMAUX
# --- Chiffrement des dumps logiques (age) ------------------------------------
# Cle publique age destinataire des dumps quotidiens.
# age-keygen -o backup-age.key && grep 'public key' backup-age.key
BACKUP_AGE_RECIPIENT=age1REMPLACER
# Chemin de la cle PRIVEE sur db-01, lue par pg-restore.sh.
BACKUP_AGE_KEY_FILE=/root/.config/xpeditis/backup-age.key
# --- Storage Box Hetzner (seconde copie des dumps) ---------------------------
# Souscrite separement (BX11 ~4 EUR/mois). Cf. 12-sauvegardes-restauration.md.
STORAGEBOX_HOST=uXXXXXX.your-storagebox.de
STORAGEBOX_USER=uXXXXXX
STORAGEBOX_PORT=23
STORAGEBOX_SSH_KEY=/root/.ssh/storagebox
# --- Alertes -----------------------------------------------------------------
# Meme webhook que la CI/CD : une sauvegarde en echec doit se voir.
DISCORD_WEBHOOK_URL=
# Battement de coeur externe appele a chaque sauvegarde reussie
# (BetterStack Heartbeats ou healthchecks.io, gratuit). Le service alerte quand
# l'appel n'arrive PAS -- c'est ce qui detecte un serveur eteint ou un timer
# systemd desactive, ce qu'une supervision interne ne verrait jamais.
BACKUP_HEARTBEAT_URL=

69
infra/prod/env/github-secrets.md vendored Normal file
View File

@ -0,0 +1,69 @@
# Secrets et variables GitHub Actions — production
Dépôt → **Settings → Environments → `production`**.
Utiliser un *Environment* et non des secrets de dépôt permet d'exiger une
approbation manuelle avant tout déploiement, et empêche une branche non
protégée d'accéder à ces valeurs.
> **Activez « Required reviewers » sur l'environnement `production`.**
> Sans cela, n'importe quel `push` sur `main` déploie sans validation humaine.
---
## Secrets
| Nom | Contenu | Comment l'obtenir |
|---|---|---|
| `REGISTRY_TOKEN` | Jeton du registre Scaleway (lecture + écriture) | Console Scaleway → Container Registry → jetons. Déjà utilisé par la preprod. |
| `HCLOUD_TOKEN_CICD` | Jeton API Hetzner Cloud | Console Hetzner → Security → API tokens. **Read & Write** (nécessaire pour ouvrir/fermer le firewall CI). Jeton **distinct** de celui de Terraform, pour pouvoir le révoquer seul. |
| `PROD_SSH_HOST` | IP publique de app-01 | `make tf-output` |
| `PROD_SSH_USER` | `deploy` | |
| `PROD_SSH_KEY` | Clé privée SSH **dédiée à la CI** | `ssh-keygen -t ed25519 -a 100 -C "github-actions-prod" -f ci_deploy` puis ajouter la clé publique dans `~deploy/.ssh/authorized_keys` sur app-01. Ne réutilisez pas votre clé personnelle. |
| `PROD_SSH_KNOWN_HOSTS` | Empreinte du serveur | `ssh-keyscan -H <IP_APP_01>` — évite qu'un détournement DNS/BGP redirige le déploiement vers une machine tierce. |
| `NEXT_PUBLIC_API_URL_PROD` | `https://api.xpeditis.com` | Figé dans l'image frontend au build. |
| `NEXT_PUBLIC_APP_URL_PROD` | `https://app.xpeditis.com` | idem |
| `DISCORD_WEBHOOK_URL` | Webhook du canal de déploiement | Déjà utilisé par la preprod. |
---
## Variables (non sensibles)
| Nom | Valeur |
|---|---|
| `PROD_API_URL` | `https://api.xpeditis.com` |
| `PROD_APP_URL` | `https://app.xpeditis.com` |
| `HCLOUD_CICD_FIREWALL` | `xpeditis-prod-fw-cicd` |
---
## Restreindre la clé SSH de la CI
La clé de déploiement n'a besoin que de lancer un script. Limitez-la dans
`~deploy/.ssh/authorized_keys` sur app-01 :
```
restrict,pty,command="/opt/xpeditis/infra-prod/scripts/ssh-deploy-wrapper.sh" ssh-ed25519 AAAA... github-actions-prod
```
`restrict` désactive le transfert de ports, d'agent et X11. Le `command=` force
l'exécution du wrapper quelle que soit la commande demandée : une clé volée ne
donne pas un shell.
> `rsync` a besoin d'exécuter sa propre commande. Le wrapper l'autorise
> explicitement en inspectant `SSH_ORIGINAL_COMMAND` — voir
> `scripts/ssh-deploy-wrapper.sh`.
---
## Ce qui ne doit PAS être un secret GitHub
- **La clé privée age / SOPS.** Elle déchiffre *tous* les secrets de production.
Les secrets sont appliqués depuis votre poste (`make secrets-apply`), pas
depuis la CI. Un dépôt compromis ne doit pas donner les mots de passe de la
base.
- **Le `kubeconfig`.** L'API Kubernetes n'est ouverte qu'à vos IP
d'administration ; la CI passe par SSH et utilise le kubeconfig local du
serveur.
- **Le token Terraform.** Il peut détruire l'infrastructure. Il reste sur votre
poste.

View File

@ -0,0 +1,30 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: xpeditis-prod
labels:
app.kubernetes.io/part-of: xpeditis
environment: production
# Pod Security Admission en mode "restricted" : refuse tout pod privilegie,
# tout conteneur root, toute capability. C'est une barriere du cluster,
# independante de ce que contiennent les manifests applicatifs.
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
---
apiVersion: v1
kind: Namespace
metadata:
name: monitoring
labels:
app.kubernetes.io/part-of: xpeditis
environment: production
# "baseline" et non "restricted" : Promtail doit lire les journaux des
# conteneurs de l'hote (hostPath), ce que "restricted" interdit.
# Le compromis est assume et confine a ce seul namespace.
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted

View File

@ -0,0 +1,59 @@
# =============================================================================
# Garde-fous de consommation
# =============================================================================
# Le noeud applicatif est un CPX41 (8 vCPU / 16 Go) partage entre l'application,
# l'ingress et la supervision. Sans quota, une fuite memoire du backend fait
# tomber Traefik et Grafana avec lui : plus d'application ET plus de moyen de
# comprendre pourquoi.
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: xpeditis-prod-quota
namespace: xpeditis-prod
spec:
hard:
requests.cpu: "3"
requests.memory: 5Gi
limits.cpu: "7"
limits.memory: 9Gi
pods: "20"
persistentvolumeclaims: "4"
services.loadbalancers: "0" # aucun LB supplementaire facture par erreur
---
apiVersion: v1
kind: LimitRange
metadata:
name: xpeditis-prod-defaults
namespace: xpeditis-prod
spec:
limits:
- type: Container
# Un conteneur deploye sans requests/limits (debug, job ponctuel) herite
# de valeurs raisonnables au lieu de pouvoir tout consommer.
default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 100m
memory: 128Mi
max:
cpu: "4"
memory: 4Gi
min:
cpu: 10m
memory: 32Mi
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: monitoring-quota
namespace: monitoring
spec:
hard:
requests.cpu: "1"
requests.memory: 1536Mi
limits.cpu: "3"
limits.memory: 3Gi
pods: "10"

View File

@ -0,0 +1,99 @@
# =============================================================================
# Configuration NON SECRETE du backend
# =============================================================================
# Seules figurent ici les variables reellement lues par le code
# (verifie dans app.module.ts, security.config.ts et les adaptateurs).
# Les identifiants et cles vivent dans le Secret xpeditis-backend-secrets.
apiVersion: v1
kind: ConfigMap
metadata:
name: xpeditis-backend-config
namespace: xpeditis-prod
labels:
app.kubernetes.io/name: xpeditis-backend
app.kubernetes.io/part-of: xpeditis
data:
NODE_ENV: "production"
PORT: "4000"
API_PREFIX: "api/v1"
# Force la sortie pino en JSON : c'est ce que Promtail sait decouper en
# champs (level, context, reqId) pour Loki.
LOG_FORMAT: "json"
# --- URLs publiques --------------------------------------------------------
APP_URL: "https://app.xpeditis.com"
FRONTEND_URL: "https://app.xpeditis.com"
BACKEND_URL: "https://api.xpeditis.com"
# Liste blanche CORS. `credentials: true` interdit le joker `*` : chaque
# origine autorisee doit etre enumeree (security.config.ts).
CORS_ORIGIN: "https://app.xpeditis.com,https://www.xpeditis.com,https://xpeditis.com"
# --- Cookies d'authentification -------------------------------------------
# Le point initial rend le cookie valide pour tous les sous-domaines : l'API
# (api.xpeditis.com) le pose, le middleware Next.js (app.xpeditis.com) le lit.
# Sans cela, la connexion reussit mais l'utilisateur est renvoye sur /login.
COOKIE_DOMAIN: ".xpeditis.com"
# app et api partagent le meme site : 'lax' suffit et reste le plus sur.
# Ne passer a 'none' que si le front devient reellement cross-site (et 'none'
# impose Secure).
COOKIE_SAMESITE: "lax"
# --- PostgreSQL (db-01, reseau prive) --------------------------------------
DATABASE_HOST: "10.10.1.20"
DATABASE_PORT: "5432"
DATABASE_NAME: "xpeditis_prod"
# pg_hba n'accepte que des lignes `hostssl` : sans cette valeur a "true",
# le backend ne peut tout simplement pas se connecter.
DATABASE_SSL: "true"
DATABASE_LOGGING: "false"
# --- Redis (db-01, reseau prive) -------------------------------------------
REDIS_HOST: "10.10.1.20"
REDIS_PORT: "6379"
REDIS_DB: "0"
# --- JWT -------------------------------------------------------------------
JWT_ACCESS_EXPIRATION: "15m"
JWT_REFRESH_EXPIRATION: "7d"
# --- SMTP (Brevo) ----------------------------------------------------------
SMTP_HOST: "smtp-relay.brevo.com"
SMTP_PORT: "587"
SMTP_SECURE: "false"
SMTP_FROM: "noreply@xpeditis.com"
# --- Stockage objet (Hetzner Object Storage, compatible S3) ----------------
# Aucun changement de code : le SDK AWS parle a Hetzner via AWS_S3_ENDPOINT.
AWS_REGION: "fsn1"
AWS_S3_ENDPOINT: "https://fsn1.your-objectstorage.com"
AWS_S3_BUCKET: "xpeditis-prod-documents"
# --- Collecteur de logs applicatifs ---------------------------------------
LOG_EXPORTER_URL: "http://xpeditis-log-exporter:3200"
# --- Premier administrateur ------------------------------------------------
# Lu par la migration 1756000000001-BootstrapAdminFromEnv, qui remplace le
# compte de demonstration admin@xpeditis.com / Password123!.
#
# Renseigne SEUL (sans BOOTSTRAP_ADMIN_PASSWORD_HASH dans le Secret), il cree
# un compte ADMIN actif SANS mot de passe utilisable : vous definissez le
# votre via « mot de passe oublie ». Aucun secret n'existe alors nulle part.
#
# La migration ne fait rien s'il existe deja un administrateur actif : elle ne
# peut donc pas en creer un second lors d'un deploiement ulterieur.
#
# Cette adresse doit etre RELEVABLE : c'est par elle que passe le lien de
# definition du mot de passe.
BOOTSTRAP_ADMIN_EMAIL: "ops@xpeditis.com"
BOOTSTRAP_ADMIN_FIRST_NAME: "Admin"
BOOTSTRAP_ADMIN_LAST_NAME: "Xpeditis"
# Organisation de rattachement (users.organization_id est NOT NULL).
# Creee si elle n'existe pas ; a completer ensuite dans l'interface.
BOOTSTRAP_ADMIN_ORG_NAME: "Xpeditis"
BOOTSTRAP_ADMIN_ORG_STREET: "A completer"
BOOTSTRAP_ADMIN_ORG_CITY: "A completer"
BOOTSTRAP_ADMIN_ORG_POSTAL_CODE: "00000"
BOOTSTRAP_ADMIN_ORG_COUNTRY: "FR"

View File

@ -0,0 +1,165 @@
# =============================================================================
# GABARIT de secrets -- NE JAMAIS COMMITTER CE FICHIER RENSEIGNE
# =============================================================================
# Mode d'emploi :
#
# 1. cp 03-secrets.template.yaml /tmp/secrets.yaml
# 2. renseigner chaque REMPLACER (voir 06-secrets-sops.md)
# 3. sops -e /tmp/secrets.yaml > k8s/base/03-secrets.sops.yaml
# 4. shred -u /tmp/secrets.yaml
# 5. git add k8s/base/03-secrets.sops.yaml <-- celui-la est chiffre, il se commit
#
# Application sur le cluster : bash scripts/secrets-apply.sh
#
# Generation d'une valeur aleatoire :
# openssl rand -base64 48 | tr -d '\n'
#
# RAPPEL : aucune valeur de preprod ne doit etre reprise. Les secrets presents
# dans infra/preprod/docker-stack.preprod.yml sont dans l'historique Git, donc
# compromis (cf. 13-securite-durcissement.md).
---
apiVersion: v1
kind: Secret
metadata:
name: xpeditis-backend-secrets
namespace: xpeditis-prod
labels:
app.kubernetes.io/name: xpeditis-backend
app.kubernetes.io/part-of: xpeditis
type: Opaque
stringData:
# --- Base de donnees -------------------------------------------------------
DATABASE_USER: "xpeditis"
DATABASE_PASSWORD: "REMPLACER" # identique a POSTGRES_PASSWORD de db-01
# --- Redis -----------------------------------------------------------------
REDIS_PASSWORD: "REMPLACER" # identique a REDIS_PASSWORD de db-01
# --- JWT -------------------------------------------------------------------
# Minimum 32 caracteres (valide par Joi au demarrage). Le changer invalide
# toutes les sessions en cours : a faire en dehors des heures ouvrees.
JWT_SECRET: "REMPLACER_64_CARACTERES_MINIMUM"
# Derive les mots de passe des documents transporteurs. S'il est absent, le
# code retombe sur JWT_SECRET -- et une rotation du JWT rendrait alors
# illisibles tous les documents deja emis. On le definit donc explicitement,
# et on ne le change JAMAIS sans plan de migration.
DOCUMENT_PASSWORD_SECRET: "REMPLACER_32_CARACTERES_MINIMUM"
# --- SMTP (Brevo) ----------------------------------------------------------
# Creez une cle SMTP dediee a la production dans Brevo. Celle de la preprod
# est publiee dans le depot : elle doit etre revoquee.
SMTP_USER: "REMPLACER"
SMTP_PASS: "REMPLACER"
# --- Stockage objet Hetzner (documents applicatifs) ------------------------
# Jeu de cles DISTINCT de celui utilise par WAL-G pour les sauvegardes :
# une fuite cote application ne doit pas donner acces aux sauvegardes.
AWS_ACCESS_KEY_ID: "REMPLACER"
AWS_SECRET_ACCESS_KEY: "REMPLACER"
# --- Stripe ----------------------------------------------------------------
# ATTENTION : cles LIVE en production (sk_live_..., whsec_...).
STRIPE_SECRET_KEY: "REMPLACER_sk_live"
STRIPE_WEBHOOK_SECRET: "REMPLACER_whsec"
# Noms exacts attendus par le code (app.module.ts / subscriptions).
# Le stack de preprod utilise STARTER/PRO/ENTERPRISE : ces noms-la ne sont lus
# par personne, les identifiants de tarif y sont donc silencieusement absents.
# Ne pas reproduire l'erreur ici.
STRIPE_SILVER_MONTHLY_PRICE_ID: "REMPLACER_price_"
STRIPE_SILVER_YEARLY_PRICE_ID: "REMPLACER_price_"
STRIPE_GOLD_MONTHLY_PRICE_ID: "REMPLACER_price_"
STRIPE_GOLD_YEARLY_PRICE_ID: "REMPLACER_price_"
STRIPE_PLATINIUM_MONTHLY_PRICE_ID: "REMPLACER_price_"
STRIPE_PLATINIUM_YEARLY_PRICE_ID: "REMPLACER_price_"
# --- Premier administrateur (FACULTATIF) -----------------------------------
# LAISSEZ VIDE dans le cas nominal.
#
# Vide + BOOTSTRAP_ADMIN_EMAIL renseigne dans le ConfigMap : le compte ADMIN
# est cree sans mot de passe utilisable, et vous definissez le votre via
# « mot de passe oublie ». Aucun secret n'existe nulle part -- rien a faire
# fuiter, et la reception du courriel prouve au passage que SMTP fonctionne.
#
# Ne renseignez ce champ que si vous devez pouvoir vous connecter AVANT que
# l'envoi de courriels ne soit operationnel. Dans ce cas :
# cd apps/backend && node scripts/setup/generate-admin-hash.js
# Le hash reste attaquable hors ligne : changez le mot de passe des la
# premiere connexion, puis RETIREZ cette valeur et reappliquez le Secret.
#
# Un mot de passe en clair place ici fait echouer la migration : la valeur
# doit commencer par "$argon2".
BOOTSTRAP_ADMIN_PASSWORD_HASH: ""
# --- Pappers (registre SIRET) ----------------------------------------------
# Facultatif : sans cle, la verification SIRET est simplement ignoree
# (pappers-siret.adapter.ts emet un avertissement au demarrage).
PAPPERS_API_KEY: "REMPLACER"
# --- Documentation Swagger -------------------------------------------------
# Laisser les deux valeurs VIDES en production : main.ts desactive alors
# completement /api/docs. Ne les renseigner que si vous avez vraiment besoin
# d'exposer la documentation, auquel cas elle passe derriere Basic Auth.
SWAGGER_USERNAME: ""
SWAGGER_PASSWORD: ""
# --- Connecteurs transporteurs (facultatifs) -------------------------------
# A renseigner uniquement quand les contrats API sont signes. Un connecteur
# sans identifiants est ignore, il ne fait pas planter le demarrage.
MAERSK_API_KEY: ""
MAERSK_API_BASE_URL: ""
MSC_API_KEY: ""
MSC_API_URL: ""
CMACGM_CLIENT_ID: ""
CMACGM_CLIENT_SECRET: ""
CMACGM_API_URL: ""
HAPAG_API_KEY: ""
HAPAG_API_URL: ""
ONE_USERNAME: ""
ONE_PASSWORD: ""
ONE_API_URL: ""
---
# Token API Cloudflare utilise par cert-manager pour le defi DNS-01.
# Permissions minimales : Zone / DNS / Edit sur la seule zone xpeditis.com.
apiVersion: v1
kind: Secret
metadata:
name: cloudflare-api-token
namespace: cert-manager
labels:
app.kubernetes.io/part-of: xpeditis
type: Opaque
stringData:
api-token: "REMPLACER"
---
# Acces a Grafana. Mot de passe long : l'interface est exposee sur Internet,
# meme si elle est filtree par IP en amont.
apiVersion: v1
kind: Secret
metadata:
name: grafana-admin
namespace: monitoring
labels:
app.kubernetes.io/part-of: xpeditis
type: Opaque
stringData:
admin-user: "REMPLACER"
admin-password: "REMPLACER"
---
# Webhook Discord utilise par Alertmanager. Dans un Secret et non dans le
# ConfigMap : une URL de webhook permet de publier n'importe quoi dans le canal.
apiVersion: v1
kind: Secret
metadata:
name: alertmanager-secrets
namespace: monitoring
labels:
app.kubernetes.io/part-of: xpeditis
type: Opaque
stringData:
discord-webhook: "https://discord.com/api/webhooks/REMPLACER"

View File

@ -0,0 +1,216 @@
# =============================================================================
# Backend NestJS
# =============================================================================
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: xpeditis-backend
namespace: xpeditis-prod
labels:
app.kubernetes.io/name: xpeditis-backend
app.kubernetes.io/component: api
app.kubernetes.io/part-of: xpeditis
spec:
replicas: 2
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
# Aucun pod retire avant qu'un remplacant ne soit pret : zero coupure.
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: xpeditis-backend
template:
metadata:
labels:
app.kubernetes.io/name: xpeditis-backend
app.kubernetes.io/component: api
app.kubernetes.io/part-of: xpeditis
annotations:
# Force le redemarrage des pods quand la configuration change : sans
# cela, modifier le ConfigMap ne produit aucun effet visible.
# La valeur est recalculee par scripts/deploy.sh.
xpeditis.com/config-checksum: "PLACEHOLDER"
spec:
imagePullSecrets:
- name: regcred
# 2 replicas sur 1 noeud : la contrainte est "preferred", sinon le second
# pod resterait indefiniment en Pending. Elle deviendra effective le jour
# ou un second noeud sera ajoute (phase 2 du plan de charge).
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: xpeditis-backend
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
seccompProfile:
type: RuntimeDefault
# 60 s : laisse le temps aux requetes en cours et aux connexions
# WebSocket de se fermer proprement.
terminationGracePeriodSeconds: 60
containers:
- name: backend
# Le tag est remplace au deploiement (kubectl set image).
image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:latest
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 4000
protocol: TCP
envFrom:
- configMapRef:
name: xpeditis-backend-config
- secretRef:
name: xpeditis-backend-secrets
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
# Le demarrage inclut l'attente de PostgreSQL puis la verification
# des migrations : la sonde de demarrage laisse jusqu'a 150 s avant
# de declarer le pod perdu, sans penaliser les redemarrages rapides.
startupProbe:
httpGet:
path: /api/v1/health/live
port: http
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 30
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /api/v1/health/live
port: http
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 3
# LIMITE CONNUE : /health/ready renvoie toujours "ready" sans tester
# PostgreSQL ni Redis (health.controller.ts). Un pod incapable de
# joindre la base sera donc declare pret. Correctif recommande apres
# la mise en ligne : @nestjs/terminus avec des indicateurs reels.
readinessProbe:
httpGet:
path: /api/v1/health/ready
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
cpu: 300m
memory: 512Mi
limits:
cpu: 1500m
memory: 1536Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
# NON active volontairement : le conteneur ecrit dans /app/logs et
# dans /app/src/infrastructure/storage/csv-storage/rates (chemin
# resolu au runtime par le chargeur de grilles CSV). Monter des
# emptyDir par-dessus masquerait les fichiers livres dans l'image.
readOnlyRootFilesystem: false
lifecycle:
preStop:
exec:
# Laisse Traefik retirer le pod de son pool avant que le
# processus ne commence a refuser des connexions.
command: ["sh", "-c", "sleep 10"]
---
apiVersion: v1
kind: Service
metadata:
name: xpeditis-backend
namespace: xpeditis-prod
labels:
app.kubernetes.io/name: xpeditis-backend
annotations:
# Sessions collantes : obligatoires pour Socket.IO. La negociation
# long-polling echoue si deux requetes d'un meme handshake atterrissent sur
# des replicas differents.
#
# ATTENTION -- limite non resolue par ce reglage : le gateway
# (notifications.gateway.ts) garde la carte userId -> sockets EN MEMOIRE et
# n'utilise pas @socket.io/redis-adapter. Une notification emise par le
# replica A n'atteint donc pas un utilisateur connecte au replica B.
# Cf. docs/mise-en-prod/15-exploitation-incidents.md, "Points de vigilance".
traefik.ingress.kubernetes.io/service.sticky.cookie: "true"
traefik.ingress.kubernetes.io/service.sticky.cookie.name: "xpd_be"
traefik.ingress.kubernetes.io/service.sticky.cookie.secure: "true"
traefik.ingress.kubernetes.io/service.sticky.cookie.httponly: "true"
traefik.ingress.kubernetes.io/service.sticky.cookie.samesite: "lax"
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: xpeditis-backend
ports:
- name: http
port: 4000
targetPort: http
protocol: TCP
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: xpeditis-backend
namespace: xpeditis-prod
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: xpeditis-backend
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: xpeditis-backend
namespace: xpeditis-prod
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: xpeditis-backend
minReplicas: 2
# Plafond a 4 : au-dela, le CPX41 sature. Passer a 3 noeuds avant d'augmenter
# (cf. 15-exploitation-incidents.md, section montee en charge).
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleUp:
stabilizationWindowSeconds: 60
scaleDown:
# Descente lente : evite de retirer un pod juste avant un nouveau pic.
stabilizationWindowSeconds: 600

View File

@ -0,0 +1,163 @@
# =============================================================================
# Frontend Next.js 14 (sortie standalone)
# =============================================================================
# RAPPEL CRITIQUE : NEXT_PUBLIC_API_URL est fige au moment du BUILD de l'image
# (next.config.js, bloc `env`). Une image construite pour la preprod pointera
# toujours vers api.preprod.xpeditis.com, quelles que soient les variables
# injectees ici. L'image du frontend doit donc etre RECONSTRUITE pour la prod,
# jamais promue depuis la preprod. Le workflow cd-main.yml applique cette regle.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: xpeditis-frontend
namespace: xpeditis-prod
labels:
app.kubernetes.io/name: xpeditis-frontend
app.kubernetes.io/component: web
app.kubernetes.io/part-of: xpeditis
spec:
replicas: 2
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: xpeditis-frontend
template:
metadata:
labels:
app.kubernetes.io/name: xpeditis-frontend
app.kubernetes.io/component: web
app.kubernetes.io/part-of: xpeditis
spec:
imagePullSecrets:
- name: regcred
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: xpeditis-frontend
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
seccompProfile:
type: RuntimeDefault
terminationGracePeriodSeconds: 30
containers:
- name: frontend
image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-frontend:latest
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 3000
protocol: TCP
env:
- name: NODE_ENV
value: "production"
- name: PORT
value: "3000"
- name: HOSTNAME
value: "0.0.0.0"
# Lues cote serveur uniquement (route handlers, server actions).
# Le code client, lui, a deja la valeur figee au build.
- name: NEXT_PUBLIC_API_URL
value: "https://api.xpeditis.com"
- name: NEXT_PUBLIC_APP_URL
value: "https://app.xpeditis.com"
- name: NEXT_TELEMETRY_DISABLED
value: "1"
startupProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 5
periodSeconds: 3
failureThreshold: 30
livenessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 3
resources:
requests:
cpu: 200m
memory: 384Mi
limits:
cpu: 1000m
memory: 1Gi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
# Le build standalone n'ecrit qu'en cache : on peut donc verrouiller
# la racine et n'ouvrir que les deux repertoires necessaires.
readOnlyRootFilesystem: true
volumeMounts:
- name: next-cache
mountPath: /app/.next/cache
- name: tmp
mountPath: /tmp
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"]
volumes:
- name: next-cache
emptyDir:
sizeLimit: 512Mi
- name: tmp
emptyDir:
sizeLimit: 128Mi
---
apiVersion: v1
kind: Service
metadata:
name: xpeditis-frontend
namespace: xpeditis-prod
labels:
app.kubernetes.io/name: xpeditis-frontend
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: xpeditis-frontend
ports:
- name: http
port: 3000
targetPort: http
protocol: TCP
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: xpeditis-frontend
namespace: xpeditis-prod
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: xpeditis-frontend

View File

@ -0,0 +1,73 @@
# =============================================================================
# Log exporter -- API interne qui pousse des logs structures vers Loki
# =============================================================================
# Jamais expose par l'Ingress : uniquement joignable depuis le namespace
# (le backend l'appelle via LOG_EXPORTER_URL).
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: xpeditis-log-exporter
namespace: xpeditis-prod
labels:
app.kubernetes.io/name: xpeditis-log-exporter
app.kubernetes.io/component: observability
app.kubernetes.io/part-of: xpeditis
spec:
replicas: 1
revisionHistoryLimit: 3
selector:
matchLabels:
app.kubernetes.io/name: xpeditis-log-exporter
template:
metadata:
labels:
app.kubernetes.io/name: xpeditis-log-exporter
app.kubernetes.io/part-of: xpeditis
spec:
imagePullSecrets:
- name: regcred
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
seccompProfile:
type: RuntimeDefault
containers:
- name: log-exporter
image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-log-exporter:latest
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 3200
env:
- name: PORT
value: "3200"
- name: LOKI_URL
value: "http://loki.monitoring.svc.cluster.local:3100"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 192Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
---
apiVersion: v1
kind: Service
metadata:
name: xpeditis-log-exporter
namespace: xpeditis-prod
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: xpeditis-log-exporter
ports:
- name: http
port: 3200
targetPort: http

View File

@ -0,0 +1,76 @@
# =============================================================================
# Migrations de base de donnees -- Job execute AVANT chaque deploiement
# =============================================================================
# Pourquoi un Job separe alors que l'image lance deja les migrations au
# demarrage (scripts/setup/startup.js) :
#
# Avec 2 replicas, deux pods appellent runMigrations() simultanement. TypeORM
# ne serialise pas les migrations entre processus : l'un des deux echoue et
# part en CrashLoopBackOff. En passant par un Job (parallelisme 1) execute
# avant le `kubectl set image`, les migrations sont deja appliquees quand les
# pods demarrent : startup.js constate "no pending migrations" et laisse la
# main a l'application. Le comportement de l'image reste inchange, il devient
# simplement un filet de securite.
#
# Le nom du Job porte le tag de l'image : chaque deploiement cree son propre
# Job, ce qui laisse une trace consultable (kubectl get jobs).
# Le tag et le nom sont substitues par scripts/deploy.sh / cd-main.yml.
apiVersion: batch/v1
kind: Job
metadata:
name: xpeditis-migrate-__IMAGE_TAG__
namespace: xpeditis-prod
labels:
app.kubernetes.io/name: xpeditis-migrate
app.kubernetes.io/part-of: xpeditis
spec:
# Les Jobs termines s'effacent au bout d'une heure : pas d'accumulation.
ttlSecondsAfterFinished: 3600
backoffLimit: 2
activeDeadlineSeconds: 900
parallelism: 1
completions: 1
template:
metadata:
labels:
app.kubernetes.io/name: xpeditis-migrate
spec:
restartPolicy: Never
imagePullSecrets:
- name: regcred
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
seccompProfile:
type: RuntimeDefault
containers:
- name: migrate
image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:__IMAGE_TAG__
imagePullPolicy: IfNotPresent
# CLI TypeORM sur la source de donnees compilee. Elle honore
# DATABASE_SSL, contrairement au client de secours de startup.js.
command:
- node
- ./node_modules/typeorm/cli.js
- migration:run
- -d
- dist/infrastructure/persistence/typeorm/data-source.js
envFrom:
- configMapRef:
name: xpeditis-backend-config
- secretRef:
name: xpeditis-backend-secrets
resources:
requests:
cpu: 200m
memory: 384Mi
limits:
cpu: 1000m
memory: 1Gi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: false

View File

@ -0,0 +1,148 @@
# =============================================================================
# Middlewares Traefik
# =============================================================================
# Reference dans les Ingress via l'annotation :
# traefik.ingress.kubernetes.io/router.middlewares:
# xpeditis-prod-<nom>@kubernetescrd
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: security-headers
namespace: xpeditis-prod
spec:
headers:
# HSTS : 1 an, sous-domaines inclus, eligible au preload.
# A n'activer qu'une fois certain que TOUS les sous-domaines sont en HTTPS,
# car la decision est irreversible cote navigateur pendant un an.
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
forceSTSHeader: true
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: "strict-origin-when-cross-origin"
customResponseHeaders:
# DENY et non SAMEORIGIN : l'application n'a aucun usage legitime d'une
# iframe, et c'est la meilleure protection contre le clickjacking.
X-Frame-Options: "DENY"
Permissions-Policy: "camera=(), microphone=(), geolocation=(), payment=(self), interest-cohort=()"
Cross-Origin-Opener-Policy: "same-origin"
Cross-Origin-Resource-Policy: "same-site"
# Masque la stack technique.
Server: ""
X-Powered-By: ""
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: rate-limit
namespace: xpeditis-prod
spec:
rateLimit:
# Limite de bordure, volontairement large : elle protege l'infrastructure.
# La limite metier fine reste celle de CustomThrottlerGuard cote NestJS.
average: 50
period: 1s
burst: 100
sourceCriterion:
ipStrategy:
# 1 = on prend l'avant-derniere IP de X-Forwarded-For, c'est-a-dire
# celle que Cloudflare a inscrite : la vraie IP du visiteur.
# Sans cela toutes les requetes seraient comptees sur l'IP Cloudflare
# et un seul abuseur ferait tomber la limite pour tout le monde.
depth: 1
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: rate-limit-auth
namespace: xpeditis-prod
spec:
rateLimit:
# Beaucoup plus stricte sur /api/v1/auth : connexion, inscription,
# reinitialisation de mot de passe, liens magiques transporteurs.
# 5 req/s en pointe suffit largement a un usage humain.
average: 5
period: 1s
burst: 10
sourceCriterion:
ipStrategy:
depth: 1
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: compress
namespace: xpeditis-prod
spec:
compress:
minResponseBodyBytes: 1024
excludedContentTypes:
- image/png
- image/jpeg
- image/webp
- application/pdf
---
# Chaine appliquee au trafic public standard.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: public-chain
namespace: xpeditis-prod
spec:
chain:
middlewares:
- name: security-headers
- name: rate-limit
- name: compress
---
# Chaine appliquee aux routes d'authentification.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: auth-chain
namespace: xpeditis-prod
spec:
chain:
middlewares:
- name: security-headers
- name: rate-limit-auth
- name: compress
---
# Restriction par IP, utilisee pour Grafana. Remplacez les valeurs par vos IP
# d'administration -- les memes que admin_ip_allowlist cote Terraform.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: admin-ip-allowlist
namespace: monitoring
spec:
ipAllowList:
sourceRange:
- 203.0.113.7/32
ipStrategy:
depth: 1
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: security-headers
namespace: monitoring
spec:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
contentTypeNosniff: true
referrerPolicy: "strict-origin-when-cross-origin"
customResponseHeaders:
X-Frame-Options: "DENY"

View File

@ -0,0 +1,128 @@
# =============================================================================
# Exposition publique
# =============================================================================
# Cartographie des domaines :
#
# xpeditis.com -> frontend (vitrine, pages publiques)
# www.xpeditis.com -> frontend
# app.xpeditis.com -> frontend (dashboard ; c'est l'origine des cookies)
# api.xpeditis.com -> backend NestJS
# grafana.xpeditis.com-> Grafana (voir k8s/monitoring/)
#
# Le certificat est un wildcard *.xpeditis.com + xpeditis.com, obtenu par
# cert-manager en DNS-01 Cloudflare (cf. 11-certificate.yaml).
# La redirection HTTP -> HTTPS est faite au niveau de l'entrypoint Traefik,
# aucun Ingress ne peut l'oublier.
---
# --- API : routes d'authentification (limitation stricte) --------------------
# Ingress separe et plus specifique que celui de l'API : Traefik privilegie la
# regle au chemin le plus long, cette limite s'applique donc bien en priorite.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: xpeditis-api-auth
namespace: xpeditis-prod
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.middlewares: xpeditis-prod-auth-chain@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- api.xpeditis.com
secretName: xpeditis-wildcard-tls
rules:
- host: api.xpeditis.com
http:
paths:
- path: /api/v1/auth
pathType: Prefix
backend:
service:
name: xpeditis-backend
port:
name: http
---
# --- API : tout le reste -----------------------------------------------------
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: xpeditis-api
namespace: xpeditis-prod
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.middlewares: xpeditis-prod-public-chain@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- api.xpeditis.com
secretName: xpeditis-wildcard-tls
rules:
- host: api.xpeditis.com
http:
paths:
# Couvre l'API REST, le webhook Stripe et le handshake Socket.IO
# (/socket.io/...), Traefik gerant l'upgrade WebSocket nativement.
- path: /
pathType: Prefix
backend:
service:
name: xpeditis-backend
port:
name: http
---
# --- Frontend ----------------------------------------------------------------
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: xpeditis-app
namespace: xpeditis-prod
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.middlewares: xpeditis-prod-public-chain@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- xpeditis.com
- www.xpeditis.com
- app.xpeditis.com
secretName: xpeditis-wildcard-tls
rules:
- host: app.xpeditis.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: xpeditis-frontend
port:
name: http
- host: www.xpeditis.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: xpeditis-frontend
port:
name: http
- host: xpeditis.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: xpeditis-frontend
port:
name: http

View File

@ -0,0 +1,239 @@
# =============================================================================
# Politiques reseau
# =============================================================================
# k3s applique nativement les NetworkPolicy (controleur kube-router embarque).
#
# Objectif : qu'un pod compromis ne puisse ni etre joint par n'importe qui, ni
# se servir du cluster comme point de rebond vers le reseau prive Hetzner.
# Sans ces regles, tout pod peut joindre tout pod ET toute IP privee.
---
# --- Refus par defaut, entrant ET sortant -----------------------------------
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: xpeditis-prod
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
---
# --- Resolution DNS (indispensable, sinon plus rien ne fonctionne) ----------
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: xpeditis-prod
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# --- Trafic entrant depuis l'ingress ----------------------------------------
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-from-traefik
namespace: xpeditis-prod
spec:
podSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values: [xpeditis-backend, xpeditis-frontend]
policyTypes: [Ingress]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: TCP
port: 4000
- protocol: TCP
port: 3000
---
# --- Le backend ecrit dans le log-exporter ----------------------------------
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-backend-to-log-exporter
namespace: xpeditis-prod
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: xpeditis-log-exporter
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: xpeditis-backend
ports:
- protocol: TCP
port: 3200
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-to-log-exporter
namespace: xpeditis-prod
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: xpeditis-backend
policyTypes: [Egress]
egress:
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: xpeditis-log-exporter
ports:
- protocol: TCP
port: 3200
---
# --- Le log-exporter pousse vers Loki ---------------------------------------
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-log-exporter-to-loki
namespace: xpeditis-prod
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: xpeditis-log-exporter
policyTypes: [Egress]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
ports:
- protocol: TCP
port: 3100
---
# --- Acces a PostgreSQL et Redis sur db-01 ----------------------------------
# Une seule IP, deux ports. Tout autre acces au reseau prive est refuse : un
# backend compromis ne peut pas scanner 10.10.0.0/16.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-to-data-node
namespace: xpeditis-prod
spec:
podSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values: [xpeditis-backend, xpeditis-migrate]
policyTypes: [Egress]
egress:
- to:
- ipBlock:
cidr: 10.10.1.20/32
ports:
- protocol: TCP
port: 5432
- protocol: TCP
port: 6379
---
# --- Sorties Internet (Stripe, Brevo, Object Storage, Pappers, carriers) ----
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-internet
namespace: xpeditis-prod
spec:
podSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values: [xpeditis-backend, xpeditis-frontend, xpeditis-migrate]
policyTypes: [Egress]
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
# Tout l'espace prive est exclu : la sortie ne sert qu'a joindre
# des services publics, jamais l'infrastructure interne.
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 169.254.0.0/16 # metadonnees cloud
ports:
- protocol: TCP
port: 443
- protocol: TCP
port: 80
- protocol: TCP
port: 587 # SMTP soumission (Brevo)
---
# --- Namespace monitoring ----------------------------------------------------
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: monitoring
spec:
podSelector: {}
policyTypes: [Ingress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-loki-writes
namespace: monitoring
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: loki
policyTypes: [Ingress]
ingress:
# Promtail (meme namespace) et le log-exporter applicatif.
- from:
- podSelector: {}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: xpeditis-prod
ports:
- protocol: TCP
port: 3100
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-grafana-from-traefik
namespace: monitoring
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: grafana
policyTypes: [Ingress]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: TCP
port: 3000

View File

@ -0,0 +1,59 @@
# =============================================================================
# Certificats TLS (Let's Encrypt via cert-manager, defi DNS-01 Cloudflare)
# =============================================================================
# Pourquoi DNS-01 et non HTTP-01 :
# - il fonctionne meme quand le proxy Cloudflare (nuage orange) est actif et
# que le firewall Hetzner n'accepte que les IP Cloudflare ;
# - il permet un certificat wildcard, donc un seul certificat pour tous les
# sous-domaines presents et futurs ;
# - il ne depend pas de la joignabilite du port 80.
#
# Un certificat est renouvele 30 jours avant expiration. Surveillez l'alerte
# "certificat expirant" (cf. k8s/monitoring/) : un renouvellement casse passe
# inapercu jusqu'au jour ou le site devient inaccessible.
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: xpeditis-wildcard
namespace: xpeditis-prod
spec:
secretName: xpeditis-wildcard-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
commonName: xpeditis.com
dnsNames:
# Le wildcard ne couvre PAS l'apex : les deux doivent etre listes.
- xpeditis.com
- "*.xpeditis.com"
duration: 2160h # 90 jours
renewBefore: 720h # 30 jours
privateKey:
algorithm: ECDSA
size: 256
# Nouvelle cle a chaque renouvellement : limite la fenetre d'exploitation
# d'une cle qui aurait fuite.
rotationPolicy: Always
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: grafana-tls
namespace: monitoring
spec:
secretName: grafana-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
commonName: grafana.xpeditis.com
dnsNames:
- grafana.xpeditis.com
duration: 2160h
renewBefore: 720h
privateKey:
algorithm: ECDSA
size: 256
rotationPolicy: Always

View File

@ -0,0 +1,56 @@
# =============================================================================
# Emetteurs ACME
# =============================================================================
# Prerequis : le Secret cert-manager/cloudflare-api-token doit exister
# (bash scripts/secrets-apply.sh).
#
# Le token Cloudflare doit avoir la permission MINIMALE :
# Zone > DNS > Edit, limite a la seule zone xpeditis.com.
# Un token global de compte donnerait a cert-manager le pouvoir de rediriger
# tout votre trafic.
---
# Emetteur de TEST. Ses certificats ne sont pas reconnus par les navigateurs,
# mais il n'a pas de quota serre : utilisez-le pour valider la chaine DNS-01
# AVANT de basculer sur l'emetteur de production.
# Let's Encrypt limite la production a 5 echecs/heure et 50 certificats/semaine
# par domaine : on ne debogue pas dessus.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: ops@xpeditis.com
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
# Adresse REELLE et surveillee : Let's Encrypt y envoie les avertissements
# d'expiration si le renouvellement automatique cesse de fonctionner.
email: ops@xpeditis.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
selector:
dnsZones:
- xpeditis.com

View File

@ -0,0 +1,172 @@
# =============================================================================
# Loki -- agregation des journaux
# =============================================================================
# Mode "single binary", stockage sur le disque local du noeud (local-path de
# k3s). Suffisant jusqu'a ~10 Go/mois de logs, ce qui couvre tres largement la
# phase 1. Au-dela, basculer le stockage sur Hetzner Object Storage.
---
apiVersion: v1
kind: ConfigMap
metadata:
name: loki-config
namespace: monitoring
data:
loki.yaml: |
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9096
log_level: warn
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
allow_structured_metadata: true
volume_enabled: true
# 31 jours : couvre l'analyse d'incident et reste sous le seuil ou les
# journaux applicatifs deviendraient une base de donnees personnelles
# a part entiere au sens du RGPD (cf. 16 dans la doc de mise en prod).
retention_period: 744h
reject_old_samples: true
reject_old_samples_max_age: 168h
ingestion_rate_mb: 8
ingestion_burst_size_mb: 16
max_entries_limit_per_query: 5000
max_query_series: 500
compactor:
working_directory: /loki/compactor
compaction_interval: 10m
retention_enabled: true
retention_delete_delay: 2h
retention_delete_worker_count: 50
delete_request_store: filesystem
query_range:
results_cache:
cache:
embedded_cache:
enabled: true
max_size_mb: 100
analytics:
reporting_enabled: false
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: loki-data
namespace: monitoring
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: loki
namespace: monitoring
labels:
app.kubernetes.io/name: loki
spec:
replicas: 1
# Un seul volume RWO : on remplace avant de recreer, jamais l'inverse.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: loki
template:
metadata:
labels:
app.kubernetes.io/name: loki
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: loki
image: grafana/loki:3.3.2
args: ["-config.file=/etc/loki/loki.yaml"]
ports:
- name: http
containerPort: 3100
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 30
periodSeconds: 10
livenessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 60
periodSeconds: 30
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /etc/loki
- name: data
mountPath: /loki
volumes:
- name: config
configMap:
name: loki-config
- name: data
persistentVolumeClaim:
claimName: loki-data
---
apiVersion: v1
kind: Service
metadata:
name: loki
namespace: monitoring
labels:
app.kubernetes.io/name: loki
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: loki
ports:
- name: http
port: 3100
targetPort: http

View File

@ -0,0 +1,185 @@
# =============================================================================
# Promtail -- collecte des journaux de conteneurs
# =============================================================================
# k3s utilise containerd, pas Docker : la decouverte se fait via l'API
# Kubernetes et la lecture de /var/log/pods, et non via le socket Docker comme
# en preprod.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: promtail
namespace: monitoring
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: promtail
rules:
# Lecture seule, strictement ce qu'exige la decouverte de pods.
- apiGroups: [""]
resources: [nodes, nodes/proxy, services, endpoints, pods]
verbs: [get, list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: promtail
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: promtail
subjects:
- kind: ServiceAccount
name: promtail
namespace: monitoring
---
apiVersion: v1
kind: ConfigMap
metadata:
name: promtail-config
namespace: monitoring
data:
promtail.yaml: |
server:
http_listen_port: 9080
log_level: warn
positions:
filename: /run/promtail/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
batchwait: 1s
batchsize: 1048576
timeout: 10s
scrape_configs:
- job_name: kubernetes-pods
kubernetes_sd_configs:
- role: pod
pipeline_stages:
# Format CRI de containerd : "<ts> <stream> <flags> <message>".
- cri: {}
- drop:
older_than: 15m
drop_counter_reason: entry_too_old
# Les sondes de sante representent l'essentiel du volume et n'ont
# aucune valeur d'analyse : on les jette avant ingestion.
- drop:
expression: '(GET /api/v1/health|GET /api/health|/ready|/metrics)'
drop_counter_reason: healthcheck
# Journaux pino du backend (LOG_FORMAT=json).
- json:
expressions:
level: level
msg: msg
context: context
reqId: reqId
- template:
source: level
template: >-
{{ if eq .Value "10" }}trace{{ else if eq .Value "20" }}debug{{ else if eq .Value "30" }}info{{ else if eq .Value "40" }}warn{{ else if eq .Value "50" }}error{{ else if eq .Value "60" }}fatal{{ else }}{{ .Value }}{{ end }}
- labels:
level:
context:
relabel_configs:
- source_labels: [__meta_kubernetes_pod_node_name]
target_label: node
- source_labels: [__meta_kubernetes_namespace]
target_label: namespace
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- source_labels: [__meta_kubernetes_pod_container_name]
target_label: container
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
target_label: service
# Chemin reel des journaux sur l'hote.
- source_labels: [__meta_kubernetes_pod_uid, __meta_kubernetes_pod_container_name]
target_label: __path__
separator: /
replacement: /var/log/pods/*$1/*.log
# On ne collecte que les namespaces utiles : pas de bruit kube-system
# hormis l'ingress.
- source_labels: [__meta_kubernetes_namespace]
regex: (xpeditis-prod|monitoring|kube-system)
action: keep
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: promtail
namespace: monitoring
labels:
app.kubernetes.io/name: promtail
spec:
selector:
matchLabels:
app.kubernetes.io/name: promtail
template:
metadata:
labels:
app.kubernetes.io/name: promtail
spec:
serviceAccountName: promtail
securityContext:
# Les journaux de /var/log/pods appartiennent a root : Promtail doit
# pouvoir les lire. C'est la raison pour laquelle le namespace
# monitoring est en "baseline" et non "restricted".
runAsUser: 0
seccompProfile:
type: RuntimeDefault
containers:
- name: promtail
image: grafana/promtail:3.3.2
args: ["-config.file=/etc/promtail/promtail.yaml"]
env:
- name: HOSTNAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
ports:
- name: http
containerPort: 9080
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 300m
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /etc/promtail
- name: positions
mountPath: /run/promtail
- name: pods
mountPath: /var/log/pods
readOnly: true
- name: containers
mountPath: /var/lib/rancher/k3s/agent/containerd
readOnly: true
volumes:
- name: config
configMap:
name: promtail-config
- name: positions
emptyDir: {}
- name: pods
hostPath:
path: /var/log/pods
- name: containers
hostPath:
path: /var/lib/rancher/k3s/agent/containerd
tolerations:
- effect: NoSchedule
operator: Exists

View File

@ -0,0 +1,351 @@
# =============================================================================
# Prometheus -- metriques et regles d'alerte
# =============================================================================
# Perimetre volontairement reduit : kubelet/cAdvisor, node-exporter, Traefik,
# cert-manager et postgres-exporter. Pas de kube-state-metrics ni d'operateur :
# a un noeud et une dizaine de pods, ils coutent plus de RAM qu'ils n'apportent.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: prometheus
namespace: monitoring
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: prometheus
rules:
- apiGroups: [""]
resources: [nodes, nodes/metrics, nodes/proxy, services, endpoints, pods]
verbs: [get, list, watch]
- nonResourceURLs: ["/metrics", "/metrics/cadvisor"]
verbs: [get]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: prometheus
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: prometheus
subjects:
- kind: ServiceAccount
name: prometheus
namespace: monitoring
---
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-config
namespace: monitoring
data:
prometheus.yml: |
global:
scrape_interval: 30s
evaluation_interval: 30s
external_labels:
cluster: xpeditis-prod
rule_files:
- /etc/prometheus/rules/*.yml
alerting:
alertmanagers:
- static_configs:
- targets: ['alertmanager:9093']
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ['localhost:9090']
- job_name: node-exporter
kubernetes_sd_configs:
- role: endpoints
relabel_configs:
- source_labels: [__meta_kubernetes_service_name]
regex: node-exporter
action: keep
# cAdvisor : consommation CPU/memoire par conteneur.
- job_name: kubelet-cadvisor
scheme: https
tls_config:
ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
insecure_skip_verify: true
bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
kubernetes_sd_configs:
- role: node
relabel_configs:
- action: labelmap
regex: __meta_kubernetes_node_label_(.+)
- target_label: __address__
replacement: kubernetes.default.svc:443
- source_labels: [__meta_kubernetes_node_name]
regex: (.+)
target_label: __metrics_path__
replacement: /api/v1/nodes/${1}/proxy/metrics/cadvisor
- job_name: traefik
kubernetes_sd_configs:
- role: pod
namespaces:
names: [kube-system]
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
regex: traefik
action: keep
- source_labels: [__address__]
regex: '(.+?)(?::\d+)?'
target_label: __address__
replacement: '${1}:9100'
- job_name: cert-manager
kubernetes_sd_configs:
- role: pod
namespaces:
names: [cert-manager]
relabel_configs:
- source_labels: [__meta_kubernetes_pod_container_port_number]
regex: "9402"
action: keep
# PostgreSQL : l'exportateur tourne sur db-01, hors du cluster.
- job_name: postgres
static_configs:
- targets: ['10.10.1.20:9187']
labels:
instance: xpeditis-prod-db-01
- job_name: loki
static_configs:
- targets: ['loki:3100']
rules.yml: |
groups:
- name: xpeditis-disponibilite
rules:
- alert: BackendIndisponible
# Traefik ne voit plus aucune instance saine derriere le service :
# l'API est hors ligne pour les utilisateurs, quoi que dise k8s.
expr: |
sum by (service) (traefik_service_server_up{service=~"xpeditis-prod-xpeditis-backend.*"}) == 0
for: 2m
labels:
severity: critique
annotations:
summary: "Aucune instance backend saine derriere l'ingress"
description: "L'API Xpeditis ne repond plus. Verifier : kubectl -n xpeditis-prod get pods"
- alert: FrontendIndisponible
expr: |
sum by (service) (traefik_service_server_up{service=~"xpeditis-prod-xpeditis-frontend.*"}) == 0
for: 2m
labels:
severity: critique
annotations:
summary: "Aucune instance frontend saine derriere l'ingress"
- alert: TauxErreur5xxEleve
expr: |
sum(rate(traefik_service_requests_total{code=~"5.."}[5m]))
/ clamp_min(sum(rate(traefik_service_requests_total[5m])), 0.001) > 0.05
for: 5m
labels:
severity: critique
annotations:
summary: "Plus de 5% de reponses 5xx"
description: "Taux d'erreur serveur anormal sur les 5 dernieres minutes."
- alert: LatenceElevee
expr: |
histogram_quantile(0.95,
sum(rate(traefik_service_request_duration_seconds_bucket[5m])) by (le, service)) > 2
for: 10m
labels:
severity: avertissement
annotations:
summary: "P95 au-dessus de 2 s sur {{ $labels.service }}"
- name: xpeditis-ressources
rules:
- alert: DisqueBientotPlein
# Un disque plein arrete PostgreSQL et k3s. Seuil a 15% restants
# pour laisser le temps d'intervenir.
expr: |
node_filesystem_avail_bytes{mountpoint="/",fstype!="tmpfs"}
/ node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"} < 0.15
for: 10m
labels:
severity: critique
annotations:
summary: "Moins de 15% d'espace disque libre sur {{ $labels.instance }}"
- alert: MemoireNoeudSaturee
expr: |
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.90
for: 10m
labels:
severity: avertissement
annotations:
summary: "Memoire du noeud a plus de 90%"
- alert: ConteneurRedemarreEnBoucle
expr: |
increase(container_start_time_seconds{namespace="xpeditis-prod"}[15m]) > 3
for: 5m
labels:
severity: critique
annotations:
summary: "Le conteneur {{ $labels.container }} redemarre en boucle"
- name: xpeditis-donnees
rules:
- alert: PostgresInjoignable
expr: pg_up == 0
for: 2m
labels:
severity: critique
annotations:
summary: "PostgreSQL ne repond plus sur db-01"
description: "Verifier db-01 : docker compose ps, journalctl -u docker"
- alert: ConnexionsPostgresSaturees
expr: |
sum(pg_stat_database_numbackends) / on() pg_settings_max_connections > 0.80
for: 5m
labels:
severity: avertissement
annotations:
summary: "Plus de 80% des connexions PostgreSQL consommees"
# NOTE -- la surveillance des sauvegardes ne passe PAS par Prometheus.
# Une alerte "la sauvegarde n'a pas tourne" evaluee par un composant
# heberge sur la meme machine ne se declenche pas quand la machine est
# eteinte, c'est-a-dire precisement quand elle serait utile.
# Elle repose donc sur un battement de coeur externe : pg-backup.sh
# appelle BACKUP_HEARTBEAT_URL a chaque succes, et le service externe
# (BetterStack / healthchecks.io) alerte en cas de silence.
# Cf. docs/mise-en-prod/12-sauvegardes-restauration.md.
- name: xpeditis-tls
rules:
- alert: CertificatBientotExpire
expr: |
(certmanager_certificate_expiration_timestamp_seconds - time()) / 86400 < 15
for: 1h
labels:
severity: critique
annotations:
summary: "Certificat {{ $labels.name }} expire dans moins de 15 jours"
description: "Le renouvellement automatique ne fonctionne plus. Verifier : kubectl describe certificate -A"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: prometheus-data
namespace: monitoring
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 15Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: prometheus
namespace: monitoring
labels:
app.kubernetes.io/name: prometheus
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: prometheus
template:
metadata:
labels:
app.kubernetes.io/name: prometheus
annotations:
# Redemarre Prometheus quand les regles changent.
checksum/config: "PLACEHOLDER"
spec:
serviceAccountName: prometheus
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: prometheus
image: prom/prometheus:v3.1.0
args:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
# 15 jours : suffisant pour l'analyse d'incident, tient dans 15 Go.
- --storage.tsdb.retention.time=15d
- --web.enable-lifecycle
- --web.listen-address=:9090
ports:
- name: http
containerPort: 9090
readinessProbe:
httpGet:
path: /-/ready
port: http
initialDelaySeconds: 20
livenessProbe:
httpGet:
path: /-/healthy
port: http
initialDelaySeconds: 60
periodSeconds: 30
resources:
requests:
cpu: 150m
memory: 512Mi
limits:
cpu: 1000m
memory: 1536Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /etc/prometheus/prometheus.yml
subPath: prometheus.yml
- name: config
mountPath: /etc/prometheus/rules/rules.yml
subPath: rules.yml
- name: data
mountPath: /prometheus
volumes:
- name: config
configMap:
name: prometheus-config
- name: data
persistentVolumeClaim:
claimName: prometheus-data
---
apiVersion: v1
kind: Service
metadata:
name: prometheus
namespace: monitoring
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: prometheus
ports:
- name: http
port: 9090
targetPort: http

View File

@ -0,0 +1,95 @@
# =============================================================================
# node-exporter -- metriques systeme du noeud
# =============================================================================
# Presence justifiee par une seule alerte, mais la plus importante de toutes :
# le disque plein. Un disque sature arrete PostgreSQL, k3s et les sauvegardes
# en meme temps, et c'est la panne la plus frequente d'un serveur laisse seul.
#
# Port 9101 et non 9100 : 9100 est deja l'entrypoint metrics de Traefik, et
# confondre les deux cibles rend le diagnostic penible.
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: node-exporter
spec:
selector:
matchLabels:
app.kubernetes.io/name: node-exporter
template:
metadata:
labels:
app.kubernetes.io/name: node-exporter
spec:
hostNetwork: true
hostPID: true
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: node-exporter
image: prom/node-exporter:v1.8.2
args:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/host/root
- --web.listen-address=:9101
- --collector.filesystem.mount-points-exclude=^/(dev|proc|sys|var/lib/docker/.+|var/lib/kubelet/.+|var/lib/rancher/.+)($|/)
ports:
- name: metrics
containerPort: 9101
hostPort: 9101
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: proc
mountPath: /host/proc
readOnly: true
- name: sys
mountPath: /host/sys
readOnly: true
- name: root
mountPath: /host/root
mountPropagation: HostToContainer
readOnly: true
volumes:
- name: proc
hostPath: { path: /proc }
- name: sys
hostPath: { path: /sys }
- name: root
hostPath: { path: / }
tolerations:
- effect: NoSchedule
operator: Exists
---
apiVersion: v1
kind: Service
metadata:
name: node-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: node-exporter
spec:
clusterIP: None
selector:
app.kubernetes.io/name: node-exporter
ports:
- name: metrics
port: 9101
targetPort: metrics

View File

@ -0,0 +1,150 @@
# =============================================================================
# Alertmanager -- routage des alertes vers Discord
# =============================================================================
# L'URL du webhook n'est PAS dans ce ConfigMap : elle est lue depuis un fichier
# monte depuis un Secret (webhook_url_file). Un ConfigMap est lisible par tout
# ce qui a un acces lecture au namespace, un webhook Discord permet de publier
# n'importe quoi dans votre canal d'exploitation.
---
apiVersion: v1
kind: ConfigMap
metadata:
name: alertmanager-config
namespace: monitoring
data:
alertmanager.yml: |
global:
resolve_timeout: 5m
route:
receiver: discord
group_by: ['alertname', 'severity']
group_wait: 30s
group_interval: 5m
# Une alerte critique non traitee est repetee toutes les heures : elle ne
# doit pas disparaitre du fil de discussion.
repeat_interval: 1h
routes:
- matchers:
- severity = "avertissement"
receiver: discord
repeat_interval: 12h
inhibit_rules:
# Si le backend est totalement indisponible, inutile d'inonder le canal
# avec les alertes de latence et de taux d'erreur qui en decoulent.
- source_matchers:
- alertname = "BackendIndisponible"
target_matchers:
- severity =~ "avertissement|critique"
equal: ['cluster']
receivers:
- name: discord
discord_configs:
- webhook_url_file: /etc/alertmanager/secrets/discord-webhook
send_resolved: true
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}'
message: |-
{{ range .Alerts }}{{ .Annotations.summary }}
{{ .Annotations.description }}
{{ end }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: alertmanager-data
namespace: monitoring
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: alertmanager
namespace: monitoring
labels:
app.kubernetes.io/name: alertmanager
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: alertmanager
template:
metadata:
labels:
app.kubernetes.io/name: alertmanager
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: alertmanager
image: prom/alertmanager:v0.28.0
args:
- --config.file=/etc/alertmanager/alertmanager.yml
- --storage.path=/alertmanager
- --web.listen-address=:9093
ports:
- name: http
containerPort: 9093
readinessProbe:
httpGet:
path: /-/ready
port: http
initialDelaySeconds: 10
resources:
requests:
cpu: 20m
memory: 64Mi
limits:
cpu: 200m
memory: 192Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /etc/alertmanager/alertmanager.yml
subPath: alertmanager.yml
- name: secrets
mountPath: /etc/alertmanager/secrets
readOnly: true
- name: data
mountPath: /alertmanager
volumes:
- name: config
configMap:
name: alertmanager-config
- name: secrets
secret:
secretName: alertmanager-secrets
- name: data
persistentVolumeClaim:
claimName: alertmanager-data
---
apiVersion: v1
kind: Service
metadata:
name: alertmanager
namespace: monitoring
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: alertmanager
ports:
- name: http
port: 9093
targetPort: http

View File

@ -0,0 +1,219 @@
# =============================================================================
# Grafana -- tableaux de bord (logs Loki + metriques Prometheus)
# =============================================================================
# Expose sur grafana.xpeditis.com, protege par TROIS couches :
# 1. le firewall Hetzner (seules les IP Cloudflare atteignent le serveur)
# 2. le middleware Traefik admin-ip-allowlist (vos IP d'administration)
# 3. l'authentification Grafana (Secret grafana-admin)
# L'inscription et l'acces anonyme sont desactives.
---
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-provisioning
namespace: monitoring
data:
datasources.yaml: |
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
url: http://loki:3100
isDefault: false
jsonData:
maxLines: 2000
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
jsonData:
timeInterval: 30s
dashboards.yaml: |
apiVersion: 1
providers:
- name: xpeditis
orgId: 1
folder: Xpeditis
type: file
disableDeletion: false
updateIntervalSeconds: 60
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: grafana-data
namespace: monitoring
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: grafana
namespace: monitoring
labels:
app.kubernetes.io/name: grafana
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: grafana
template:
metadata:
labels:
app.kubernetes.io/name: grafana
spec:
securityContext:
runAsNonRoot: true
runAsUser: 472
runAsGroup: 472
fsGroup: 472
seccompProfile:
type: RuntimeDefault
containers:
- name: grafana
image: grafana/grafana:11.4.0
ports:
- name: http
containerPort: 3000
env:
- name: GF_SECURITY_ADMIN_USER
valueFrom:
secretKeyRef:
name: grafana-admin
key: admin-user
- name: GF_SECURITY_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: grafana-admin
key: admin-password
- name: GF_SERVER_ROOT_URL
value: "https://grafana.xpeditis.com"
- name: GF_USERS_ALLOW_SIGN_UP
value: "false"
- name: GF_AUTH_ANONYMOUS_ENABLED
value: "false"
# Empeche l'integration de Grafana dans une iframe tierce.
- name: GF_SECURITY_ALLOW_EMBEDDING
value: "false"
- name: GF_SECURITY_COOKIE_SECURE
value: "true"
- name: GF_SECURITY_COOKIE_SAMESITE
value: "strict"
- name: GF_SECURITY_STRICT_TRANSPORT_SECURITY
value: "true"
- name: GF_ANALYTICS_REPORTING_ENABLED
value: "false"
- name: GF_ANALYTICS_CHECK_FOR_UPDATES
value: "false"
# Les alertes sont evaluees par Prometheus et routees par
# Alertmanager : l'alerting Grafana ferait doublon.
- name: GF_UNIFIED_ALERTING_ENABLED
value: "false"
- name: GF_ALERTING_ENABLED
value: "false"
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 20
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 60
periodSeconds: 30
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: provisioning-datasources
mountPath: /etc/grafana/provisioning/datasources
- name: provisioning-dashboards
mountPath: /etc/grafana/provisioning/dashboards
- name: dashboards
mountPath: /var/lib/grafana/dashboards
- name: data
mountPath: /var/lib/grafana
volumes:
- name: provisioning-datasources
configMap:
name: grafana-provisioning
items:
- key: datasources.yaml
path: datasources.yaml
- name: provisioning-dashboards
configMap:
name: grafana-provisioning
items:
- key: dashboards.yaml
path: dashboards.yaml
# Cree par scripts/deploy-monitoring.sh depuis infra/logging/grafana/.
- name: dashboards
configMap:
name: grafana-dashboards
optional: true
- name: data
persistentVolumeClaim:
claimName: grafana-data
---
apiVersion: v1
kind: Service
metadata:
name: grafana
namespace: monitoring
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: grafana
ports:
- name: http
port: 3000
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: grafana
namespace: monitoring
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.middlewares: monitoring-admin-ip-allowlist@kubernetescrd,monitoring-security-headers@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- grafana.xpeditis.com
secretName: grafana-tls
rules:
- host: grafana.xpeditis.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: grafana
port:
name: http

View File

@ -0,0 +1,227 @@
#!/usr/bin/env bash
# =============================================================================
# 00 - Durcissement commun aux deux noeuds (app-01 et db-01)
# =============================================================================
# A executer EN PREMIER sur chaque serveur, en sudo :
# scp infra/prod/scripts/00-bootstrap-common.sh deploy@<ip>:/tmp/
# ssh deploy@<ip> 'sudo bash /tmp/00-bootstrap-common.sh <role>'
#
# <role> = app | data
#
# Idempotent : peut etre relance sans risque.
set -euo pipefail
ROLE="${1:-}"
if [[ "$ROLE" != "app" && "$ROLE" != "data" ]]; then
echo "Usage: $0 <app|data>" >&2
exit 1
fi
if [[ "$EUID" -ne 0 ]]; then
echo "Ce script doit tourner en root (sudo)." >&2
exit 1
fi
log() { printf '\n>>> %s\n' "$*"; }
# --- 1. Paquets de base ------------------------------------------------------
log "Mise a jour du systeme"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get upgrade -y -qq
apt-get install -y -qq \
ufw fail2ban unattended-upgrades apt-listchanges \
chrony auditd audispd-plugins \
curl gnupg ca-certificates jq git rsync htop ncdu \
needrestart
# --- 2. Mises a jour de securite automatiques --------------------------------
log "Activation des mises a jour de securite automatiques"
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'CONF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
CONF
cat > /etc/apt/apt.conf.d/50unattended-upgrades <<'CONF'
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
};
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
// Redemarrage automatique la nuit SI un paquet noyau l'exige.
// Fenetre choisie hors des heures ouvrees des transitaires europeens.
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-WithUsers "false";
Unattended-Upgrade::Automatic-Reboot-Time "04:30";
Unattended-Upgrade::Mail "";
CONF
systemctl enable --now unattended-upgrades
# --- 3. SSH ------------------------------------------------------------------
log "Durcissement SSH"
cat > /etc/ssh/sshd_config.d/99-xpeditis-hardening.conf <<'CONF'
# Authentification par cle uniquement.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PermitEmptyPasswords no
PubkeyAuthentication yes
AuthenticationMethods publickey
# Reduction de surface.
X11Forwarding no
AllowAgentForwarding no
PermitTunnel no
GatewayPorts no
# Anti brute-force / sessions fantomes.
MaxAuthTries 3
MaxSessions 5
LoginGraceTime 20
ClientAliveInterval 300
ClientAliveCountMax 2
# Cryptographie moderne uniquement.
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
Macs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
AllowUsers deploy
CONF
# Retire les cles d'hote faibles si presentes.
rm -f /etc/ssh/ssh_host_dsa_key* /etc/ssh/ssh_host_ecdsa_key* || true
sshd -t
systemctl restart ssh 2>/dev/null || systemctl restart sshd
# --- 4. fail2ban -------------------------------------------------------------
log "Configuration fail2ban"
cat > /etc/fail2ban/jail.d/xpeditis.local <<'CONF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 4
backend = systemd
# Ne jamais se bannir soi-meme depuis le reseau prive.
ignoreip = 127.0.0.1/8 ::1 10.10.0.0/16
[sshd]
enabled = true
mode = aggressive
maxretry = 3
bantime = 24h
CONF
systemctl enable --now fail2ban
systemctl restart fail2ban
# --- 5. Parametres noyau -----------------------------------------------------
log "Durcissement sysctl"
cat > /etc/sysctl.d/99-xpeditis-hardening.conf <<'CONF'
# Reseau
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.all.log_martians = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.tcp_syncookies = 1
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_source_route = 0
# Memoire / noyau
kernel.randomize_va_space = 2
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.yama.ptrace_scope = 1
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.suid_dumpable = 0
# Capacite : k3s et PostgreSQL ouvrent beaucoup de descripteurs.
fs.file-max = 2097152
fs.inotify.max_user_instances = 8192
fs.inotify.max_user_watches = 524288
CONF
sysctl --system >/dev/null
# --- 6. Journalisation -------------------------------------------------------
log "Limitation des journaux systemd (evite de saturer le disque)"
mkdir -p /etc/systemd/journald.conf.d
cat > /etc/systemd/journald.conf.d/99-xpeditis.conf <<'CONF'
[Journal]
SystemMaxUse=2G
SystemMaxFileSize=200M
MaxRetentionSec=30day
Compress=yes
CONF
systemctl restart systemd-journald
# --- 7. Horloge --------------------------------------------------------------
log "Synchronisation horaire (obligatoire : JWT, TLS, audit_logs)"
timedatectl set-timezone Europe/Paris
systemctl enable --now chrony
# --- 8. Audit ----------------------------------------------------------------
log "Regles auditd minimales"
cat > /etc/audit/rules.d/99-xpeditis.rules <<'CONF'
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/ssh/sshd_config -p wa -k sshd
-w /etc/ssh/sshd_config.d/ -p wa -k sshd
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d/ -p wa -k sudoers
-w /var/log/auth.log -p wa -k authlog
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=4294967295 -k rootcmd
CONF
augenrules --load >/dev/null 2>&1 || true
systemctl enable --now auditd
# --- 9. Pare-feu local -------------------------------------------------------
# Le firewall Hetzner Cloud ne filtre QUE les interfaces publiques. UFW prend
# en charge le reseau prive, ou transite le trafic PostgreSQL/Redis.
log "Configuration UFW (role: $ROLE)"
ufw --force reset >/dev/null
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp comment 'SSH'
if [[ "$ROLE" == "app" ]]; then
ufw allow 80/tcp comment 'HTTP (redirection + ACME)'
ufw allow 443/tcp comment 'HTTPS'
ufw allow 6443/tcp comment 'API k3s'
# Reseau prive : le noeud app doit joindre db-01, pas l'inverse.
ufw allow from 10.10.0.0/16 to any port 10250 proto tcp comment 'kubelet metrics'
else
# Seul app-01 (IP privee) peut atteindre PostgreSQL et Redis.
APP_PRIVATE_IP="${APP_PRIVATE_IP:-10.10.1.10}"
ufw allow from "${APP_PRIVATE_IP}" to any port 5432 proto tcp comment 'PostgreSQL <- app-01'
ufw allow from "${APP_PRIVATE_IP}" to any port 6379 proto tcp comment 'Redis <- app-01'
fi
ufw --force enable
ufw status verbose
# --- 10. Verifications finales ----------------------------------------------
log "Verifications"
echo " SSH root login : $(sshd -T 2>/dev/null | grep -i '^permitrootlogin' || echo '?')"
echo " Password auth : $(sshd -T 2>/dev/null | grep -i '^passwordauthentication' || echo '?')"
echo " fail2ban : $(systemctl is-active fail2ban)"
echo " unattended-upgr. : $(systemctl is-active unattended-upgrades)"
echo " auditd : $(systemctl is-active auditd)"
echo " chrony : $(systemctl is-active chrony)"
log "Durcissement commun termine pour le role '$ROLE'."
echo "Etape suivante :"
if [[ "$ROLE" == "app" ]]; then
echo " sudo bash 02-setup-k3s-server.sh"
else
echo " sudo bash 01-setup-data-node.sh"
fi

View File

@ -0,0 +1,170 @@
#!/usr/bin/env bash
# =============================================================================
# 01 - Installation du noeud de donnees (db-01)
# =============================================================================
# Prerequis : 00-bootstrap-common.sh data deja execute sur ce serveur.
#
# sudo bash 01-setup-data-node.sh
#
# Ce script :
# 1. monte le volume Hetzner sur /var/lib/xpeditis/pgdata
# 2. installe Docker Engine depuis le depot officiel
# 3. genere le certificat TLS interne de PostgreSQL
# 4. installe age (chiffrement des dumps)
# 5. installe les unites systemd de sauvegarde
#
# Il ne demarre PAS la base : il faut d'abord deposer .env.data (SOPS).
set -euo pipefail
[[ "$EUID" -eq 0 ]] || { echo "Executer en root (sudo)." >&2; exit 1; }
APP_PRIVATE_IP="${APP_PRIVATE_IP:-10.10.1.10}"
DB_PRIVATE_IP="${DB_PRIVATE_IP:-10.10.1.20}"
BASE_DIR=/opt/xpeditis/data-node
DATA_ROOT=/var/lib/xpeditis
log() { printf '\n>>> %s\n' "$*"; }
# --- 1. Volume de donnees ----------------------------------------------------
log "Montage du volume PostgreSQL"
mkdir -p "${DATA_ROOT}"/{pgdata,redis,certs,dumps}
# Hetzner expose les volumes sous /dev/disk/by-id/scsi-0HC_Volume_<id>.
VOLUME_DEV="$(ls /dev/disk/by-id/scsi-0HC_Volume_* 2>/dev/null | head -1 || true)"
if [[ -n "$VOLUME_DEV" ]]; then
if ! blkid "$VOLUME_DEV" >/dev/null 2>&1; then
log "Formatage de $VOLUME_DEV en ext4 (volume vierge)"
mkfs.ext4 -F -L xpeditis-pgdata "$VOLUME_DEV"
else
log "Volume deja formate, conservation des donnees existantes"
fi
if ! grep -q "${DATA_ROOT}/pgdata" /etc/fstab; then
# `nofail` : si le volume est absent au boot, le serveur demarre quand meme
# et on diagnostique en SSH plutot que de se retrouver hors ligne.
echo "${VOLUME_DEV} ${DATA_ROOT}/pgdata ext4 discard,nofail,defaults 0 2" >> /etc/fstab
fi
mount -a
df -h "${DATA_ROOT}/pgdata"
else
echo "AVERTISSEMENT: aucun volume Hetzner detecte. Les donnees resteront sur le disque systeme."
fi
# L'UID/GID 999 est celui de l'utilisateur postgres dans l'image officielle.
chown -R 999:999 "${DATA_ROOT}/pgdata" "${DATA_ROOT}/redis" "${DATA_ROOT}/dumps"
chmod 700 "${DATA_ROOT}/pgdata"
# --- 2. Docker Engine --------------------------------------------------------
if ! command -v docker >/dev/null; then
log "Installation de Docker Engine (depot officiel)"
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
fi
log "Durcissement du demon Docker"
cat > /etc/docker/daemon.json <<'JSON'
{
"log-driver": "json-file",
"log-opts": { "max-size": "50m", "max-file": "5" },
"live-restore": true,
"userland-proxy": false,
"no-new-privileges": true,
"icc": false,
"default-address-pools": [
{ "base": "172.28.0.0/16", "size": 24 }
]
}
JSON
systemctl enable --now docker
systemctl restart docker
# --- 3. Certificat TLS de PostgreSQL ----------------------------------------
# Certificat auto-signe : PostgreSQL n'est joignable que depuis app-01 sur un
# reseau prive, il n'y a pas de tiers a authentifier. Ce certificat sert a
# CHIFFRER le transport, pas a prouver une identite publique.
# Cote client, DATABASE_SSL=true avec rejectUnauthorized:false accepte ce
# certificat : c'est coherent, et documente dans 04-noeud-donnees.md.
if [[ ! -f "${DATA_ROOT}/certs/server.key" ]]; then
log "Generation du certificat TLS PostgreSQL (10 ans)"
openssl req -new -x509 -days 3650 -nodes \
-newkey rsa:4096 \
-out "${DATA_ROOT}/certs/server.crt" \
-keyout "${DATA_ROOT}/certs/server.key" \
-subj "/CN=xpeditis-prod-db-01/O=Xpeditis/C=FR" \
-addext "subjectAltName=IP:${DB_PRIVATE_IP},DNS:postgres"
# PostgreSQL refuse de demarrer si la cle privee est lisible par d'autres.
chown 999:999 "${DATA_ROOT}/certs/server.key" "${DATA_ROOT}/certs/server.crt"
chmod 600 "${DATA_ROOT}/certs/server.key"
chmod 644 "${DATA_ROOT}/certs/server.crt"
else
log "Certificat TLS deja present, conserve"
fi
# --- 4. age (chiffrement des dumps) -----------------------------------------
if ! command -v age >/dev/null; then
log "Installation de age"
apt-get install -y -qq age
fi
mkdir -p /root/.config/xpeditis
chmod 700 /root/.config/xpeditis
# --- 5. Arborescence applicative --------------------------------------------
log "Preparation de ${BASE_DIR}"
mkdir -p "${BASE_DIR}"/{conf,backup}
chmod 750 "${BASE_DIR}"
# --- 6. Unites systemd de sauvegarde ----------------------------------------
if [[ -f "${BASE_DIR}/backup/xpeditis-backup.service" ]]; then
log "Installation des timers de sauvegarde"
install -m 0644 "${BASE_DIR}/backup/xpeditis-backup.service" /etc/systemd/system/
install -m 0644 "${BASE_DIR}/backup/xpeditis-backup.timer" /etc/systemd/system/
install -m 0644 "${BASE_DIR}/backup/xpeditis-backup-verify.service" /etc/systemd/system/
install -m 0644 "${BASE_DIR}/backup/xpeditis-backup-verify.timer" /etc/systemd/system/
chmod +x "${BASE_DIR}/backup/"*.sh
systemctl daemon-reload
systemctl enable --now xpeditis-backup.timer xpeditis-backup-verify.timer
systemctl list-timers 'xpeditis-*' --no-pager
else
echo "AVERTISSEMENT: ${BASE_DIR}/backup/ vide. Copiez infra/prod/data-node/ puis relancez ce script."
fi
# --- 7. Rappel du filtrage reseau -------------------------------------------
log "Regles UFW effectives"
ufw status numbered
cat <<NEXT
=============================================================================
Noeud de donnees pret.
=============================================================================
Etapes suivantes (depuis votre poste) :
1. Copier la configuration :
rsync -a infra/prod/data-node/ deploy@${DB_PRIVATE_IP}:/tmp/data-node/
ssh deploy@<ip-publique-db> 'sudo rsync -a /tmp/data-node/ ${BASE_DIR}/'
2. Dechiffrer et deposer les secrets :
sops -d --input-type dotenv --output-type dotenv \\
infra/prod/data-node/data-node.sops.env > /tmp/.env.data
scp /tmp/.env.data deploy@<ip-publique-db>:/tmp/
ssh deploy@<ip> 'sudo install -m600 -o root -g root /tmp/.env.data ${BASE_DIR}/.env.data && shred -u /tmp/.env.data'
3. Demarrer :
cd ${BASE_DIR} && sudo docker compose -f docker-compose.data.yml --env-file .env.data up -d --build
4. Premiere sauvegarde complete (obligatoire avant d'ouvrir au public) :
sudo systemctl start xpeditis-backup.service
sudo journalctl -u xpeditis-backup -f
Verifier que rien n'est expose publiquement, depuis un autre reseau :
nmap -Pn -p 5432,6379 <ip-publique-db> # doit repondre filtered
=============================================================================
NEXT

View File

@ -0,0 +1,245 @@
#!/usr/bin/env bash
# =============================================================================
# 02 - Installation du cluster k3s (app-01)
# =============================================================================
# Prerequis : 00-bootstrap-common.sh app deja execute sur ce serveur.
#
# sudo K3S_VERSION=v1.31.5+k3s1 PUBLIC_IP=<ip> bash 02-setup-k3s-server.sh
#
# Choix structurants et pourquoi :
# --secrets-encryption les Secrets sont chiffres au repos dans la base
# d'etat de k3s. Sans ce drapeau, un acces disque
# (snapshot, vol de volume) livre tous les secrets.
# --protect-kernel-defaults refuse de demarrer si les sysctl attendus par
# kubelet ne sont pas poses : echec bruyant plutot
# que derive silencieuse.
# audit-log journal d'audit de l'API : indispensable pour
# repondre a "qui a supprime ce deploiement".
# Traefik est CONSERVE (celui livre par k3s) et reconfigure via
# HelmChartConfig : entrypoints, en-tetes de
# securite, IPs de confiance Cloudflare.
set -euo pipefail
[[ "$EUID" -eq 0 ]] || { echo "Executer en root (sudo)." >&2; exit 1; }
K3S_VERSION="${K3S_VERSION:-v1.31.5+k3s1}"
PUBLIC_IP="${PUBLIC_IP:-$(curl -s --max-time 5 https://ifconfig.me || true)}"
PRIVATE_IP="${PRIVATE_IP:-10.10.1.10}"
MANIFEST_DIR=/var/lib/rancher/k3s/server/manifests
log() { printf '\n>>> %s\n' "$*"; }
[[ -n "$PUBLIC_IP" ]] || { echo "PUBLIC_IP introuvable, passez-la en variable." >&2; exit 1; }
# --- 1. Prerequis noyau ------------------------------------------------------
log "Parametres noyau exiges par kubelet (--protect-kernel-defaults)"
cat > /etc/sysctl.d/90-kubelet.conf <<'CONF'
vm.panic_on_oom=0
vm.overcommit_memory=1
kernel.panic=10
kernel.panic_on_oops=1
CONF
sysctl --system >/dev/null
log "Desactivation du swap (exige par kubelet)"
swapoff -a || true
sed -i '/\sswap\s/s/^/#/' /etc/fstab || true
# --- 2. Politique d'audit de l'API Kubernetes -------------------------------
log "Politique d'audit"
mkdir -p /var/lib/rancher/k3s/server /var/log/k3s
cat > /var/lib/rancher/k3s/server/audit-policy.yaml <<'YAML'
apiVersion: audit.k8s.io/v1
kind: Policy
# Ne jamais journaliser le contenu des Secrets : le journal d'audit deviendrait
# lui-meme un coffre-fort en clair.
omitStages:
- RequestReceived
rules:
- level: None
resources:
- group: ""
resources: ["secrets", "configmaps"]
verbs: ["get", "list", "watch"]
# Bruit de fond : sondes et lectures d'etat.
- level: None
users: ["system:kube-proxy", "system:apiserver"]
verbs: ["watch", "list"]
- level: None
nonResourceURLs: ["/healthz*", "/readyz*", "/livez*", "/version", "/metrics"]
# Toute ecriture est tracee avec ses metadonnees (qui, quoi, quand).
- level: Metadata
verbs: ["create", "update", "patch", "delete", "deletecollection"]
# Acces aux Secrets : trace, sans le contenu.
- level: Metadata
resources:
- group: ""
resources: ["secrets"]
# Escalades de privileges : trace complet cote requete.
- level: Request
resources:
- group: "rbac.authorization.k8s.io"
resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
- level: Metadata
YAML
chmod 600 /var/lib/rancher/k3s/server/audit-policy.yaml
# --- 3. Configuration Traefik (avant l'installation : k3s l'applique au boot) -
log "Configuration Traefik (HelmChartConfig)"
mkdir -p "$MANIFEST_DIR"
cat > "${MANIFEST_DIR}/traefik-config.yaml" <<'YAML'
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
# Les vraies IP des visiteurs arrivent dans X-Forwarded-For, pose par
# Cloudflare. Sans cette liste de confiance, la limitation de debit et les
# audit_logs verraient tous l'IP de Cloudflare : inexploitable.
# Rafraichir avec scripts/refresh-cloudflare-ips.sh.
additionalArguments:
- "--entrypoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,2400:cb00::/32,2606:4700::/32,2803:f800::/32,2405:b500::/32,2405:8100::/32,2a06:98c0::/29,2c0f:f248::/32"
- "--entrypoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,2400:cb00::/32,2606:4700::/32,2803:f800::/32,2405:b500::/32,2405:8100::/32,2a06:98c0::/29,2c0f:f248::/32"
# Tout le trafic HTTP est redirige en HTTPS au niveau de l'entrypoint :
# aucun Ingress ne peut oublier de le faire.
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--entrypoints.web.http.redirections.entryPoint.permanent=true"
# Delais de garde : borne les connexions lentes (slowloris).
- "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=60s"
- "--entrypoints.websecure.transport.respondingTimeouts.writeTimeout=0s"
- "--entrypoints.websecure.transport.respondingTimeouts.idleTimeout=180s"
- "--serversTransport.maxIdleConnsPerHost=100"
# WebSocket (Socket.IO) : pas de timeout d'ecriture, sinon les
# notifications temps reel sont coupees toutes les 60 s.
- "--metrics.prometheus=true"
- "--metrics.prometheus.addEntryPointsLabels=true"
- "--metrics.prometheus.addServicesLabels=true"
- "--accesslog=true"
- "--accesslog.format=json"
- "--accesslog.fields.headers.defaultMode=drop"
- "--accesslog.fields.headers.names.User-Agent=keep"
- "--accesslog.fields.headers.names.Cf-Connecting-Ip=keep"
- "--ping=true"
# Le tableau de bord Traefik n'est jamais expose.
ingressRoute:
dashboard:
enabled: false
logs:
general:
level: WARN
access:
enabled: true
# Le service est en LoadBalancer (klipper-lb de k3s) : il ecoute
# directement sur les ports 80/443 de l'hote, filtres par le firewall
# Hetzner qui n'accepte que les IP Cloudflare.
service:
spec:
externalTrafficPolicy: Local
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 512Mi
YAML
# --- 4. Installation de k3s --------------------------------------------------
if ! command -v k3s >/dev/null; then
log "Installation de k3s ${K3S_VERSION}"
curl -sfL https://get.k3s.io | \
INSTALL_K3S_VERSION="${K3S_VERSION}" \
INSTALL_K3S_EXEC="server \
--node-name=xpeditis-prod-app-01 \
--node-ip=${PRIVATE_IP} \
--advertise-address=${PRIVATE_IP} \
--tls-san=${PUBLIC_IP} \
--tls-san=${PRIVATE_IP} \
--write-kubeconfig-mode=0600 \
--secrets-encryption \
--protect-kernel-defaults \
--kube-apiserver-arg=audit-log-path=/var/log/k3s/audit.log \
--kube-apiserver-arg=audit-policy-file=/var/lib/rancher/k3s/server/audit-policy.yaml \
--kube-apiserver-arg=audit-log-maxage=30 \
--kube-apiserver-arg=audit-log-maxbackup=10 \
--kube-apiserver-arg=audit-log-maxsize=100 \
--kube-apiserver-arg=request-timeout=300s \
--kubelet-arg=streaming-connection-idle-timeout=5m \
--kubelet-arg=event-qps=0 \
--etcd-expose-metrics=false" \
sh -
else
log "k3s deja installe : $(k3s --version | head -1)"
fi
systemctl enable --now k3s
log "Attente de la disponibilite du noeud"
for _ in $(seq 1 60); do
k3s kubectl get node >/dev/null 2>&1 && break
sleep 5
done
k3s kubectl get node -o wide
# --- 5. kubectl pour l'utilisateur deploy -----------------------------------
log "Configuration de kubectl pour l'utilisateur deploy"
DEPLOY_USER="${DEPLOY_USER:-deploy}"
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "/home/${DEPLOY_USER}/.kube"
install -m 600 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \
/etc/rancher/k3s/k3s.yaml "/home/${DEPLOY_USER}/.kube/config"
grep -q 'KUBECONFIG' "/home/${DEPLOY_USER}/.bashrc" || \
echo 'export KUBECONFIG=$HOME/.kube/config' >> "/home/${DEPLOY_USER}/.bashrc"
# `k3s kubectl` reste reserve a root ; deploy passe par son kubeconfig.
ln -sf /usr/local/bin/k3s /usr/local/bin/kubectl
# --- 6. Rotation des journaux d'audit ---------------------------------------
cat > /etc/logrotate.d/k3s-audit <<'CONF'
/var/log/k3s/audit.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
copytruncate
su root root
}
CONF
# --- 7. Verifications --------------------------------------------------------
log "Etat du cluster"
k3s kubectl get nodes
k3s kubectl -n kube-system get pods
cat <<NEXT
=============================================================================
Cluster k3s pret.
=============================================================================
Le chiffrement des Secrets au repos est actif :
sudo k3s secrets-encrypt status
Recuperer le kubeconfig sur votre poste (l'API n'est ouverte qu'a vos IPs
d'administration, cf. firewall Terraform) :
ssh deploy@${PUBLIC_IP} 'cat ~/.kube/config' \\
| sed "s/127.0.0.1/${PUBLIC_IP}/" > ~/.kube/xpeditis-prod.yaml
chmod 600 ~/.kube/xpeditis-prod.yaml
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
kubectl get nodes
Etape suivante :
sudo bash 03-install-cluster-addons.sh
=============================================================================
NEXT

View File

@ -0,0 +1,86 @@
#!/usr/bin/env bash
# =============================================================================
# 03 - Composants du cluster (cert-manager, namespaces, acces registre)
# =============================================================================
# A executer depuis VOTRE POSTE, kubeconfig de prod charge :
#
# export KUBECONFIG=~/.kube/xpeditis-prod.yaml
# REGISTRY_TOKEN=... bash 03-install-cluster-addons.sh
#
# cert-manager est installe depuis son manifeste statique officiel : pas de
# Helm a maintenir sur le cluster, une seule version epinglee, un seul fichier
# a relire en cas de doute.
set -euo pipefail
CERT_MANAGER_VERSION="${CERT_MANAGER_VERSION:-v1.16.2}"
REGISTRY_SERVER="${REGISTRY_SERVER:-rg.fr-par.scw.cloud}"
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
NAMESPACE="${NAMESPACE:-xpeditis-prod}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
K8S_DIR="${HERE}/../k8s"
log() { printf '\n>>> %s\n' "$*"; }
kubectl version --output=yaml >/dev/null || { echo "kubectl ne joint pas le cluster." >&2; exit 1; }
# --- 1. Namespaces -----------------------------------------------------------
log "Namespaces"
kubectl apply -f "${K8S_DIR}/base/00-namespaces.yaml"
# --- 2. cert-manager ---------------------------------------------------------
if ! kubectl get ns cert-manager >/dev/null 2>&1; then
log "Installation de cert-manager ${CERT_MANAGER_VERSION}"
kubectl apply -f \
"https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml"
else
log "cert-manager deja present"
fi
log "Attente de cert-manager"
kubectl -n cert-manager rollout status deploy/cert-manager --timeout=180s
kubectl -n cert-manager rollout status deploy/cert-manager-webhook --timeout=180s
kubectl -n cert-manager rollout status deploy/cert-manager-cainjector --timeout=180s
# --- 3. Acces au registre Scaleway ------------------------------------------
# Le registre est prive : sans ce Secret, les pods restent en ImagePullBackOff.
if [[ -n "$REGISTRY_TOKEN" ]]; then
log "Secret d'acces au registre (${REGISTRY_SERVER})"
kubectl -n "$NAMESPACE" create secret docker-registry regcred \
--docker-server="$REGISTRY_SERVER" \
--docker-username=nologin \
--docker-password="$REGISTRY_TOKEN" \
--dry-run=client -o yaml | kubectl apply -f -
else
echo "AVERTISSEMENT: REGISTRY_TOKEN absent. Creez 'regcred' avant de deployer :"
echo " kubectl -n ${NAMESPACE} create secret docker-registry regcred \\"
echo " --docker-server=${REGISTRY_SERVER} --docker-username=nologin --docker-password=<token>"
fi
# --- 4. Emetteur ACME --------------------------------------------------------
# Le ClusterIssuer depend d'un Secret contenant le token API Cloudflare :
# il est chiffre SOPS et doit etre applique avant (secrets-apply.sh).
if kubectl -n cert-manager get secret cloudflare-api-token >/dev/null 2>&1; then
log "ClusterIssuer Let's Encrypt (DNS-01 Cloudflare)"
kubectl apply -f "${K8S_DIR}/cluster/cluster-issuer.yaml"
else
echo "AVERTISSEMENT: le Secret cert-manager/cloudflare-api-token est absent."
echo " Appliquez d'abord les secrets : bash scripts/secrets-apply.sh"
echo " puis : kubectl apply -f ${K8S_DIR}/cluster/cluster-issuer.yaml"
fi
# --- 5. Verifications --------------------------------------------------------
log "Etat"
kubectl get ns
kubectl -n cert-manager get pods
kubectl get clusterissuer 2>/dev/null || true
cat <<NEXT
=============================================================================
Composants du cluster installes.
=============================================================================
Etapes suivantes :
1. bash scripts/secrets-apply.sh # secrets SOPS -> cluster
2. bash scripts/deploy.sh <tag-image> # premier deploiement
=============================================================================
NEXT

View File

@ -0,0 +1,76 @@
#!/usr/bin/env bash
# =============================================================================
# Deploiement de la pile d'observabilite
# =============================================================================
# export KUBECONFIG=~/.kube/xpeditis-prod.yaml
# bash scripts/deploy-monitoring.sh
#
# Loki (journaux) + Promtail (collecte) + Prometheus (metriques) +
# Alertmanager (Discord) + Grafana (consultation).
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
K8S_DIR="${HERE}/../k8s"
REPO_ROOT="$(cd "${HERE}/../../.." && pwd)"
log() { printf '\n>>> %s\n' "$*"; }
kubectl get ns monitoring >/dev/null 2>&1 || kubectl apply -f "${K8S_DIR}/base/00-namespaces.yaml"
kubectl -n monitoring get secret grafana-admin >/dev/null 2>&1 \
|| { echo "Secret grafana-admin absent : lancez d'abord scripts/secrets-apply.sh" >&2; exit 1; }
kubectl -n monitoring get secret alertmanager-secrets >/dev/null 2>&1 \
|| { echo "Secret alertmanager-secrets absent : lancez d'abord scripts/secrets-apply.sh" >&2; exit 1; }
# --- Tableaux de bord --------------------------------------------------------
# Reutilise les tableaux de bord deja ecrits pour la preprod plutot que d'en
# maintenir un second jeu.
DASHBOARD_SRC="${REPO_ROOT}/infra/logging/grafana/provisioning/dashboards"
if [[ -d "$DASHBOARD_SRC" ]]; then
log "Tableaux de bord depuis ${DASHBOARD_SRC}"
kubectl -n monitoring create configmap grafana-dashboards \
$(find "$DASHBOARD_SRC" -name '*.json' -exec printf -- '--from-file=%s ' {} +) \
--dry-run=client -o yaml | kubectl apply -f -
else
echo "AVERTISSEMENT: aucun tableau de bord trouve dans ${DASHBOARD_SRC}"
fi
log "Loki"
kubectl apply -f "${K8S_DIR}/monitoring/01-loki.yaml"
log "Promtail"
kubectl apply -f "${K8S_DIR}/monitoring/02-promtail.yaml"
log "Prometheus"
kubectl apply -f "${K8S_DIR}/monitoring/03-prometheus.yaml"
log "node-exporter"
kubectl apply -f "${K8S_DIR}/monitoring/04-node-exporter.yaml"
log "Alertmanager"
kubectl apply -f "${K8S_DIR}/monitoring/05-alertmanager.yaml"
log "Grafana"
kubectl apply -f "${K8S_DIR}/monitoring/06-grafana.yaml"
log "Politiques reseau du namespace monitoring"
kubectl apply -f "${K8S_DIR}/base/10-network-policies.yaml"
kubectl apply -f "${K8S_DIR}/base/08-traefik-middlewares.yaml"
log "Attente du demarrage"
kubectl -n monitoring rollout status deploy/loki --timeout=300s
kubectl -n monitoring rollout status deploy/prometheus --timeout=300s
kubectl -n monitoring rollout status deploy/alertmanager --timeout=180s
kubectl -n monitoring rollout status deploy/grafana --timeout=300s
log "Etat"
kubectl -n monitoring get pods,svc,pvc
cat <<'NEXT'
Grafana : https://grafana.xpeditis.com
Identifiants : Secret monitoring/grafana-admin
L'acces est filtre par IP (middleware monitoring-admin-ip-allowlist).
Adaptez la liste dans k8s/base/08-traefik-middlewares.yaml, sinon vous
obtiendrez un 403 depuis votre poste.
Verifier que les alertes partent bien, AVANT d'en avoir besoin :
kubectl -n monitoring port-forward svc/alertmanager 9093:9093
curl -XPOST http://localhost:9093/api/v2/alerts -H 'Content-Type: application/json' \
-d '[{"labels":{"alertname":"TestDeRoutage","severity":"avertissement"}}]'
NEXT

126
infra/prod/scripts/deploy.sh Executable file
View File

@ -0,0 +1,126 @@
#!/usr/bin/env bash
# =============================================================================
# Deploiement d'une version en production
# =============================================================================
# export KUBECONFIG=~/.kube/xpeditis-prod.yaml
# bash scripts/deploy.sh prod-a1b2c3d
#
# Sequence :
# 1. verification que les images existent dans le registre
# 2. Job de migration (parallelisme 1) -- bloquant
# 3. mise a jour des images backend et frontend
# 4. attente du deploiement complet
# 5. tests de fumee sur les URLs publiques
# 6. retour arriere automatique si l'une des etapes echoue
#
# C'est la meme sequence que cd-main.yml : ce script est le chemin manuel de
# secours quand GitHub Actions est indisponible.
set -euo pipefail
TAG="${1:?usage: $0 <tag-image> ex: prod-a1b2c3d}"
NAMESPACE="${NAMESPACE:-xpeditis-prod}"
REGISTRY="${REGISTRY:-rg.fr-par.scw.cloud/weworkstudio}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
K8S_DIR="${HERE}/../k8s"
log() { printf '\n>>> %s\n' "$*"; }
fail() { printf '\nECHEC: %s\n' "$*" >&2; exit 1; }
rollback() {
log "RETOUR ARRIERE"
kubectl -n "$NAMESPACE" rollout undo deploy/xpeditis-backend || true
kubectl -n "$NAMESPACE" rollout undo deploy/xpeditis-frontend || true
kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-backend --timeout=180s || true
kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-frontend --timeout=180s || true
cat >&2 <<'MSG'
Les deploiements sont revenus a la version precedente.
ATTENTION : les MIGRATIONS DE BASE, elles, ne sont pas annulees. Si la version
retiree contenait une migration destructrice (colonne supprimee, type change),
l'ancienne version applicative peut ne plus fonctionner contre le schema
courant. Voir docs/mise-en-prod/15-exploitation-incidents.md, "Retour arriere
avec migration".
MSG
exit 1
}
# --- 0. Preconditions --------------------------------------------------------
kubectl get ns "$NAMESPACE" >/dev/null || fail "namespace $NAMESPACE introuvable"
kubectl -n "$NAMESPACE" get secret regcred >/dev/null \
|| fail "secret 'regcred' absent : les images ne pourront pas etre telechargees"
kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets >/dev/null \
|| fail "secrets applicatifs absents : lancez scripts/secrets-apply.sh"
BACKEND_IMAGE="${REGISTRY}/xpeditis-backend:${TAG}"
FRONTEND_IMAGE="${REGISTRY}/xpeditis-frontend:${TAG}"
EXPORTER_IMAGE="${REGISTRY}/xpeditis-log-exporter:${TAG}"
log "Version deployee : ${TAG}"
kubectl -n "$NAMESPACE" get deploy -o wide
# --- 1. Configuration --------------------------------------------------------
log "Application de la configuration (ConfigMap, Ingress, politiques)"
kubectl apply -f "${K8S_DIR}/base/00-namespaces.yaml"
kubectl apply -f "${K8S_DIR}/base/01-limits.yaml"
kubectl apply -f "${K8S_DIR}/base/02-configmap-backend.yaml"
kubectl apply -f "${K8S_DIR}/base/08-traefik-middlewares.yaml"
kubectl apply -f "${K8S_DIR}/base/10-network-policies.yaml"
kubectl apply -f "${K8S_DIR}/base/11-certificate.yaml"
kubectl apply -f "${K8S_DIR}/base/09-ingress.yaml"
# Empreinte de la configuration : sans elle, un changement de ConfigMap ne
# provoque aucun redemarrage et reste sans effet jusqu'au deploiement suivant.
CONFIG_SUM="$(kubectl -n "$NAMESPACE" get cm xpeditis-backend-config -o yaml \
| sha256sum | cut -c1-16)"
# --- 2. Migrations -----------------------------------------------------------
JOB_NAME="xpeditis-migrate-${TAG//[^a-z0-9-]/-}"
log "Migrations de base (Job ${JOB_NAME})"
kubectl -n "$NAMESPACE" delete job "$JOB_NAME" --ignore-not-found >/dev/null
sed -e "s|__IMAGE_TAG__|${TAG}|g" "${K8S_DIR}/base/07-migration-job.yaml" \
| kubectl apply -f -
if ! kubectl -n "$NAMESPACE" wait --for=condition=complete "job/${JOB_NAME}" --timeout=900s; then
echo "--- journaux du Job de migration ---" >&2
kubectl -n "$NAMESPACE" logs "job/${JOB_NAME}" --tail=200 >&2 || true
fail "les migrations ont echoue : AUCUNE image n'a ete deployee, la production tourne toujours sur la version precedente"
fi
kubectl -n "$NAMESPACE" logs "job/${JOB_NAME}" --tail=50
# --- 3. Deploiement ----------------------------------------------------------
log "Mise a jour du backend"
kubectl -n "$NAMESPACE" patch deploy xpeditis-backend --type=strategic -p \
"{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"xpeditis.com/config-checksum\":\"${CONFIG_SUM}\"}}}}}"
kubectl -n "$NAMESPACE" set image deploy/xpeditis-backend "backend=${BACKEND_IMAGE}"
kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-backend --timeout=300s || rollback
log "Mise a jour du frontend"
kubectl -n "$NAMESPACE" set image deploy/xpeditis-frontend "frontend=${FRONTEND_IMAGE}"
kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-frontend --timeout=300s || rollback
# Le collecteur de logs n'est pas critique : son echec ne doit pas declencher
# un retour arriere de l'application.
log "Mise a jour du log-exporter"
kubectl -n "$NAMESPACE" set image deploy/xpeditis-log-exporter "log-exporter=${EXPORTER_IMAGE}" || true
kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-log-exporter --timeout=180s \
|| log "AVERTISSEMENT: le log-exporter n'a pas demarre (non bloquant)"
# --- 4. Tests de fumee -------------------------------------------------------
log "Tests de fumee"
if ! bash "${HERE}/smoke-test.sh"; then
rollback
fi
# --- 5. Compte rendu ---------------------------------------------------------
log "Deploiement termine"
kubectl -n "$NAMESPACE" get pods -o wide
echo
echo "Backend : ${BACKEND_IMAGE}"
echo "Frontend : ${FRONTEND_IMAGE}"
echo
echo "Retour arriere manuel si necessaire :"
echo " kubectl -n ${NAMESPACE} rollout undo deploy/xpeditis-backend"
echo " kubectl -n ${NAMESPACE} rollout undo deploy/xpeditis-frontend"

View File

@ -0,0 +1,151 @@
#!/usr/bin/env bash
# =============================================================================
# Neutralisation des comptes de demonstration -- OUTIL DE SECOURS ET D'AUDIT
# =============================================================================
#
# LE CAS NOMINAL N'A PLUS BESOIN DE CE SCRIPT.
#
# Le traitement est desormais fait par les migrations, donc automatiquement et
# sans risque d'oubli :
#
# 1730000000007-SeedTestUsers ne s'execute plus si
# NODE_ENV=production
# 1756000000000-NeutralizeSeedAccountsInProduction filet de securite
# 1756000000001-BootstrapAdminFromEnv cree VOTRE administrateur
#
# Ce script reste utile dans trois situations :
#
# - une base de production migree AVANT l'ajout de la garde NODE_ENV ;
# - un deploiement ou NODE_ENV n'etait pas correctement positionne (le journal
# du Job de migration affiche alors "Seeded test users successfully") ;
# - une verification manuelle : il affiche l'etat des comptes sans rien
# changer s'il n'y a rien a changer.
#
# ssh deploy@<db-01>
# sudo bash /opt/xpeditis/infra-prod/scripts/harden-seed-data.sh
#
# RAPPEL DU PROBLEME
#
# La migration 1730000000007-SeedTestUsers cree trois comptes dont le mot de
# passe est ecrit en clair dans le depot :
#
# admin@xpeditis.com role ADMIN Password123!
# manager@xpeditis.com role MANAGER Password123!
# user@xpeditis.com role USER Password123!
#
# Sur une base de production, cela donne un acces complet a la plateforme a
# quiconque a lu le depot.
#
# Ce script ne SUPPRIME pas les lignes (des cles etrangeres peuvent y pointer,
# et une suppression en cascade dans audit_logs serait pire). Il :
# 1. renomme les adresses vers un domaine invalide -- ce qui libere au passage
# admin@xpeditis.com pour votre vrai compte ;
# 2. remplace le mot de passe par une valeur aleatoire inutilisable ;
# 3. desactive les comptes (is_active = false).
#
# Idempotent : peut etre relance sans risque.
set -euo pipefail
COMPOSE_DIR="${COMPOSE_DIR:-/opt/xpeditis/data-node}"
ENV_FILE="${ENV_FILE:-${COMPOSE_DIR}/.env.data}"
[[ -f "$ENV_FILE" ]] || { echo "Fichier d'environnement introuvable : $ENV_FILE" >&2; exit 1; }
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a
psql_run() {
docker compose -f "${COMPOSE_DIR}/docker-compose.data.yml" --env-file "$ENV_FILE" \
exec -T -u postgres postgres psql -v ON_ERROR_STOP=1 -d "$POSTGRES_DB" "$@"
}
echo ">>> Etat avant intervention"
psql_run -c "
SELECT email, role, is_active
FROM users
WHERE email IN ('admin@xpeditis.com','manager@xpeditis.com','user@xpeditis.com');
"
echo
echo ">>> Neutralisation"
psql_run <<'SQL'
BEGIN;
-- Mot de passe remplace par une valeur aleatoire : le format reste un hash
-- Argon2 valide en apparence, mais aucun mot de passe ne peut y correspondre.
UPDATE users
SET
email = 'seed-desactive-' || substr(id::text, 1, 8) || '@invalid.local',
-- md5(random()) plutot que gen_random_bytes : pas besoin de l'extension
-- pgcrypto, qui n'est pas installee sur cette base.
password_hash = '$argon2id$v=19$m=65536,t=3,p=4$' || md5(random()::text)
|| '$' || md5(random()::text) || md5(clock_timestamp()::text),
is_active = false,
updated_at = NOW()
WHERE email IN ('admin@xpeditis.com', 'manager@xpeditis.com', 'user@xpeditis.com');
COMMIT;
SQL
echo
echo ">>> Etat apres intervention"
psql_run -c "
SELECT email, role, is_active
FROM users
WHERE email LIKE 'seed-desactive-%@invalid.local';
"
echo
echo ">>> Controle : aucun compte de demonstration ne doit subsister"
RESTE=$(psql_run -tAc "
SELECT count(*) FROM users
WHERE email IN ('admin@xpeditis.com','manager@xpeditis.com','user@xpeditis.com');
")
if [[ "$RESTE" != "0" ]]; then
echo "ECHEC : ${RESTE} compte(s) de demonstration encore actifs." >&2
exit 1
fi
echo "OK : aucun compte de demonstration actif."
echo
echo ">>> Organisations de demonstration presentes (a examiner, non modifiees)"
# Non supprimees automatiquement : les comptes desactives y sont rattaches, et
# une suppression en cascade toucherait aussi audit_logs.
psql_run -c "
SELECT id, name, created_at
FROM organizations
WHERE name IN ('Test Freight Forwarder Inc.','Demo Shipping Company','Sample Shipper Ltd.');
"
cat <<'NEXT'
-----------------------------------------------------------------------------
Etape suivante : disposer d'un administrateur.
La migration 1756000000001-BootstrapAdminFromEnv s'en charge normalement, a
partir de BOOTSTRAP_ADMIN_EMAIL (ConfigMap). Si elle a ete ignoree parce qu'un
ADMIN actif existait alors -- typiquement le compte de demonstration que ce
script vient de neutraliser -- relancez simplement le Job de migration :
kubectl -n xpeditis-prod delete job -l app.kubernetes.io/name=xpeditis-migrate
# puis redeployez, ou rejouez le Job pour le tag courant
Elle ne rejouera pas les migrations deja appliquees ; pour forcer uniquement
l'amorcage, creez votre compte a la main :
1. Inscrivez-vous sur https://app.xpeditis.com/fr/register
2. Promouvez le compte :
UPDATE users SET role = 'ADMIN' WHERE email = '<votre adresse>';
Dans les deux cas, verifiez qu'il n'existe qu'un seul ADMIN actif :
SELECT email, role, is_active FROM users WHERE role = 'ADMIN';
Puis controlez depuis l'exterieur que l'ancien compte est bien mort :
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
https://api.xpeditis.com/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@xpeditis.com","password":"Password123!"}'
# Attendu : 401 (et surtout pas 200 ni 201)
-----------------------------------------------------------------------------
NEXT

View File

@ -0,0 +1,187 @@
#!/usr/bin/env bash
# =============================================================================
# Controle go / no-go avant ouverture au public
# =============================================================================
# export KUBECONFIG=~/.kube/xpeditis-prod.yaml
# bash scripts/preflight-check.sh
#
# Chaque point BLOQUANT en echec doit etre corrige avant d'ouvrir le service.
# Les points d'AVERTISSEMENT peuvent etre acceptes consciemment et notes dans
# le registre des risques.
set -uo pipefail
NAMESPACE=xpeditis-prod
API="${PROD_API_URL:-https://api.xpeditis.com}"
DB_HOST="${DB_PRIVATE_IP:-10.10.1.20}"
DB_PUBLIC_IP="${DB_PUBLIC_IP:-}"
BLOCK=0
WARN=0
ok() { printf ' \033[32m[OK]\033[0m %s\n' "$1"; }
block() { printf ' \033[31m[BLOQUANT]\033[0m %s\n' "$1"; BLOCK=$((BLOCK+1)); }
warn() { printf ' \033[33m[ATTENTION]\033[0m %s\n' "$1"; WARN=$((WARN+1)); }
section() { printf '\n\033[1m%s\033[0m\n' "$1"; }
# ---------------------------------------------------------------------------
section "1. Cluster"
if kubectl get nodes >/dev/null 2>&1; then
ok "Cluster joignable"
if kubectl get nodes --no-headers | grep -qv ' Ready'; then
block "Un noeud n'est pas Ready"
else
ok "Tous les noeuds sont Ready"
fi
else
block "Cluster injoignable : rien d'autre ne peut etre verifie"
fi
if kubectl -n "$NAMESPACE" get pods --no-headers 2>/dev/null | grep -qE 'CrashLoop|ImagePull|Error|Pending'; then
block "Des pods ne sont pas sains dans $NAMESPACE"
else
ok "Tous les pods de $NAMESPACE sont sains"
fi
for d in xpeditis-backend xpeditis-frontend; do
READY=$(kubectl -n "$NAMESPACE" get deploy "$d" -o jsonpath='{.status.readyReplicas}' 2>/dev/null || echo 0)
[[ "${READY:-0}" -ge 2 ]] && ok "$d : ${READY} replicas prets" \
|| warn "$d : ${READY:-0} replica(s) pret(s), 2 attendus (pas de haute disponibilite)"
done
# ---------------------------------------------------------------------------
section "2. Secrets"
if kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets >/dev/null 2>&1; then
ok "Secrets applicatifs presents"
JWT=$(kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets -o jsonpath='{.data.JWT_SECRET}' 2>/dev/null | base64 -d 2>/dev/null || echo "")
[[ ${#JWT} -ge 32 ]] && ok "JWT_SECRET fait ${#JWT} caracteres" || block "JWT_SECRET trop court (${#JWT} caracteres, 32 minimum)"
# Les secrets publies dans infra/preprod/docker-stack.preprod.yml sont dans
# l'historique Git : les retrouver en production serait une compromission.
for compromis in "4C4tQC8qym" "9Lc3M9qoPBeHLKHDXGUf1" "hXiy5GMPswMtxMZujjS2O" "RBJfD0QVXC5JDfAHCwdUW"; do
if kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets -o json 2>/dev/null \
| grep -q "$(printf '%s' "$compromis" | base64 | cut -c1-12)"; then
block "Un secret de preprod (present dans Git, donc compromis) est reutilise en production"
fi
done
ok "Aucun secret de preprod detecte"
STRIPE=$(kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets -o jsonpath='{.data.STRIPE_SECRET_KEY}' 2>/dev/null | base64 -d 2>/dev/null || echo "")
case "$STRIPE" in
sk_live_*) ok "Cle Stripe en mode LIVE" ;;
sk_test_*) block "Cle Stripe en mode TEST : aucun paiement reel ne sera encaisse" ;;
*) warn "Cle Stripe absente ou non reconnue" ;;
esac
else
block "Secrets applicatifs absents"
fi
if kubectl -n "$NAMESPACE" get secret regcred >/dev/null 2>&1; then
ok "Acces au registre configure"
else
block "Secret 'regcred' absent : aucune image ne pourra etre telechargee"
fi
if sudo k3s secrets-encrypt status 2>/dev/null | grep -qi 'Encryption Status: Enabled'; then
ok "Chiffrement des Secrets au repos actif"
else
warn "Chiffrement des Secrets au repos non verifiable depuis ce poste (a controler sur app-01)"
fi
# ---------------------------------------------------------------------------
section "3. TLS et exposition"
CERT=$(kubectl -n "$NAMESPACE" get certificate xpeditis-wildcard -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo "")
[[ "$CERT" == "True" ]] && ok "Certificat wildcard emis" || block "Certificat wildcard non pret"
ISSUER=$(kubectl -n "$NAMESPACE" get certificate xpeditis-wildcard -o jsonpath='{.spec.issuerRef.name}' 2>/dev/null || echo "")
[[ "$ISSUER" == "letsencrypt-prod" ]] && ok "Emetteur : letsencrypt-prod" \
|| block "Emetteur '$ISSUER' : un certificat de staging n'est pas reconnu par les navigateurs"
if curl -sSI --max-time 15 "${API}/api/v1/health" 2>/dev/null | grep -qi '^strict-transport-security:'; then
ok "HSTS actif"
else
block "En-tete HSTS absent"
fi
if [[ -n "$DB_PUBLIC_IP" ]]; then
if command -v nc >/dev/null; then
if nc -z -w3 "$DB_PUBLIC_IP" 5432 2>/dev/null; then
block "PostgreSQL repond sur l'IP PUBLIQUE de db-01"
else
ok "PostgreSQL injoignable publiquement"
fi
if nc -z -w3 "$DB_PUBLIC_IP" 6379 2>/dev/null; then
block "Redis repond sur l'IP PUBLIQUE de db-01"
else
ok "Redis injoignable publiquement"
fi
fi
else
warn "DB_PUBLIC_IP non fournie : exposition de la base non verifiee"
fi
# ---------------------------------------------------------------------------
section "4. Comptes de demonstration"
# La migration 1730000000007-SeedTestUsers creait admin@xpeditis.com avec le mot
# de passe "Password123!", ecrit en clair dans le depot. Elle ne s'execute plus
# quand NODE_ENV=production, et 1756000000000 neutralise ces comptes s'ils
# existent malgre tout.
#
# Ce controle verifie le RESULTAT en conditions reelles, pas l'intention : c'est
# le seul moyen de detecter un NODE_ENV mal positionne ou une base restauree
# depuis une sauvegarde anterieure. Le controle le plus important de la liste.
for compte in admin manager user; do
CODE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 \
-X POST "${API}/api/v1/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"email\":\"${compte}@xpeditis.com\",\"password\":\"Password123!\"}" 2>/dev/null || echo "000")
case "$CODE" in
200|201) block "Le compte de demonstration ${compte}@xpeditis.com accepte encore Password123! (HTTP ${CODE})" ;;
000) warn "Impossible de tester ${compte}@xpeditis.com (API injoignable)" ;;
*) ok "${compte}@xpeditis.com neutralise (HTTP ${CODE})" ;;
esac
done
echo " Verifier aussi qu'il n'existe qu'un seul ADMIN actif, sur db-01 :"
echo " SELECT email, role, is_active FROM users WHERE role = 'ADMIN';"
# ---------------------------------------------------------------------------
section "5. Sauvegardes"
cat <<'MANUEL'
Ces points se verifient sur db-01, ils ne peuvent pas l'etre d'ici :
ssh deploy@<db-01>
systemctl list-timers 'xpeditis-*' # timers actifs
sudo journalctl -u xpeditis-backup -n 30 # derniere execution
sudo /opt/xpeditis/data-node/backup/pg-restore.sh verify
Une sauvegarde jamais restauree n'est pas une sauvegarde. Le test de
restauration DOIT avoir ete passe au moins une fois avant l'ouverture.
MANUEL
# ---------------------------------------------------------------------------
section "6. Fonctionnement"
if bash "$(dirname "${BASH_SOURCE[0]}")/smoke-test.sh" >/dev/null 2>&1; then
ok "Tests de fumee au vert"
else
block "Tests de fumee en echec (relancer smoke-test.sh pour le detail)"
fi
# ---------------------------------------------------------------------------
printf '\n=========================================\n'
printf ' Bloquants : %d Avertissements : %d\n' "$BLOCK" "$WARN"
printf '=========================================\n'
if [[ "$BLOCK" -gt 0 ]]; then
printf '\n\033[31mNO-GO\033[0m : corrigez les points bloquants avant d ouvrir au public.\n'
exit 1
fi
printf '\n\033[32mGO\033[0m : les controles bloquants sont passes.\n'
[[ "$WARN" -gt 0 ]] && printf 'Consignez les %d avertissement(s) dans le registre des risques.\n' "$WARN"
exit 0

View File

@ -0,0 +1,88 @@
#!/usr/bin/env bash
# =============================================================================
# Rafraichissement des rangs d'IP Cloudflare
# =============================================================================
# Cloudflare fait evoluer ses rangs. Deux endroits en dependent :
# - terraform/firewall.tf (qui peut atteindre 80/443 sur app-01)
# - la configuration Traefik (quelles IP ont le droit d'ecrire X-Forwarded-For)
#
# Une liste perimee produit deux pannes distinctes et peu evidentes :
# - du trafic legitime rejete par le firewall Hetzner ;
# - une IP client mal identifiee, donc une limitation de debit qui frappe
# tout le monde et des audit_logs faux.
#
# A relancer tous les trimestres, et systematiquement avant un `terraform apply`.
#
# bash scripts/refresh-cloudflare-ips.sh # compare et affiche
# bash scripts/refresh-cloudflare-ips.sh --write # met a jour firewall.tf
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TF_FILE="${HERE}/../terraform/firewall.tf"
WRITE=false
[[ "${1:-}" == "--write" ]] && WRITE=true
echo ">>> Recuperation des rangs officiels Cloudflare"
V4="$(curl -fsS --max-time 15 https://www.cloudflare.com/ips-v4)"
V6="$(curl -fsS --max-time 15 https://www.cloudflare.com/ips-v6)"
[[ -n "$V4" && -n "$V6" ]] || { echo "Reponse vide de Cloudflare, abandon." >&2; exit 1; }
echo
echo "IPv4 ($(echo "$V4" | wc -l | tr -d ' ') rangs) :"
echo "$V4" | sed 's/^/ /'
echo
echo "IPv6 ($(echo "$V6" | wc -l | tr -d ' ') rangs) :"
echo "$V6" | sed 's/^/ /'
echo
echo ">>> Rangs actuellement declares dans firewall.tf :"
grep -oE '"[0-9a-f:.]+/[0-9]+"' "$TF_FILE" | tr -d '"' | sort -u | sed 's/^/ /'
DIFF=$(diff <(printf '%s\n%s\n' "$V4" "$V6" | sort -u) \
<(grep -oE '"[0-9a-f:.]+/[0-9]+"' "$TF_FILE" | tr -d '"' | sort -u) || true)
if [[ -z "$DIFF" ]]; then
echo
echo ">>> Aucune difference : rien a faire."
exit 0
fi
echo
echo ">>> DIFFERENCES DETECTEES :"
echo "$DIFF"
if ! $WRITE; then
cat <<'MSG'
Relancez avec --write pour mettre a jour terraform/firewall.tf, puis :
cd terraform && terraform plan && terraform apply
Pensez aussi a reporter la liste dans la configuration Traefik :
/var/lib/rancher/k3s/server/manifests/traefik-config.yaml (sur app-01)
puis : sudo systemctl restart k3s
MSG
exit 2
fi
BLOCK_V4=$(echo "$V4" | sed 's/^/ "/; s/$/",/')
BLOCK_V6=$(echo "$V6" | sed 's/^/ "/; s/$/",/')
python3 - "$TF_FILE" <<PY
import re, sys
path = sys.argv[1]
src = open(path).read()
src = re.sub(r'(cloudflare_ipv4 = \[\n).*?(\n \])',
lambda m: m.group(1) + """${BLOCK_V4}""".rstrip(',') + m.group(2),
src, flags=re.S)
src = re.sub(r'(cloudflare_ipv6 = \[\n).*?(\n \])',
lambda m: m.group(1) + """${BLOCK_V6}""".rstrip(',') + m.group(2),
src, flags=re.S)
open(path, 'w').write(src)
print("firewall.tf mis a jour")
PY
echo
echo ">>> Verifiez le diff avant d'appliquer :"
echo " git diff infra/prod/terraform/firewall.tf"
echo " cd infra/prod/terraform && terraform plan"

View File

@ -0,0 +1,50 @@
#!/usr/bin/env bash
# =============================================================================
# Application des secrets chiffres SOPS sur le cluster
# =============================================================================
# export KUBECONFIG=~/.kube/xpeditis-prod.yaml
# bash scripts/secrets-apply.sh
#
# Le contenu dechiffre ne touche JAMAIS le disque : sops ecrit sur la sortie
# standard, kubectl lit sur l'entree standard. Rien a nettoyer, rien a oublier.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SECRETS_FILE="${HERE}/../k8s/base/03-secrets.sops.yaml"
command -v sops >/dev/null || { echo "sops n'est pas installe." >&2; exit 1; }
[[ -f "$SECRETS_FILE" ]] || {
cat >&2 <<MSG
Fichier introuvable : $SECRETS_FILE
Creez-le a partir du gabarit :
cp k8s/base/03-secrets.template.yaml /tmp/secrets.yaml
\$EDITOR /tmp/secrets.yaml
sops -e /tmp/secrets.yaml > k8s/base/03-secrets.sops.yaml
shred -u /tmp/secrets.yaml
MSG
exit 1
}
# Refuse d'appliquer un fichier qui ne serait pas reellement chiffre : c'est le
# genre d'erreur qui ne se voit qu'une fois le secret pousse sur GitHub.
grep -q 'sops:' "$SECRETS_FILE" || {
echo "ERREUR: $SECRETS_FILE ne semble pas chiffre par SOPS. Abandon." >&2
exit 1
}
echo ">>> Application des secrets sur $(kubectl config current-context)"
sops -d "$SECRETS_FILE" | kubectl apply -f -
echo
echo ">>> Secrets presents :"
kubectl -n xpeditis-prod get secrets
kubectl -n monitoring get secrets 2>/dev/null || true
kubectl -n cert-manager get secret cloudflare-api-token 2>/dev/null || true
cat <<'NEXT'
Rappel : modifier un Secret ne redemarre PAS les pods qui l'utilisent.
Pour que la nouvelle valeur soit prise en compte :
kubectl -n xpeditis-prod rollout restart deploy/xpeditis-backend
NEXT

View File

@ -0,0 +1,81 @@
#!/usr/bin/env bash
# =============================================================================
# Tests de fumee post-deploiement
# =============================================================================
# Verifie ce qu'un utilisateur constate reellement, depuis l'exterieur, a
# travers Cloudflare et Traefik -- pas l'etat interne du cluster.
#
# bash scripts/smoke-test.sh
#
# Code de retour 0 = production saine. Utilise par deploy.sh et cd-main.yml
# pour declencher un retour arriere automatique.
set -uo pipefail
API="${PROD_API_URL:-https://api.xpeditis.com}"
APP="${PROD_APP_URL:-https://app.xpeditis.com}"
SITE="${PROD_SITE_URL:-https://xpeditis.com}"
PASS=0
FAIL=0
check() {
local label="$1"; shift
if "$@" >/dev/null 2>&1; then
printf ' [OK] %s\n' "$label"; PASS=$((PASS + 1))
else
printf ' [ECHEC] %s\n' "$label"; FAIL=$((FAIL + 1))
fi
}
http_code() { curl -sS -o /dev/null -w '%{http_code}' --max-time 15 "$1"; }
expect_code() {
local url="$1" expected="$2"
[[ "$(http_code "$url")" == "$expected" ]]
}
expect_header() {
local url="$1" header="$2"
curl -sSI --max-time 15 "$url" | grep -qi "^${header}:"
}
echo "Tests de fumee - $(date -Is)"
echo
echo "Disponibilite"
check "API en ligne (200 sur /api/v1/health)" expect_code "${API}/api/v1/health" 200
check "Frontend en ligne (app)" expect_code "${APP}/" 200
check "Vitrine en ligne (apex)" expect_code "${SITE}/" 200
echo
echo "TLS et redirections"
check "HTTP redirige vers HTTPS" bash -c "[[ \$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 'http://api.xpeditis.com/api/v1/health') =~ ^30 ]]"
check "Certificat valide (pas d'option -k)" curl -sS --max-time 15 -o /dev/null "${API}/api/v1/health"
check "En-tete HSTS present" expect_header "${API}/api/v1/health" "strict-transport-security"
echo
echo "Durcissement"
check "X-Frame-Options present" expect_header "${APP}/" "x-frame-options"
check "X-Content-Type-Options present" expect_header "${APP}/" "x-content-type-options"
# main.ts desactive Swagger en production sauf si SWAGGER_USERNAME/PASSWORD
# sont definis. 404 = desactive, 401 = protege : les deux sont acceptables,
# 200 signifie que la documentation de l'API est publique.
check "Swagger non accessible librement" bash -c "[[ \$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 '${API}/api/docs') != '200' ]]"
check "Route protegee refuse l'anonyme (401/403)" bash -c "[[ \$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 '${API}/api/v1/bookings') =~ ^(401|403)$ ]]"
check "CORS refuse une origine inconnue" bash -c "! curl -sSI --max-time 15 -H 'Origin: https://evil.example' '${API}/api/v1/health' | grep -qi 'access-control-allow-origin: https://evil.example'"
echo
echo "Etat du cluster"
if command -v kubectl >/dev/null && kubectl get ns xpeditis-prod >/dev/null 2>&1; then
check "Aucun pod en erreur" bash -c "! kubectl -n xpeditis-prod get pods --no-headers | grep -qE 'CrashLoopBackOff|ImagePullBackOff|Error'"
check "Certificat cert-manager pret" bash -c "kubectl -n xpeditis-prod get certificate xpeditis-wildcard -o jsonpath='{.status.conditions[?(@.type==\"Ready\")].status}' | grep -q True"
else
echo " [saute] kubectl indisponible : verifications cluster ignorees"
fi
echo
echo "-----------------------------------------"
printf ' Reussis : %d Echecs : %d\n' "$PASS" "$FAIL"
echo "-----------------------------------------"
[[ "$FAIL" -eq 0 ]]

View File

@ -0,0 +1,56 @@
#!/usr/bin/env bash
# =============================================================================
# Enveloppe de la cle SSH de deploiement
# =============================================================================
# Reference depuis ~deploy/.ssh/authorized_keys sur app-01 :
#
# restrict,pty,command="/opt/xpeditis/infra-prod/scripts/ssh-deploy-wrapper.sh" ssh-ed25519 AAAA... github-actions-prod
#
# Le `command=` d'OpenSSH ignore ce que le client demande et execute ce script,
# en placant la commande d'origine dans SSH_ORIGINAL_COMMAND. Une cle volee ne
# donne donc pas un shell : elle ne peut lancer que ce qui est autorise ici.
set -euo pipefail
CMD="${SSH_ORIGINAL_COMMAND:-}"
LOG_TAG=xpeditis-ssh-deploy
deny() {
logger -t "$LOG_TAG" -- "REFUS: ${CMD}"
echo "Commande non autorisee pour cette cle." >&2
exit 126
}
logger -t "$LOG_TAG" -- "DEMANDE: ${CMD}"
case "$CMD" in
# rsync doit pouvoir se lancer en mode serveur pour recevoir infra/prod.
# --server et --sender uniquement, chemin de destination contraint.
"rsync --server "*"/opt/xpeditis/infra-prod/"*)
exec $CMD
;;
# Deploiement d'une version. Le tag est valide par une expression stricte :
# sans cela, `deploy.sh "; rm -rf /"` serait accepte.
"deploy "*)
TAG="${CMD#deploy }"
[[ "$TAG" =~ ^prod-[a-f0-9]{7,40}$ ]] || deny
exec /opt/xpeditis/infra-prod/scripts/deploy.sh "$TAG"
;;
# Retour arriere d'urgence.
"rollback")
kubectl -n xpeditis-prod rollout undo deploy/xpeditis-backend
kubectl -n xpeditis-prod rollout undo deploy/xpeditis-frontend
exec kubectl -n xpeditis-prod rollout status deploy/xpeditis-backend --timeout=180s
;;
# Diagnostic en lecture seule.
"status")
kubectl -n xpeditis-prod get pods,deploy -o wide
exec kubectl -n xpeditis-prod get events --sort-by=.lastTimestamp | tail -20
;;
*)
deny
;;
esac

View File

@ -0,0 +1,76 @@
#cloud-config
# =============================================================================
# Amorcage minimal des serveurs Xpeditis prod
# =============================================================================
# Ce fichier ne fait que le strict necessaire pour obtenir un serveur joignable
# en SSH par un compte non-root. Tout le durcissement reel est fait par
# scripts/00-bootstrap-common.sh, versionne et relisible.
hostname: ${hostname}
fqdn: ${hostname}
preserve_hostname: false
users:
- name: ${deploy_user}
groups: [sudo]
shell: /bin/bash
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
lock_passwd: true
ssh_authorized_keys:
- ${ssh_public_key}
# Le compte root n'a ni mot de passe ni acces SSH par mot de passe.
disable_root: true
ssh_pwauth: false
package_update: true
package_upgrade: true
packages:
- curl
- ca-certificates
- gnupg
- ufw
- fail2ban
- unattended-upgrades
- chrony
- jq
- git
- htop
- rsync
write_files:
# Pare-feu local minimal des le premier boot : le serveur n'est jamais
# accessible "nu", meme entre cloud-init et l'execution du script de
# durcissement.
- path: /etc/ssh/sshd_config.d/99-xpeditis-hardening.conf
permissions: "0644"
content: |
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PubkeyAuthentication yes
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding yes
MaxAuthTries 3
MaxSessions 5
LoginGraceTime 20
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers ${deploy_user}
runcmd:
- systemctl restart ssh || systemctl restart sshd
- systemctl enable --now fail2ban
- systemctl enable --now chrony
- |
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw --force enable
- touch /var/log/cloud-init-xpeditis-done
final_message: "Xpeditis prod node ${hostname} pret. Lancer scripts/00-bootstrap-common.sh."

View File

@ -0,0 +1,157 @@
# =============================================================================
# Firewalls Hetzner Cloud (interfaces publiques uniquement)
# =============================================================================
# Politique : tout est ferme par defaut. On n'ouvre que le strict necessaire,
# et jamais vers 0.0.0.0/0 sauf pour le trafic web derriere Cloudflare.
locals {
# Rangs d'IP Cloudflare. A rafraichir avec :
# curl -s https://www.cloudflare.com/ips-v4
# curl -s https://www.cloudflare.com/ips-v6
# Verifiez cette liste a chaque `terraform apply` (cf. scripts/refresh-cloudflare-ips.sh).
cloudflare_ipv4 = [
"173.245.48.0/20",
"103.21.244.0/22",
"103.22.200.0/22",
"103.31.4.0/22",
"141.101.64.0/18",
"108.162.192.0/18",
"190.93.240.0/20",
"188.114.96.0/20",
"197.234.240.0/22",
"198.41.128.0/17",
"162.158.0.0/15",
"104.16.0.0/13",
"104.24.0.0/14",
"172.64.0.0/13",
"131.0.72.0/22",
]
cloudflare_ipv6 = [
"2400:cb00::/32",
"2606:4700::/32",
"2803:f800::/32",
"2405:b500::/32",
"2405:8100::/32",
"2a06:98c0::/29",
"2c0f:f248::/32",
]
http_sources_v4 = var.restrict_http_to_cloudflare ? local.cloudflare_ipv4 : ["0.0.0.0/0"]
http_sources_v6 = var.restrict_http_to_cloudflare ? local.cloudflare_ipv6 : ["::/0"]
http_sources = concat(local.http_sources_v4, local.http_sources_v6)
}
# --- Noeud applicatif (k3s server) ------------------------------------------
resource "hcloud_firewall" "app" {
name = "${var.project_name}-fw-app"
# SSH : uniquement depuis les IPs d'administration.
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = var.admin_ip_allowlist
description = "SSH administrateur"
}
# API Kubernetes : uniquement depuis les IPs d'administration.
# Les runners GitHub Actions n'ont pas d'IP fixe : le deploiement passe donc
# par SSH + kubectl local sur le serveur, pas par 6443 expose.
# Cf. .github/workflows/cd-main.yml.
rule {
direction = "in"
protocol = "tcp"
port = "6443"
source_ips = var.admin_ip_allowlist
description = "API k3s (kubectl administrateur)"
}
rule {
direction = "in"
protocol = "tcp"
port = "80"
source_ips = local.http_sources
description = "HTTP (redirection 301 + ACME HTTP-01 de secours)"
}
rule {
direction = "in"
protocol = "tcp"
port = "443"
source_ips = local.http_sources
description = "HTTPS via Cloudflare"
}
# ICMP : utile pour le diagnostic reseau, sans risque notable.
rule {
direction = "in"
protocol = "icmp"
source_ips = ["0.0.0.0/0", "::/0"]
description = "ICMP (ping / MTU discovery)"
}
labels = {
project = var.project_name
managed = "terraform"
}
}
# --- Ouverture temporaire pour la CI/CD -------------------------------------
# Les runners GitHub Actions n'ont pas d'IP fixe : impossible de les inscrire
# une fois pour toutes dans une liste blanche, et ouvrir SSH au monde entier
# n'est pas une option.
#
# Ce firewall est attache a app-01 et reste VIDE en regime nominal. Le workflow
# cd-main.yml y injecte l'IP du runner juste avant le deploiement, puis le vide
# systematiquement (etape `if: always()`). La fenetre d'exposition dure le temps
# du deploiement, pour une seule IP, sur le seul port 22.
#
# `ignore_changes = [rule]` est indispensable : sans lui, le prochain
# `terraform apply` supprimerait une regle posee par la CI en cours d'execution.
resource "hcloud_firewall" "cicd" {
name = "${var.project_name}-fw-cicd"
# Aucune regle : l'etat au repos est "ferme".
labels = {
project = var.project_name
managed = "terraform"
purpose = "cicd-temporaire"
}
lifecycle {
ignore_changes = [rule]
}
}
# --- Noeud de donnees --------------------------------------------------------
# Aucun port applicatif expose publiquement. PostgreSQL et Redis n'ecoutent que
# sur l'IP privee (cf. conf/postgresql.conf et conf/redis.conf) et sont en plus
# filtres par nftables. Ce firewall ne laisse passer que le SSH d'administration.
resource "hcloud_firewall" "db" {
name = "${var.project_name}-fw-db"
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = var.admin_ip_allowlist
description = "SSH administrateur"
}
rule {
direction = "in"
protocol = "icmp"
source_ips = ["0.0.0.0/0", "::/0"]
description = "ICMP (ping / MTU discovery)"
}
labels = {
project = var.project_name
managed = "terraform"
}
}

View File

@ -0,0 +1,52 @@
# =============================================================================
# Reseau prive Hetzner
# =============================================================================
# Le noeud de donnees n'est JAMAIS joignable depuis Internet sur 5432/6379.
# Tout le trafic applicatif -> base passe par ce reseau prive, isole au niveau
# du projet Hetzner.
#
# Attention : le trafic sur un reseau prive Hetzner n'est pas chiffre par
# l'hyperviseur. On ajoute donc deux couches par-dessus :
# 1. TLS PostgreSQL (ssl = on + DATABASE_SSL=true cote applicatif)
# 2. Filtrage nftables sur db-01 (cf. scripts/01-setup-data-node.sh)
# Les firewalls Hetzner Cloud ne filtrent que les interfaces PUBLIQUES.
resource "hcloud_network" "main" {
name = "${var.project_name}-net"
ip_range = var.network_cidr
labels = {
project = var.project_name
managed = "terraform"
}
}
resource "hcloud_network_subnet" "main" {
network_id = hcloud_network.main.id
type = "cloud"
network_zone = "eu-central"
ip_range = var.subnet_cidr
}
# Cle SSH partagee par les deux serveurs.
resource "hcloud_ssh_key" "admin" {
name = "${var.project_name}-admin"
public_key = var.ssh_public_key
labels = {
project = var.project_name
managed = "terraform"
}
}
# Repartit les deux VMs sur des hotes physiques differents : une panne materielle
# ne peut pas emporter l'app ET la base en meme temps.
resource "hcloud_placement_group" "spread" {
name = "${var.project_name}-spread"
type = "spread"
labels = {
project = var.project_name
managed = "terraform"
}
}

View File

@ -0,0 +1,64 @@
output "app_public_ipv4" {
description = "IP publique du noeud applicatif. A pointer depuis Cloudflare (enregistrements A, proxifies)."
value = hcloud_server.app.ipv4_address
}
output "app_public_ipv6" {
description = "IPv6 du noeud applicatif (enregistrements AAAA)."
value = hcloud_server.app.ipv6_address
}
output "app_private_ip" {
description = "IP privee du noeud applicatif."
value = var.app_private_ip
}
output "db_public_ipv4" {
description = "IP publique du noeud de donnees. NE JAMAIS publier en DNS : elle ne sert qu'au SSH d'administration."
value = hcloud_server.db.ipv4_address
}
output "db_private_ip" {
description = "IP privee du noeud de donnees. C'est cette valeur qui alimente DATABASE_HOST et REDIS_HOST."
value = var.db_private_ip
}
output "pgdata_volume_device" {
description = "Chemin du peripherique du volume PostgreSQL sur db-01 (a monter sur /var/lib/xpeditis/pgdata)."
value = hcloud_volume.pgdata.linux_device
}
output "cicd_firewall_name" {
description = "Firewall temporaire pilote par cd-main.yml. Doit rester vide au repos."
value = hcloud_firewall.cicd.name
}
output "ssh_commands" {
description = "Commandes de connexion."
value = {
app = "ssh ${var.deploy_user}@${hcloud_server.app.ipv4_address}"
db = "ssh ${var.deploy_user}@${hcloud_server.db.ipv4_address}"
}
}
output "dns_records_to_create" {
description = "Enregistrements DNS a creer dans Cloudflare (tous proxifies, nuage orange)."
value = {
"xpeditis.com" = "A ${hcloud_server.app.ipv4_address}"
"www.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}"
"app.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}"
"api.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}"
"grafana.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}"
}
}
output "monthly_cost_estimate_eur" {
description = "Estimation indicative HT (tarifs Hetzner T2 2026, hors Storage Box et services tiers)."
value = {
app_server = "${var.app_server_type} : voir grille Hetzner"
db_server = "${var.db_server_type} : voir grille Hetzner"
volume = "${var.db_volume_size} Go x 0.048 EUR/Go = ${format("%.2f", var.db_volume_size * 0.048)} EUR"
backups = var.enable_hetzner_backups ? "+20% du prix des deux serveurs" : "desactives"
note = "Reference budgetaire : Xpeditis_Previsions_Couts.xlsx, feuille 'Hetzner (auto-heberge)'."
}
}

View File

@ -0,0 +1,116 @@
# =============================================================================
# Serveurs
# =============================================================================
# Topologie retenue (2 VMs, cf. docs/mise-en-prod/00 et Excel previsions couts) :
#
# app-01 CPX41 8 vCPU / 16 Go k3s server + tous les pods (stateless)
# db-01 CPX31 4 vCPU / 8 Go PostgreSQL 15 + Redis 7 (stateful, Docker)
#
# Pourquoi la base HORS de Kubernetes : PostgreSQL en StatefulSet apporte de la
# complexite (PV, ordre de demarrage, upgrades) sans aucun gain a cette echelle.
# Le hors-cluster rend les sauvegardes, la PITR et les restaurations triviales,
# et permet de reconstruire integralement le noeud app sans toucher aux donnees.
resource "hcloud_server" "app" {
name = "${var.project_name}-app-01"
server_type = var.app_server_type
image = var.image
location = var.location
ssh_keys = [hcloud_ssh_key.admin.id]
# Deux firewalls : le permanent, et celui que la CI ouvre puis referme.
firewall_ids = [hcloud_firewall.app.id, hcloud_firewall.cicd.id]
placement_group_id = hcloud_placement_group.spread.id
backups = var.enable_hetzner_backups
public_net {
ipv4_enabled = true
ipv6_enabled = true
}
network {
network_id = hcloud_network.main.id
ip = var.app_private_ip
}
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
hostname = "${var.project_name}-app-01"
deploy_user = var.deploy_user
ssh_public_key = var.ssh_public_key
})
labels = {
project = var.project_name
role = "app"
managed = "terraform"
}
depends_on = [hcloud_network_subnet.main]
lifecycle {
# Un changement d'image ou de user_data recreerait le serveur : on veut une
# decision explicite, pas une destruction silencieuse de la prod.
ignore_changes = [image, user_data]
}
}
resource "hcloud_server" "db" {
name = "${var.project_name}-db-01"
server_type = var.db_server_type
image = var.image
location = var.location
ssh_keys = [hcloud_ssh_key.admin.id]
firewall_ids = [hcloud_firewall.db.id]
placement_group_id = hcloud_placement_group.spread.id
backups = var.enable_hetzner_backups
public_net {
ipv4_enabled = true
ipv6_enabled = true
}
network {
network_id = hcloud_network.main.id
ip = var.db_private_ip
}
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
hostname = "${var.project_name}-db-01"
deploy_user = var.deploy_user
ssh_public_key = var.ssh_public_key
})
labels = {
project = var.project_name
role = "data"
managed = "terraform"
}
depends_on = [hcloud_network_subnet.main]
lifecycle {
ignore_changes = [image, user_data]
}
}
# --- Volume de donnees PostgreSQL -------------------------------------------
# Volume dedie plutot que le disque systeme : agrandissable a chaud, snapshotable
# independamment, et survit a une reinstallation complete du serveur.
resource "hcloud_volume" "pgdata" {
name = "${var.project_name}-pgdata"
size = var.db_volume_size
server_id = hcloud_server.db.id
automount = false
format = "ext4"
labels = {
project = var.project_name
role = "pgdata"
managed = "terraform"
}
lifecycle {
# Garde-fou : un `terraform destroy` ne doit jamais emporter les donnees.
prevent_destroy = true
}
}

View File

@ -0,0 +1,37 @@
# =============================================================================
# Copier en terraform.tfvars (gitignore) et completer.
# cp terraform.tfvars.example terraform.tfvars
# =============================================================================
# Token API Hetzner Cloud, projet "xpeditis-prod" UNIQUEMENT (Read & Write).
# Console Hetzner > Security > API tokens.
# Ne le mettez pas ici si vous preferez la variable d'environnement :
# export TF_VAR_hcloud_token="..."
hcloud_token = "REMPLACER"
project_name = "xpeditis-prod"
location = "fsn1"
# Cle publique SSH de l'administrateur (ed25519 recommande).
# ssh-keygen -t ed25519 -a 100 -C "xpeditis-prod-admin" -f ~/.ssh/xpeditis_prod
# cat ~/.ssh/xpeditis_prod.pub
ssh_public_key = "ssh-ed25519 AAAA... xpeditis-prod-admin"
# IP publique fixe depuis laquelle vous administrez (SSH + kubectl).
# Trouver la votre : curl -s https://ifconfig.me
# Si votre IP est dynamique, utilisez plutot un VPN a IP fixe, ou acceptez de
# mettre a jour cette liste puis de relancer `terraform apply`.
admin_ip_allowlist = [
"203.0.113.7/32",
]
# Dimensionnement (phase 1 = 0-100 utilisateurs)
app_server_type = "cpx41" # 8 vCPU / 16 Go
db_server_type = "cpx31" # 4 vCPU / 8 Go
db_volume_size = 50 # Go
enable_hetzner_backups = true
# Laisser a true en regime nominal : personne ne doit pouvoir contourner
# Cloudflare en tapant directement l'IP d'origine.
restrict_http_to_cloudflare = true

View File

@ -0,0 +1,120 @@
variable "hcloud_token" {
description = "Token API Hetzner Cloud (Read & Write), projet xpeditis-prod uniquement."
type = string
sensitive = true
}
variable "project_name" {
description = "Prefixe applique a toutes les ressources."
type = string
default = "xpeditis-prod"
}
variable "location" {
description = "Datacenter Hetzner. fsn1 = Falkenstein (DE), nbg1 = Nuremberg (DE), hel1 = Helsinki (FI). Rester dans l'UE pour le RGPD."
type = string
default = "fsn1"
validation {
condition = contains(["fsn1", "nbg1", "hel1"], var.location)
error_message = "Le RGPD impose de rester en UE : fsn1, nbg1 ou hel1."
}
}
# --- Dimensionnement (cf. Xpeditis_Previsions_Couts.xlsx, feuille Hetzner) ---
variable "app_server_type" {
description = "Type du noeud applicatif (k3s server + pods). CPX41 = 8 vCPU / 16 Go / 240 Go."
type = string
default = "cpx41"
}
variable "db_server_type" {
description = "Type du noeud de donnees (PostgreSQL + Redis). CPX31 = 4 vCPU / 8 Go / 160 Go."
type = string
default = "cpx31"
}
variable "image" {
description = "Image systeme de base."
type = string
default = "ubuntu-24.04"
}
variable "db_volume_size" {
description = "Volume dedie aux donnees PostgreSQL, en Go. Detache du disque systeme : on peut agrandir, snapshotter et reattacher sans toucher au serveur."
type = number
default = 50
}
variable "enable_hetzner_backups" {
description = "Snapshots automatiques Hetzner (+20% du prix du serveur). Ce n'est PAS une strategie de sauvegarde suffisante : cf. 12-sauvegardes-restauration.md."
type = bool
default = true
}
# --- Reseau ------------------------------------------------------------------
variable "network_cidr" {
description = "CIDR du reseau prive Hetzner."
type = string
default = "10.10.0.0/16"
}
variable "subnet_cidr" {
description = "CIDR du sous-reseau."
type = string
default = "10.10.1.0/24"
}
variable "app_private_ip" {
description = "IP privee fixe du noeud applicatif."
type = string
default = "10.10.1.10"
}
variable "db_private_ip" {
description = "IP privee fixe du noeud de donnees."
type = string
default = "10.10.1.20"
}
# --- Acces administrateur ----------------------------------------------------
variable "ssh_public_key" {
description = "Cle publique SSH (ed25519) de l'administrateur. C'est la seule methode d'authentification autorisee sur les serveurs."
type = string
}
variable "admin_ip_allowlist" {
description = <<-EOT
IPs/CIDR autorises a atteindre SSH (22) et l'API Kubernetes (6443).
Mettez votre IP fixe ou celle de votre VPN, JAMAIS 0.0.0.0/0.
Format CIDR obligatoire, ex. ["203.0.113.7/32"].
EOT
type = list(string)
validation {
condition = !contains(var.admin_ip_allowlist, "0.0.0.0/0")
error_message = "Ouvrir SSH et l'API k3s au monde entier est interdit. Renseignez votre IP en /32."
}
}
variable "deploy_user" {
description = "Compte non-root utilise pour l'administration et les deploiements."
type = string
default = "deploy"
}
# --- Cloudflare --------------------------------------------------------------
variable "restrict_http_to_cloudflare" {
description = <<-EOT
Si true, seuls les rangs d'IP Cloudflare peuvent joindre 80/443 sur le noeud
applicatif : impossible de contourner le WAF en tapant l'IP d'origine.
Mettre a false UNIQUEMENT le temps d'emettre le premier certificat en HTTP-01
ou si le proxy Cloudflare (nuage orange) est desactive.
EOT
type = bool
default = true
}

View File

@ -0,0 +1,33 @@
terraform {
required_version = ">= 1.6.0"
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.48"
}
}
# Backend distant recommande des que vous n'etes plus seul sur le projet.
# Par defaut le state reste local : il contient des donnees sensibles
# (IPs, ids), il est donc gitignore. Sauvegardez-le dans votre coffre-fort.
#
# Pour passer sur un backend S3 (Hetzner Object Storage compatible S3) :
#
# backend "s3" {
# bucket = "xpeditis-prod-tfstate"
# key = "prod/terraform.tfstate"
# region = "fsn1"
# endpoints = { s3 = "https://fsn1.your-objectstorage.com" }
# skip_credentials_validation = true
# skip_region_validation = true
# skip_requesting_account_id = true
# skip_s3_checksum = true
# use_path_style = true
# encrypt = true
# }
}
provider "hcloud" {
token = var.hcloud_token
}