merge: integrer feat/mise-en-prod

This commit is contained in:
David 2026-09-07 21:42:36 +02:00
commit 7df9fd41c1
82 changed files with 11499 additions and 117 deletions

View File

@ -1,40 +1,52 @@
name: CD Production name: CD Production
# Production pipeline — Hetzner k3s. # Pipeline de production — Hetzner k3s (infra/prod/).
# #
# SECURITY: Two mandatory gates before any production deployment: # Enchaînement : qualité → vérification → promotion/rebuild → déploiement → contrôle
# 1. quality-gate — lint + unit tests on the exact commit being deployed
# 2. verify-image — confirms preprod-SHA image EXISTS in registry,
# which proves this commit passed the full preprod
# pipeline (lint + unit + integration + docker build).
# If someone merges to main without going through preprod,
# this step fails and the deployment is blocked.
# #
# Flow: quality-gate → verify-image → promote → deploy → notify # TROIS RÈGLES STRUCTURANTES
# #
# Secrets required: # 1. Le BACKEND est PROMU depuis la preprod, jamais reconstruit.
# REGISTRY_TOKEN — Scaleway registry (read/write) # Promouvoir garantit que le binaire déployé en production est exactement
# HETZNER_KUBECONFIG — base64: cat ~/.kube/kubeconfig-xpeditis-prod | base64 -w 0 # celui qui a passé la chaîne de preprod (lint, tests unitaires, tests
# PROD_BACKEND_URL — https://api.xpeditis.com # d'intégration, build). Un rebuild casserait cette garantie.
# PROD_FRONTEND_URL — https://app.xpeditis.com #
# DISCORD_WEBHOOK_URL # 2. Le FRONTEND est RECONSTRUIT pour la production.
# next.config.js fige NEXT_PUBLIC_API_URL au moment du build. Promouvoir
# l'image de preprod livrerait une application qui appelle
# api.preprod.xpeditis.com en production. C'est la raison pour laquelle ce
# workflow ne peut pas se contenter de re-taguer.
#
# 3. Le déploiement passe par SSH, pas par l'API Kubernetes.
# L'API k3s (6443) n'est ouverte qu'aux IP d'administration. Les runners
# GitHub n'ont pas d'IP fixe : le job ouvre le port 22 pour la seule IP du
# runner via un firewall Hetzner dédié, puis le referme systématiquement.
#
# Secrets et variables : voir infra/prod/env/github-secrets.md
on: on:
push: push:
branches: [main] branches: [main]
workflow_dispatch:
inputs:
tag:
description: "SHA court à déployer (laisser vide = HEAD de main)"
required: false
concurrency: concurrency:
group: cd-production group: cd-production
cancel-in-progress: false cancel-in-progress: false
permissions:
contents: read
env: env:
REGISTRY: rg.fr-par.scw.cloud/weworkstudio REGISTRY: rg.fr-par.scw.cloud/weworkstudio
NODE_VERSION: '20' NODE_VERSION: '20'
K8S_NAMESPACE: xpeditis-prod K8S_NAMESPACE: xpeditis-prod
jobs: jobs:
# ── 1. Quality Gate ────────────────────────────────────────────────── # ═══ 1. Qualité ══════════════════════════════════════════════════════════
# Runs on every prod deployment regardless of what happened in preprod.
backend-quality: backend-quality:
name: Backend — Lint name: Backend — Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -69,7 +81,7 @@ jobs:
- run: npm run type-check - run: npm run type-check
backend-tests: backend-tests:
name: Backend — Unit Tests name: Backend — Tests unitaires
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: backend-quality needs: backend-quality
defaults: defaults:
@ -86,7 +98,7 @@ jobs:
- run: npm test -- --passWithNoTests - run: npm test -- --passWithNoTests
frontend-tests: frontend-tests:
name: Frontend — Unit Tests name: Frontend — Tests unitaires
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: frontend-quality needs: frontend-quality
defaults: defaults:
@ -102,175 +114,248 @@ jobs:
- run: npm ci --legacy-peer-deps - run: npm ci --legacy-peer-deps
- run: npm test -- --passWithNoTests - run: npm test -- --passWithNoTests
# ── 2. Image Verification ──────────────────────────────────────────── # ═══ 2. Vérification de la provenance ════════════════════════════════════
# Checks that preprod-SHA tags exist for this EXACT commit. # Si l'image preprod-SHA n'existe pas, c'est que ce commit n'est jamais passé
# This is the security gate: if the preprod pipeline never ran for this # par la chaîne de preprod. Le déploiement est alors bloqué net.
# commit (or failed before the docker build step), this job fails and
# the deployment is fully blocked.
verify-image: verify-image:
name: Verify Preprod Image Exists name: Vérifier l'image de preprod
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [backend-tests, frontend-tests] needs: [backend-tests, frontend-tests]
outputs: outputs:
sha: ${{ steps.sha.outputs.short }} sha: ${{ steps.sha.outputs.short }}
steps: steps:
- name: Short SHA - name: SHA court
id: sha id: sha
run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT run: |
RAW="${{ github.event.inputs.tag }}"
[ -n "$RAW" ] || RAW="${{ github.sha }}"
echo "short=$(echo "$RAW" | cut -c1-7)" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3 - uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: nologin username: nologin
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- name: Check backend image preprod-SHA - name: Image backend preprod-SHA présente
run: | run: |
TAG="${{ env.REGISTRY }}/xpeditis-backend:preprod-${{ steps.sha.outputs.short }}" TAG="${{ env.REGISTRY }}/xpeditis-backend:preprod-${{ steps.sha.outputs.short }}"
echo "Verifying: $TAG"
docker buildx imagetools inspect "$TAG" || { docker buildx imagetools inspect "$TAG" || {
echo "" echo "::error::$TAG introuvable. Ce commit n'a pas été construit par la chaîne de preprod."
echo "BLOCKED: Image $TAG not found in registry." echo "Fusionnez d'abord sur preprod et attendez que le pipeline passe au vert."
echo "This commit was not built by the preprod pipeline."
echo "Merge to preprod first and wait for the full pipeline to succeed."
exit 1 exit 1
} }
- name: Check frontend image preprod-SHA - name: Image log-exporter preprod-SHA présente
run: | run: |
TAG="${{ env.REGISTRY }}/xpeditis-frontend:preprod-${{ steps.sha.outputs.short }}" TAG="${{ env.REGISTRY }}/xpeditis-log-exporter:preprod-${{ steps.sha.outputs.short }}"
echo "Verifying: $TAG"
docker buildx imagetools inspect "$TAG" || { docker buildx imagetools inspect "$TAG" || {
echo "" echo "::error::$TAG introuvable."
echo "BLOCKED: Image $TAG not found in registry."
echo "This commit was not built by the preprod pipeline."
echo "Merge to preprod first and wait for the full pipeline to succeed."
exit 1 exit 1
} }
# ── 3. Promote Images ──────────────────────────────────────────────── # ═══ 3a. Promotion du backend (aucun rebuild) ════════════════════════════
# Re-tags preprod-SHA → latest + prod-SHA within Scaleway. promote-backend:
# No rebuild. No layer transfer. Manifest-level operation only. name: Promouvoir le backend
promote-images:
name: Promote Images (preprod-SHA → prod)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: verify-image needs: verify-image
steps: steps:
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3 - uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: nologin username: nologin
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- name: preprod-SHA → prod-SHA
- name: Promote backend
run: | run: |
SHA="${{ needs.verify-image.outputs.sha }}" SHA="${{ needs.verify-image.outputs.sha }}"
# Opération au niveau du manifeste : aucune couche n'est retransférée,
# le condensat de l'image reste identique à celui validé en preprod.
docker buildx imagetools create \ docker buildx imagetools create \
--tag ${{ env.REGISTRY }}/xpeditis-backend:latest \
--tag ${{ env.REGISTRY }}/xpeditis-backend:prod-${SHA} \ --tag ${{ env.REGISTRY }}/xpeditis-backend:prod-${SHA} \
--tag ${{ env.REGISTRY }}/xpeditis-backend:latest \
${{ env.REGISTRY }}/xpeditis-backend:preprod-${SHA} ${{ env.REGISTRY }}/xpeditis-backend:preprod-${SHA}
echo "Backend promoted: preprod-${SHA} → latest + prod-${SHA}"
- name: Promote frontend
run: |
SHA="${{ needs.verify-image.outputs.sha }}"
docker buildx imagetools create \ docker buildx imagetools create \
--tag ${{ env.REGISTRY }}/xpeditis-frontend:latest \ --tag ${{ env.REGISTRY }}/xpeditis-log-exporter:prod-${SHA} \
--tag ${{ env.REGISTRY }}/xpeditis-frontend:prod-${SHA} \ --tag ${{ env.REGISTRY }}/xpeditis-log-exporter:latest \
${{ env.REGISTRY }}/xpeditis-frontend:preprod-${SHA} ${{ env.REGISTRY }}/xpeditis-log-exporter:preprod-${SHA}
echo "Frontend promoted: preprod-${SHA} → latest + prod-${SHA}"
# ── 4. Deploy to k3s ───────────────────────────────────────────────── # ═══ 3b. Reconstruction du frontend avec les URLs de production ══════════
deploy: build-frontend:
name: Deploy to Production (k3s) name: Reconstruire le frontend (URLs de production)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [verify-image, promote-images] needs: verify-image
steps:
- uses: actions/checkout@v4
with:
# On construit EXACTEMENT le commit vérifié, pas HEAD.
ref: ${{ github.sha }}
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: nologin
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: ./apps/frontend
file: ./apps/frontend/Dockerfile
push: true
platforms: linux/amd64
tags: |
${{ env.REGISTRY }}/xpeditis-frontend:prod-${{ needs.verify-image.outputs.sha }}
${{ env.REGISTRY }}/xpeditis-frontend:latest
cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache-prod
cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache-prod,mode=max
build-args: |
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL_PROD }}
NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL_PROD }}
- name: Contrôle — l'URL de preprod ne doit pas figurer dans le bundle
run: |
IMAGE="${{ env.REGISTRY }}/xpeditis-frontend:prod-${{ needs.verify-image.outputs.sha }}"
CID=$(docker create "$IMAGE")
docker cp "$CID:/app/.next" /tmp/next-check 2>/dev/null || true
docker rm "$CID" >/dev/null
if grep -rq "api.preprod.xpeditis.com" /tmp/next-check 2>/dev/null; then
echo "::error::L'URL de preprod est figée dans le bundle de production."
echo "Vérifiez le secret NEXT_PUBLIC_API_URL_PROD."
exit 1
fi
echo "Aucune URL de preprod dans le bundle."
# ═══ 4. Déploiement ══════════════════════════════════════════════════════
deploy:
name: Déployer en production
runs-on: ubuntu-latest
needs: [verify-image, promote-backend, build-frontend]
# Environnement protégé : activez « Required reviewers » pour exiger une
# validation humaine avant toute mise en production.
environment: environment:
name: production name: production
url: https://app.xpeditis.com url: https://app.xpeditis.com
steps: steps:
- name: Configure kubectl - uses: actions/checkout@v4
run: |
mkdir -p ~/.kube
echo "${{ secrets.HETZNER_KUBECONFIG }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
kubectl cluster-info
kubectl get nodes -o wide
- name: Deploy backend - name: Installer le client Hetzner
id: deploy-backend run: |
curl -fsSL https://github.com/hetznercloud/cli/releases/download/v1.49.0/hcloud-linux-amd64.tar.gz \
| tar -xz -C /tmp hcloud
sudo install -m 0755 /tmp/hcloud /usr/local/bin/hcloud
hcloud version
- name: Ouvrir le port 22 pour l'IP de ce runner
env:
HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN_CICD }}
run: |
RUNNER_IP="$(curl -fsS --max-time 10 https://ifconfig.me)"
echo "IP du runner : ${RUNNER_IP}"
cat > /tmp/fw-open.json <<JSON
[{
"direction": "in",
"protocol": "tcp",
"port": "22",
"source_ips": ["${RUNNER_IP}/32"],
"description": "GitHub Actions run ${{ github.run_id }}"
}]
JSON
hcloud firewall replace-rules "${{ vars.HCLOUD_CICD_FIREWALL }}" --rules-file /tmp/fw-open.json
- name: Préparer SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
# Empreinte épinglée : un détournement DNS ou BGP ne peut pas
# rediriger le déploiement vers une machine tierce.
echo "${{ secrets.PROD_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Synchroniser infra/prod sur le serveur
run: |
rsync -az --delete \
--exclude '.terraform' --exclude '*.tfstate*' --exclude '*.tfvars' \
-e "ssh -o StrictHostKeyChecking=yes -i ~/.ssh/id_ed25519" \
infra/prod/ \
"${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }}:/opt/xpeditis/infra-prod/"
- name: Déployer
id: deploy
run: | run: |
SHA="${{ needs.verify-image.outputs.sha }}" SHA="${{ needs.verify-image.outputs.sha }}"
IMAGE="${{ env.REGISTRY }}/xpeditis-backend:prod-${SHA}" ssh -o StrictHostKeyChecking=yes -i ~/.ssh/id_ed25519 \
echo "Deploying: $IMAGE" "${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }}" \
kubectl set image deployment/xpeditis-backend backend="$IMAGE" -n ${{ env.K8S_NAMESPACE }} "deploy prod-${SHA}"
kubectl rollout status deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} --timeout=300s
echo "Backend rollout complete."
- name: Deploy frontend - name: Tests de fumée depuis l'extérieur
id: deploy-frontend env:
PROD_API_URL: ${{ vars.PROD_API_URL }}
PROD_APP_URL: ${{ vars.PROD_APP_URL }}
run: bash infra/prod/scripts/smoke-test.sh
- name: Retour arrière si le déploiement a échoué
if: failure() && steps.deploy.conclusion == 'failure'
run: | run: |
SHA="${{ needs.verify-image.outputs.sha }}" ssh -o StrictHostKeyChecking=yes -i ~/.ssh/id_ed25519 \
IMAGE="${{ env.REGISTRY }}/xpeditis-frontend:prod-${SHA}" "${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }}" \
echo "Deploying: $IMAGE" "rollback" || true
kubectl set image deployment/xpeditis-frontend frontend="$IMAGE" -n ${{ env.K8S_NAMESPACE }}
kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=300s
echo "Frontend rollout complete."
- name: Auto-rollback on deployment failure - name: Refermer le firewall
if: failure() # `always()` : la fenêtre d'exposition se referme même si le
# déploiement a échoué, si le job a été annulé ou s'il a expiré.
if: always()
env:
HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN_CICD }}
run: | run: |
echo "Deployment failed — initiating rollback..." echo '[]' > /tmp/fw-close.json
kubectl rollout undo deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} hcloud firewall replace-rules "${{ vars.HCLOUD_CICD_FIREWALL }}" --rules-file /tmp/fw-close.json
kubectl rollout undo deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} echo "Firewall CI refermé."
kubectl rollout status deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} --timeout=120s
kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=120s
echo "Rollback complete. Previous version is live."
# ── Notifications ──────────────────────────────────────────────────── - name: Effacer la clé SSH
if: always()
run: shred -u ~/.ssh/id_ed25519 2>/dev/null || rm -f ~/.ssh/id_ed25519
# ═══ 5. Notifications ════════════════════════════════════════════════════
notify-success: notify-success:
name: Notify Success name: Notifier le succès
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [verify-image, deploy] needs: [verify-image, deploy]
if: success() if: success()
steps: steps:
- run: | - run: |
curl -s -H "Content-Type: application/json" -d '{ curl -sf -H "Content-Type: application/json" -d '{
"embeds": [{ "embeds": [{
"title": "🚀 Production Deployed & Healthy", "title": "Production déployée et saine",
"color": 3066993, "color": 3066993,
"fields": [ "fields": [
{"name": "Author", "value": "${{ github.actor }}", "inline": true}, {"name": "Auteur", "value": "${{ github.actor }}", "inline": true},
{"name": "Version", "value": "`prod-${{ needs.verify-image.outputs.sha }}`", "inline": true}, {"name": "Version", "value": "`prod-${{ needs.verify-image.outputs.sha }}`", "inline": true},
{"name": "Cluster", "value": "Hetzner k3s — `xpeditis-prod`", "inline": false}, {"name": "Cible", "value": "Hetzner k3s — xpeditis-prod", "inline": false},
{"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false} {"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false}
], ],
"footer": {"text": "Xpeditis CI/CD • Production"} "footer": {"text": "Xpeditis CI/CD - Production"}
}] }]
}' ${{ secrets.DISCORD_WEBHOOK_URL }} }' ${{ secrets.DISCORD_WEBHOOK_URL }}
notify-failure: notify-failure:
name: Notify Failure name: Notifier l'échec
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, verify-image, promote-images, deploy] needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, verify-image, promote-backend, build-frontend, deploy]
if: failure() if: failure()
steps: steps:
- run: | - run: |
curl -s -H "Content-Type: application/json" -d '{ curl -sf -H "Content-Type: application/json" -d '{
"content": "@here PRODUCTION PIPELINE FAILED", "content": "@here ECHEC DU PIPELINE DE PRODUCTION",
"embeds": [{ "embeds": [{
"title": "🔴 Production Pipeline Failed", "title": "Pipeline de production en échec",
"description": "Check the workflow for details. Auto-rollback was triggered if the failure was during deploy.", "description": "Un retour arrière a été tenté si l échec est survenu pendant le déploiement. Vérifiez l état réel avant toute nouvelle tentative.",
"color": 15158332, "color": 15158332,
"fields": [ "fields": [
{"name": "Author", "value": "${{ github.actor }}", "inline": true}, {"name": "Auteur", "value": "${{ github.actor }}", "inline": true},
{"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false}, {"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false},
{"name": "Rollback", "value": "[Run rollback workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/rollback.yml)", "inline": false} {"name": "A vérifier", "value": "Le firewall CI est-il bien refermé ? `hcloud firewall describe xpeditis-prod-fw-cicd`", "inline": false}
], ],
"footer": {"text": "Xpeditis CI/CD • Production"} "footer": {"text": "Xpeditis CI/CD - Production"}
}] }]
}' ${{ secrets.DISCORD_WEBHOOK_URL }} }' ${{ secrets.DISCORD_WEBHOOK_URL }}

View File

@ -91,3 +91,27 @@ STRIPE_GOLD_MONTHLY_PRICE_ID=
STRIPE_GOLD_YEARLY_PRICE_ID= STRIPE_GOLD_YEARLY_PRICE_ID=
STRIPE_PLATINIUM_MONTHLY_PRICE_ID= STRIPE_PLATINIUM_MONTHLY_PRICE_ID=
STRIPE_PLATINIUM_YEARLY_PRICE_ID= STRIPE_PLATINIUM_YEARLY_PRICE_ID=
# Premier administrateur (amorcage) - migration BootstrapAdminFromEnv
# En developpement, laissez vide : SeedTestUsers cree deja admin@xpeditis.com.
# En production, renseignez une adresse RELEVABLE : le compte est cree sans
# mot de passe utilisable et vous definissez le votre via "mot de passe oublie".
# BOOTSTRAP_ADMIN_EMAIL=
# BOOTSTRAP_ADMIN_FIRST_NAME=Admin
# BOOTSTRAP_ADMIN_LAST_NAME=Xpeditis
# BOOTSTRAP_ADMIN_ORG_NAME=Xpeditis
# BOOTSTRAP_ADMIN_ORG_STREET=A completer
# BOOTSTRAP_ADMIN_ORG_CITY=A completer
# BOOTSTRAP_ADMIN_ORG_POSTAL_CODE=00000
# BOOTSTRAP_ADMIN_ORG_COUNTRY=FR
# Facultatif : hash Argon2id, si SMTP n'est pas encore operationnel.
# Generer avec : node scripts/setup/generate-admin-hash.js
# Jamais un mot de passe en clair - la migration le refuse.
# BOOTSTRAP_ADMIN_PASSWORD_HASH=
# Force la neutralisation des comptes de demonstration hors production.
# FORCE_NEUTRALIZE_SEED_ACCOUNTS=true
# Trade assistant — server only. Empty key enables guided help only.
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4.1-mini

View File

@ -0,0 +1,129 @@
#!/usr/bin/env node
/**
* Génère un hash Argon2id pour BOOTSTRAP_ADMIN_PASSWORD_HASH.
*
* cd apps/backend && node scripts/setup/generate-admin-hash.js
*
* Le mot de passe est saisi sans écho et ne quitte jamais votre poste : ni
* argument de ligne de commande (visible dans `ps` et dans l'historique du
* shell), ni variable d'environnement, ni fichier temporaire.
*
* RAPPEL — le mode SANS mot de passe est préférable.
* Si votre chaîne SMTP fonctionne, ne renseignez que BOOTSTRAP_ADMIN_EMAIL :
* le compte est alors créé sans mot de passe utilisable et vous le définissez
* via « mot de passe oublié ». Aucun secret n'existe nulle part, il n'y a donc
* rien à faire fuiter. Ce script n'est utile que si vous devez pouvoir vous
* connecter avant que l'envoi de courriels ne soit opérationnel.
*/
'use strict';
const argon2 = require('argon2');
const readline = require('readline');
// Mêmes paramètres que auth.service.ts : un hash produit ici est vérifiable
// par l'application sans aucune adaptation.
const ARGON2_OPTIONS = {
type: argon2.argon2id,
memoryCost: 65536, // 64 Mo
timeCost: 3,
parallelism: 4,
};
const MIN_LENGTH = 16;
/** Saisie masquée sur un terminal ; lecture directe si l'entrée est redirigée. */
function readSecret(prompt) {
return new Promise((resolve, reject) => {
if (!process.stdin.isTTY) {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => (data += chunk));
process.stdin.on('end', () => resolve(data.replace(/\r?\n$/, '')));
process.stdin.on('error', reject);
return;
}
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const onKeypress = () => {
// Réécrit la ligne sans révéler la longueur de la saisie.
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
process.stdout.write(prompt);
};
process.stdout.write(prompt);
process.stdin.on('data', onKeypress);
rl.question('', answer => {
process.stdin.removeListener('data', onKeypress);
rl.close();
process.stdout.write('\n');
resolve(answer);
});
});
}
function checkStrength(password) {
const problems = [];
if (password.length < MIN_LENGTH) {
problems.push(`au moins ${MIN_LENGTH} caractères (${password.length} fournis)`);
}
if (!/[a-z]/.test(password)) problems.push('une minuscule');
if (!/[A-Z]/.test(password)) problems.push('une majuscule');
if (!/[0-9]/.test(password)) problems.push('un chiffre');
if (!/[^A-Za-z0-9]/.test(password)) problems.push('un caractère spécial');
return problems;
}
async function main() {
console.log('');
console.log('Génération du hash Argon2id pour le premier administrateur.');
console.log('La saisie n’est pas affichée.');
console.log('');
const password = await readSecret('Mot de passe : ');
if (!password) {
console.error('Aucun mot de passe saisi.');
process.exit(1);
}
if (process.stdin.isTTY) {
const confirmation = await readSecret('Confirmation : ');
if (confirmation !== password) {
console.error('Les deux saisies diffèrent.');
process.exit(1);
}
}
const problems = checkStrength(password);
if (problems.length > 0) {
console.error('');
console.error('Mot de passe refusé. Il manque : ' + problems.join(', ') + '.');
console.error('Ce compte a tous les droits sur la plateforme : générez plutôt une');
console.error('phrase longue et aléatoire depuis votre gestionnaire de mots de passe.');
process.exit(1);
}
const hash = await argon2.hash(password, ARGON2_OPTIONS);
console.log('');
console.log('Hash à placer dans le Secret Kubernetes (jamais dans le ConfigMap) :');
console.log('');
console.log(' BOOTSTRAP_ADMIN_PASSWORD_HASH: ' + JSON.stringify(hash));
console.log('');
console.log(' cd infra/prod && sops k8s/base/03-secrets.sops.yaml');
console.log('');
console.log('Après votre première connexion :');
console.log(' 1. changez le mot de passe depuis l’interface ;');
console.log(' 2. retirez BOOTSTRAP_ADMIN_PASSWORD_HASH du Secret et réappliquez.');
console.log('');
console.log('Un hash reste attaquable hors ligne : il n’a plus aucune raison');
console.log('de rester stocké une fois le compte opérationnel.');
console.log('');
}
main().catch(error => {
console.error('Échec :', error.message);
process.exit(1);
});

View File

@ -1,9 +1,26 @@
/** /**
* Seed Test Users Migration * Seed Test Users Migration
* *
* Seeds test users for development and testing * Comptes de test pour le developpement et la preprod.
* Password for all users: Password123! * Mot de passe commun : Password123! (hash Argon2id ci-dessous)
* Hash generated with Argon2id *
* NE S'EXECUTE JAMAIS EN PRODUCTION
* ---------------------------------
* Ce fichier contient un mot de passe en clair pour un compte ADMIN. Sur une
* base de production, l'appliquer creerait un administrateur aux identifiants
* publics, connus de quiconque a lu le depot. La garde NODE_ENV ci-dessous
* l'en empeche.
*
* Le corps de la migration a ete modifie apres son ecriture initiale, ce qui
* deroge a la regle "ne jamais modifier une migration appliquee". C'est sans
* consequence ici : TypeORM suit les migrations par NOM de classe et ne
* recalcule aucune empreinte. Les bases ou elle a deja tourne (dev, preprod) ne
* la rejouent pas et gardent leurs comptes de test ; seules les bases neuves
* voient la garde s'appliquer.
*
* Filet de securite pour les bases ou elle aurait deja tourne :
* migration 1756000000000-NeutralizeSeedAccountsInProduction.
* Creation d'un vrai administrateur : 1756000000001-BootstrapAdminFromEnv.
*/ */
import { MigrationInterface, QueryRunner } from 'typeorm'; import { MigrationInterface, QueryRunner } from 'typeorm';
@ -11,6 +28,14 @@ import { DEFAULT_ORG_ID } from '../seeds/test-organizations.seed';
export class SeedTestUsers1730000000007 implements MigrationInterface { export class SeedTestUsers1730000000007 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
if (process.env.NODE_ENV === 'production') {
console.log(
'SeedTestUsers ignore : NODE_ENV=production. ' +
'Utilisez BOOTSTRAP_ADMIN_EMAIL pour creer le premier administrateur.'
);
return;
}
// Use fixed organization ID from seed // Use fixed organization ID from seed
const organizationId = DEFAULT_ORG_ID; const organizationId = DEFAULT_ORG_ID;

View File

@ -0,0 +1,119 @@
/**
* Neutralise les comptes de démonstration en production.
*
* POURQUOI
* --------
* La migration 1730000000007-SeedTestUsers insère trois comptes dont le mot de
* passe (`Password123!`) est écrit en clair dans le dépôt, dont un ADMIN.
* Sur une base de production neuve, appliquer les migrations créait donc un
* administrateur aux identifiants publics.
*
* SeedTestUsers ne s'exécute désormais plus en production (garde ajoutée dans
* cette même migration). Ce filet de sécurité couvre les cas restants :
* - une base de production migrée avant l'ajout de la garde ;
* - un environnement où NODE_ENV n'était pas correctement positionné ;
* - une restauration à partir d'une sauvegarde antérieure.
*
* Les lignes ne sont PAS supprimées : `audit_logs` et d'autres tables peuvent y
* référer, et une suppression en cascade ferait plus de dégâts que de bien.
* Les comptes sont renommés (ce qui libère `admin@xpeditis.com` pour votre vrai
* compte), rendus impossibles à authentifier, et désactivés.
*
* Idempotente : une seconde exécution ne trouve plus rien à faire.
*
* En développement et en preprod, cette migration ne fait rien — les comptes de
* test restent utilisables. Pour l'y forcer malgré tout :
* FORCE_NEUTRALIZE_SEED_ACCOUNTS=true
*/
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as crypto from 'crypto';
import * as argon2 from 'argon2';
/** Paramètres Argon2id du projet (cf. auth.service.ts). */
const ARGON2_OPTIONS = {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
} as const;
const SEED_ACCOUNTS = ['admin@xpeditis.com', 'manager@xpeditis.com', 'user@xpeditis.com'];
/**
* Produit un hash Argon2id valide d'un secret aléatoire immédiatement perdu.
*
* Un hash *syntaxiquement valide* est indispensable : `auth.service.ts` appelle
* `argon2.verify()` sans try/catch, et une chaîne malformée lèverait une
* exception — donc un 500 au lieu du 401 attendu.
*/
async function unusablePasswordHash(): Promise<string> {
return argon2.hash(crypto.randomBytes(48).toString('hex'), ARGON2_OPTIONS);
}
export class NeutralizeSeedAccountsInProduction1756000000000 implements MigrationInterface {
name = 'NeutralizeSeedAccountsInProduction1756000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const isProduction = process.env.NODE_ENV === 'production';
const forced = process.env.FORCE_NEUTRALIZE_SEED_ACCOUNTS === 'true';
if (!isProduction && !forced) {
console.log('[neutralisation] NODE_ENV != production : comptes de démonstration conservés.');
return;
}
const rows: Array<{ id: string; email: string }> = await queryRunner.query(
`SELECT "id", "email" FROM "users" WHERE "email" = ANY($1)`,
[SEED_ACCOUNTS]
);
if (rows.length === 0) {
console.log('[neutralisation] Aucun compte de démonstration présent.');
return;
}
for (const row of rows) {
// Le nouveau libellé respecte la contrainte chk_users_email
// (LOWER(email) = email) : les UUID sont en minuscules.
const disabledEmail = `seed-disabled-${String(row.id).slice(0, 8)}@invalid.local`;
await queryRunner.query(
`UPDATE "users"
SET "email" = $1,
"password_hash" = $2,
"is_active" = false,
"updated_at" = NOW()
WHERE "id" = $3`,
[disabledEmail, await unusablePasswordHash(), row.id]
);
console.log(`[neutralisation] ${row.email} -> ${disabledEmail} (désactivé)`);
}
// Contrôle explicite : la migration échoue plutôt que de laisser croire
// que le nettoyage a eu lieu.
const remaining: Array<{ n: number }> = await queryRunner.query(
`SELECT count(*)::int AS n FROM "users" WHERE "email" = ANY($1)`,
[SEED_ACCOUNTS]
);
if (remaining[0].n > 0) {
throw new Error(
`Neutralisation incomplète : ${remaining[0].n} compte(s) de démonstration subsistent.`
);
}
console.log(`[neutralisation] ${rows.length} compte(s) neutralisé(s).`);
}
public async down(): Promise<void> {
// Volontairement sans effet.
//
// Restaurer des comptes dont le mot de passe est public serait une
// régression de sécurité déclenchée par un simple `migration:revert`.
// Si vous avez réellement besoin des comptes de démonstration, recréez-les
// dans un environnement non productif.
console.log('[neutralisation] down() sans effet — par conception.');
}
}

View File

@ -0,0 +1,190 @@
/**
* Crée le premier administrateur à partir de l'environnement.
*
* Remplace le compte `admin@xpeditis.com / Password123!` de la migration de
* démonstration : on garde la commodité (une base neuve arrive avec un
* administrateur utilisable) sans le mot de passe public.
*
* DEUX MODES
* ----------
*
* 1. SANS MOT DE PASSE — recommandé.
* BOOTSTRAP_ADMIN_EMAIL=vous@votredomaine.fr
*
* Le compte est créé avec un hash Argon2id d'un secret aléatoire
* immédiatement perdu : personne, pas même vous, ne peut s'y connecter.
* Vous définissez votre mot de passe via « mot de passe oublié », qui envoie
* un jeton à usage unique, valable 1 heure, stocké haché en base.
*
* Aucun secret n'existe donc nulle part : ni dans Git, ni dans le Secret
* Kubernetes, ni dans l'historique du shell, ni dans les journaux de
* migration. C'est la seule variante où il n'y a rien à faire fuiter.
* Effet de bord utile : la réception du courriel prouve que la chaîne SMTP
* fonctionne.
*
* 2. AVEC UN HASH PRÉ-CALCULÉ — si SMTP n'est pas encore opérationnel.
* BOOTSTRAP_ADMIN_EMAIL=vous@votredomaine.fr
* BOOTSTRAP_ADMIN_PASSWORD_HASH=$argon2id$v=19$m=65536,t=3,p=4$...
*
* Le hash se génère hors ligne :
* node apps/backend/scripts/setup/generate-admin-hash.js
* Le mot de passe en clair ne quitte jamais votre poste. Le hash, lui, reste
* sensible (attaque hors ligne possible) : utilisez un mot de passe long et
* aléatoire, changez-le après la première connexion, puis retirez la
* variable du Secret.
*
* GARDE-FOUS
* ----------
* - Sans BOOTSTRAP_ADMIN_EMAIL, la migration ne fait rien.
* - S'il existe déjà un ADMIN actif, la migration ne fait rien : elle ne peut
* donc pas créer un second administrateur à votre insu lors d'un déploiement
* ultérieur.
* - Si un compte porte déjà cette adresse, il est promu ADMIN sans que son
* mot de passe ne soit touché.
* - Un mot de passe en clair passé par erreur dans
* BOOTSTRAP_ADMIN_PASSWORD_HASH est refusé : la migration échoue.
*/
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as crypto from 'crypto';
import * as argon2 from 'argon2';
/** Paramètres Argon2id du projet (cf. auth.service.ts). */
const ARGON2_OPTIONS = {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
} as const;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function env(name: string, fallback = ''): string {
return (process.env[name] ?? fallback).trim();
}
export class BootstrapAdminFromEnv1756000000001 implements MigrationInterface {
name = 'BootstrapAdminFromEnv1756000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
const email = env('BOOTSTRAP_ADMIN_EMAIL').toLowerCase();
if (!email) {
console.log(
'[amorçage admin] BOOTSTRAP_ADMIN_EMAIL absent : aucun administrateur créé. ' +
'Inscrivez-vous par l’interface puis promouvez le compte en base.'
);
return;
}
if (!EMAIL_PATTERN.test(email)) {
throw new Error(`[amorçage admin] BOOTSTRAP_ADMIN_EMAIL invalide : "${email}"`);
}
// Ne jamais créer un second administrateur silencieusement.
const activeAdmins: Array<{ n: number }> = await queryRunner.query(
`SELECT count(*)::int AS n FROM "users" WHERE "role" = 'ADMIN' AND "is_active" = true`
);
if (activeAdmins[0].n > 0) {
console.log(
`[amorçage admin] ${activeAdmins[0].n} administrateur(s) actif(s) déjà présent(s) : rien à faire.`
);
return;
}
// --- Compte déjà existant : promotion, sans toucher au mot de passe ------
const existing: Array<{ id: string }> = await queryRunner.query(
`SELECT "id" FROM "users" WHERE "email" = $1`,
[email]
);
if (existing.length > 0) {
await queryRunner.query(
`UPDATE "users"
SET "role" = 'ADMIN', "is_active" = true, "updated_at" = NOW()
WHERE "id" = $1`,
[existing[0].id]
);
console.log(`[amorçage admin] Compte existant ${email} promu ADMIN (mot de passe inchangé).`);
return;
}
// --- Organisation de rattachement ---------------------------------------
// users.organization_id est NOT NULL avec clé étrangère : il faut une
// organisation avant de pouvoir créer l'administrateur.
const orgName = env('BOOTSTRAP_ADMIN_ORG_NAME', 'Xpeditis');
const orgCountry = env('BOOTSTRAP_ADMIN_ORG_COUNTRY', 'FR').toUpperCase();
if (!/^[A-Z]{2}$/.test(orgCountry)) {
throw new Error(
`[amorçage admin] BOOTSTRAP_ADMIN_ORG_COUNTRY doit être un code ISO à 2 lettres, reçu "${orgCountry}"`
);
}
const org: Array<{ id: string }> = await queryRunner.query(
`INSERT INTO "organizations"
("name", "type", "address_street", "address_city", "address_postal_code", "address_country")
VALUES ($1, 'FREIGHT_FORWARDER', $2, $3, $4, $5)
ON CONFLICT ("name") DO UPDATE SET "updated_at" = NOW()
RETURNING "id"`,
[
orgName,
env('BOOTSTRAP_ADMIN_ORG_STREET', 'A completer'),
env('BOOTSTRAP_ADMIN_ORG_CITY', 'A completer'),
env('BOOTSTRAP_ADMIN_ORG_POSTAL_CODE', '00000'),
orgCountry,
]
);
const organizationId = org[0].id;
// --- Mot de passe --------------------------------------------------------
const providedHash = env('BOOTSTRAP_ADMIN_PASSWORD_HASH');
let passwordHash: string;
let mode: string;
if (providedHash) {
if (!providedHash.startsWith('$argon2')) {
throw new Error(
'[amorçage admin] BOOTSTRAP_ADMIN_PASSWORD_HASH doit contenir un hash Argon2 ' +
'(commençant par "$argon2"), jamais un mot de passe en clair. ' +
'Générez-le avec scripts/setup/generate-admin-hash.js.'
);
}
passwordHash = providedHash;
mode = 'hash fourni par l’environnement';
} else {
// Hash d'un secret aléatoire immédiatement perdu : le compte existe, il
// est actif, mais aucun mot de passe ne peut y correspondre.
passwordHash = await argon2.hash(crypto.randomBytes(48).toString('hex'), ARGON2_OPTIONS);
mode = 'aucun mot de passe — à définir via « mot de passe oublié »';
}
await queryRunner.query(
`INSERT INTO "users"
("organization_id", "email", "password_hash", "role",
"first_name", "last_name", "is_email_verified", "is_active")
VALUES ($1, $2, $3, 'ADMIN', $4, $5, true, true)`,
[
organizationId,
email,
passwordHash,
env('BOOTSTRAP_ADMIN_FIRST_NAME', 'Admin'),
env('BOOTSTRAP_ADMIN_LAST_NAME', 'Xpeditis'),
]
);
console.log(`[amorçage admin] Administrateur ${email} créé (${mode}).`);
if (!providedHash) {
console.log(
'[amorçage admin] Étape suivante : POST /api/v1/auth/forgot-password avec cette adresse, ' +
'puis suivez le lien reçu par courriel pour définir le mot de passe.'
);
}
}
public async down(): Promise<void> {
// Volontairement sans effet : supprimer l'unique administrateur d'une
// production sur un `migration:revert` serait pire que le problème résolu.
console.log('[amorçage admin] down() sans effet — par conception.');
}
}

View File

@ -2,7 +2,8 @@
# Xpeditis — Full Dev Stack (infrastructure + app + logging) # Xpeditis — Full Dev Stack (infrastructure + app + logging)
# #
# Usage: # Usage:
# docker-compose -f docker-compose.full.yml up -d # docker network inspect xpeditis-network >/dev/null 2>&1 || docker network create xpeditis-network
# docker compose -f docker/docker-compose.full.yml up -d --build
# #
# Exposed ports: # Exposed ports:
# - Frontend: http://localhost:3000 # - Frontend: http://localhost:3000
@ -28,7 +29,7 @@ services:
POSTGRES_USER: xpeditis POSTGRES_USER: xpeditis
POSTGRES_PASSWORD: xpeditis_dev_password POSTGRES_PASSWORD: xpeditis_dev_password
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U xpeditis"] test: ["CMD-SHELL", "pg_isready -U xpeditis -d xpeditis_dev"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@ -252,3 +253,4 @@ volumes:
networks: networks:
xpeditis-network: xpeditis-network:
name: xpeditis-network name: xpeditis-network
external: true

View File

@ -40,14 +40,48 @@ Documentation complète de la plateforme B2B SaaS de réservation de fret mariti
--- ---
## Déploiement ## Mise en production
**[mise-en-prod/](mise-en-prod/README.md)** — procédure complète, pas à pas, pour
ouvrir la plateforme au public sur Hetzner. C'est le point d'entrée à suivre
pour un déploiement réel. Les fichiers correspondants sont dans
[`infra/prod/`](../infra/prod/README.md).
| Étape | Fichier |
|---|---|
| Index, chronologie, blocages identifiés | [mise-en-prod/README.md](mise-en-prod/README.md) |
| Prérequis (comptes, outils, clés) | [mise-en-prod/01-prerequis.md](mise-en-prod/01-prerequis.md) |
| Provisioning Hetzner (Terraform) | [mise-en-prod/02-provisioning-hetzner.md](mise-en-prod/02-provisioning-hetzner.md) |
| Durcissement des serveurs | [mise-en-prod/03-durcissement-serveurs.md](mise-en-prod/03-durcissement-serveurs.md) |
| Nœud de données (PostgreSQL, Redis) | [mise-en-prod/04-noeud-donnees.md](mise-en-prod/04-noeud-donnees.md) |
| Cluster k3s | [mise-en-prod/05-cluster-k3s.md](mise-en-prod/05-cluster-k3s.md) |
| Secrets (SOPS + age) | [mise-en-prod/06-secrets-sops.md](mise-en-prod/06-secrets-sops.md) |
| Stockage objet | [mise-en-prod/07-stockage-objet-s3.md](mise-en-prod/07-stockage-objet-s3.md) |
| DNS, TLS, Cloudflare | [mise-en-prod/08-dns-tls-cloudflare.md](mise-en-prod/08-dns-tls-cloudflare.md) |
| Déploiement applicatif | [mise-en-prod/09-deploiement-application.md](mise-en-prod/09-deploiement-application.md) |
| CI/CD GitHub Actions | [mise-en-prod/10-cicd-github-actions.md](mise-en-prod/10-cicd-github-actions.md) |
| Observabilité | [mise-en-prod/11-observabilite.md](mise-en-prod/11-observabilite.md) |
| Sauvegardes et restauration | [mise-en-prod/12-sauvegardes-restauration.md](mise-en-prod/12-sauvegardes-restauration.md) |
| Sécurité | [mise-en-prod/13-securite-durcissement.md](mise-en-prod/13-securite-durcissement.md) |
| Runbook de mise en ligne | [mise-en-prod/14-runbook-go-live.md](mise-en-prod/14-runbook-go-live.md) |
| Exploitation et incidents | [mise-en-prod/15-exploitation-incidents.md](mise-en-prod/15-exploitation-incidents.md) |
| RGPD et conformité | [mise-en-prod/16-rgpd-conformite.md](mise-en-prod/16-rgpd-conformite.md) |
---
## Déploiement — autres environnements
| Sujet | Fichier | | Sujet | Fichier |
|---|---| |---|---|
| Portainer / Docker Swarm | [deployment/portainer.md](deployment/portainer.md) | | Preprod : Portainer / Docker Swarm | [deployment/portainer.md](deployment/portainer.md) |
| Hetzner / Kubernetes | [deployment/hetzner/README.md](deployment/hetzner/README.md) | | Étude Hetzner / Kubernetes (antérieure) | [deployment/hetzner/README.md](deployment/hetzner/README.md) |
| Stripe (paiements) | [deployment/STRIPE_SETUP.md](deployment/STRIPE_SETUP.md) | | Stripe (paiements) | [deployment/STRIPE_SETUP.md](deployment/STRIPE_SETUP.md) |
> `deployment/hetzner/` est l'étude de cadrage qui a précédé la mise en œuvre.
> Elle reste utile pour comprendre les arbitrages, mais **la procédure à suivre
> est `mise-en-prod/`**, seule alignée sur les fichiers réellement livrés dans
> `infra/prod/`.
--- ---
## Tests ## Tests

View File

@ -0,0 +1,163 @@
# 01 — Prérequis
**Durée : 2 à 3 h.** Rien de technique ici, mais tout bloque si un élément
manque au moment où vous en avez besoin.
---
## 1. Comptes à créer ou vérifier
| Service | Rôle | À faire | Coût |
|---|---|---|---|
| **Hetzner Cloud** | Serveurs, réseau, firewalls, volume | Créer un **projet dédié** `xpeditis-prod`, séparé de la preprod. Activer la **2FA**. | ~50 €/mois |
| **Hetzner Storage Box** | Seconde copie des dumps | Commander une **BX11** (1 To). Commande séparée de Hetzner Cloud. | 3,90 €/mois |
| **Hetzner Object Storage** | Documents + archives WAL-G | Activer dans le projet, région `fsn1`. | ~6 €/mois |
| **Cloudflare** | DNS, WAF, anti-DDoS | Zone `xpeditis.com` déléguée. Plan Free suffisant. **2FA obligatoire.** | 0 € |
| **Registrar du domaine** | `xpeditis.com` | Pointer les serveurs de noms vers Cloudflare. Activer le **verrou de transfert**. | ~10 €/an |
| **Scaleway Container Registry** | Images Docker | Déjà utilisé par la preprod. Vérifier que `REGISTRY_TOKEN` est encore valide. | ~1 €/mois |
| **Brevo** | E-mails transactionnels | Créer une **nouvelle clé SMTP de production** (celle de preprod est compromise). Vérifier le domaine expéditeur. | 0 → 19 €/mois |
| **Stripe** | Paiements | Passer le compte en **mode Live**. Récupérer `sk_live_…`, créer le webhook de production, noter les 6 identifiants de tarif. | % du volume |
| **Sentry** | Erreurs applicatives | Projet `xpeditis-prod` distinct de la preprod. | 0 → 26 €/mois |
| **Pappers** | Vérification SIRET | Clé API. Facultatif : sans clé, la vérification est simplement ignorée. | ~3 €/mois |
| **Discord** | Alertes et déploiements | Deux webhooks : `#deploiements` et `#alertes`. | 0 € |
| **healthchecks.io** ou **BetterStack** | Surveillance externe | Un *heartbeat* pour les sauvegardes, un contrôle d'uptime sur `https://app.xpeditis.com`. | 0 € |
> **Pourquoi un projet Hetzner séparé** — le token API est valable pour tout un
> projet. Un token de preprod compromis ne doit pas pouvoir détruire la
> production. La séparation est aussi ce qui isole les deux réseaux privés.
---
## 2. Décisions à trancher maintenant
### 2.1 Adresse e-mail d'exploitation
Une adresse **relevée** est nécessaire pour :
- Let's Encrypt (avertissements d'expiration si le renouvellement casse) ;
- Hetzner (incidents, maintenances) ;
- Cloudflare et le registrar.
Recommandé : `ops@xpeditis.com`, redirigée vers votre boîte personnelle.
Remplacez `ops@xpeditis.com` dans `infra/prod/k8s/cluster/cluster-issuer.yaml`
si vous choisissez autre chose.
### 2.2 IP d'administration
Terraform refuse `0.0.0.0/0` pour SSH. Il vous faut une IP publique stable.
```bash
curl -s https://ifconfig.me
```
- **IP fixe** (fibre pro, bureau) → parfait.
- **IP dynamique** → deux options :
- relancer `terraform apply` quand elle change (acceptable si c'est rare) ;
- passer par un VPN à IP fixe (Mullvad, un petit serveur Hetzner CX22 à 4 €).
### 2.3 Noms de domaine
La configuration livrée suppose :
| Domaine | Sert |
|---|---|
| `xpeditis.com`, `www.xpeditis.com` | vitrine (pages publiques du frontend) |
| `app.xpeditis.com` | application (origine des cookies d'authentification) |
| `api.xpeditis.com` | API |
| `grafana.xpeditis.com` | supervision |
Pour un autre découpage, modifiez `k8s/base/02-configmap-backend.yaml`
(`APP_URL`, `CORS_ORIGIN`, `COOKIE_DOMAIN`) **et** `k8s/base/09-ingress.yaml`.
> `COOKIE_DOMAIN=.xpeditis.com` (avec le point initial) est **indispensable** :
> l'API pose le cookie sur `api.xpeditis.com`, le middleware Next.js le lit sur
> `app.xpeditis.com`. Sans le point, la connexion réussit mais l'utilisateur est
> renvoyé sur `/login` — le symptôme est déroutant, la cause est ici.
---
## 3. Outils sur votre poste
```bash
# macOS
brew install terraform kubectl sops age hcloud jq rsync
brew install --cask docker # pour construire des images localement
# Vérification
terraform version # >= 1.6
kubectl version --client
sops --version # >= 3.8
age --version
hcloud version
jq --version
```
`shellcheck` est facultatif mais utilisé par `make validate` :
```bash
brew install shellcheck
```
---
## 4. Clés SSH
Trois clés distinctes, jamais interchangeables :
```bash
# 1. Administration (vous, sur les deux serveurs)
ssh-keygen -t ed25519 -a 100 -C "xpeditis-prod-admin" -f ~/.ssh/xpeditis_prod
# 2. Déploiement CI (GitHub Actions → app-01, restreinte à un script)
ssh-keygen -t ed25519 -a 100 -C "github-actions-prod" -f ~/.ssh/xpeditis_ci
# 3. Storage Box (db-01 → Storage Box, pour les dumps)
ssh-keygen -t ed25519 -a 100 -C "xpeditis-storagebox" -f ~/.ssh/xpeditis_storagebox
```
Protégez la clé d'administration par une phrase de passe. Celles de la CI et de
la Storage Box sont utilisées par des automates : elles ne peuvent pas en avoir,
c'est justement pourquoi leurs privilèges sont limités.
**Sauvegardez les trois clés privées dans votre gestionnaire de mots de passe.**
Perdre la clé d'administration signifie repasser par la console Hetzner en mode
secours.
---
## 5. Clé de chiffrement des secrets
C'est **la** clé à ne pas perdre : elle déchiffre tous les secrets de
production. Sa création est détaillée dans [06-secrets-sops.md](./06-secrets-sops.md),
mais générez-la maintenant, vous en aurez besoin partout :
```bash
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txt
grep 'public key' ~/.config/sops/age/keys.txt
```
Générez **aussi une clé de secours**, stockée hors ligne (papier dans un coffre,
ou clé USB chiffrée rangée ailleurs). Les deux clés publiques iront dans
`infra/prod/.sops.yaml`. Sans clé de secours, la perte de votre poste = la perte
définitive de tous les secrets de production.
---
## 6. Contrôle avant de passer à la suite
```
[ ] Projet Hetzner Cloud `xpeditis-prod` créé, 2FA activée
[ ] Storage Box BX11 commandée (l'activation prend jusqu'à 1 h)
[ ] Zone Cloudflare active pour xpeditis.com, 2FA activée
[ ] Compte Stripe en mode Live, webhook de production créé
[ ] Nouvelle clé SMTP Brevo de production créée
[ ] Domaine expéditeur vérifié chez Brevo (SPF/DKIM prêts à publier)
[ ] ops@xpeditis.com relevée
[ ] IP d'administration connue et stable
[ ] Outils installés (terraform, kubectl, sops, age, hcloud, jq)
[ ] 3 clés SSH générées et sauvegardées
[ ] Clé age principale + clé de secours générées
[ ] Webhooks Discord créés
```
→ **Suite : [02 — Provisioning Hetzner](./02-provisioning-hetzner.md)**

View File

@ -0,0 +1,211 @@
# 02 — Provisioning Hetzner
**Durée : environ 1 h.** Création des serveurs, du réseau privé, des firewalls
et du volume de données, entièrement décrite en Terraform.
> Le fichier de prévisions de coûts note explicitement le risque
> « bus factor DevOps — Terraform/IaC obligatoire ». C'est la raison d'être de
> ce dossier : l'infrastructure est reconstructible par quelqu'un d'autre, à
> partir du dépôt seul.
---
## 1. Token API Hetzner
Console Hetzner → projet `xpeditis-prod` → **Security → API tokens** →
*Generate API token*.
- Description : `terraform-prod`
- Permissions : **Read & Write**
Le token n'est affiché **qu'une fois**. Copiez-le dans votre gestionnaire de
mots de passe immédiatement.
Créez un **second token** nommé `github-actions-cicd`, également Read & Write.
Il servira uniquement au firewall temporaire de la CI, ce qui permet de le
révoquer sans casser Terraform.
---
## 2. Configuration
```bash
cd infra/prod/terraform
cp terraform.tfvars.example terraform.tfvars
$EDITOR terraform.tfvars
```
À renseigner :
```hcl
hcloud_token = "<token terraform-prod>"
ssh_public_key = "ssh-ed25519 AAAA... xpeditis-prod-admin" # cat ~/.ssh/xpeditis_prod.pub
admin_ip_allowlist = ["<votre IP>/32"] # curl -s https://ifconfig.me
```
Le reste des valeurs par défaut correspond au dimensionnement de la phase 1 :
`cpx41` pour l'application, `cpx31` pour les données, volume de 50 Go,
sauvegardes Hetzner activées.
> `terraform.tfvars` est ignoré par Git (`infra/prod/.gitignore`). Vérifiez-le
> avant tout commit : `git status` ne doit pas le mentionner.
---
## 3. Vérifier avant d'appliquer
```bash
make -C .. tf-init # ou : terraform init
make -C .. tf-plan # ou : terraform plan
```
Le plan doit annoncer **8 ressources à créer** :
```
hcloud_network.main
hcloud_network_subnet.main
hcloud_ssh_key.admin
hcloud_placement_group.spread
hcloud_firewall.app
hcloud_firewall.db
hcloud_firewall.cicd
hcloud_server.app
hcloud_server.db
hcloud_volume.pgdata
```
Trois points à relire dans le plan :
1. `hcloud_firewall.app` — les règles 22 et 6443 portent **uniquement** votre
IP. Si vous y voyez `0.0.0.0/0`, arrêtez tout.
2. `hcloud_firewall.db` — **seul le port 22** est ouvert. Ni 5432, ni 6379.
3. `hcloud_firewall.cicd` — **aucune règle**. C'est normal : la CI les injecte
puis les retire.
---
## 4. Appliquer
```bash
make -C .. tf-apply
```
Environ 90 secondes. Puis :
```bash
terraform output
```
Notez précieusement :
```
app_public_ipv4 = "..." → tous les enregistrements DNS Cloudflare
db_public_ipv4 = "..." → SSH d'administration UNIQUEMENT, jamais en DNS
db_private_ip = "10.10.1.20"
pgdata_volume_device = "/dev/disk/by-id/scsi-0HC_Volume_..."
```
---
## 5. Vérifier l'accès
```bash
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> 'hostname; uptime'
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> 'hostname; uptime'
```
cloud-init met une à deux minutes après la création du serveur. Si la connexion
est refusée, attendez puis vérifiez :
```bash
ssh -i ~/.ssh/xpeditis_prod deploy@<ip> 'ls -l /var/log/cloud-init-xpeditis-done'
```
Le réseau privé doit fonctionner dans les deux sens :
```bash
ssh deploy@<app_public_ipv4> 'ping -c2 10.10.1.20'
```
---
## 6. Confirmer que la base est bien inaccessible
**Depuis un autre réseau que votre IP d'administration** (partage de connexion
mobile, par exemple) :
```bash
nmap -Pn -p 22,80,443,5432,6379,6443 <db_public_ipv4>
```
Attendu : **tous les ports `filtered`**, y compris le 22 — puisque vous testez
depuis une IP non autorisée.
```bash
nmap -Pn -p 22,80,443,5432,6379,6443 <app_public_ipv4>
```
Attendu à ce stade : `80` et `443` ouverts uniquement si
`restrict_http_to_cloudflare = false`. Avec la valeur par défaut `true`, ils
apparaissent aussi `filtered` tant que la requête ne vient pas de Cloudflare —
c'est exactement l'effet recherché.
---
## 7. Storage Box
La Storage Box se commande séparément (Robot Hetzner, pas la console Cloud).
1. Commandez une **BX11** (1 To, 3,90 €/mois).
2. Dans son interface : activez **SSH** et **désactivez** Samba/CIFS et WebDAV,
inutiles ici et exposés sur Internet.
3. Déposez la clé publique dédiée :
```bash
# Depuis votre poste
ssh-copy-id -p 23 -i ~/.ssh/xpeditis_storagebox.pub uXXXXXX@uXXXXXX.your-storagebox.de
# Créez l'arborescence
ssh -p 23 -i ~/.ssh/xpeditis_storagebox uXXXXXX@uXXXXXX.your-storagebox.de \
'mkdir -p xpeditis-prod/dumps'
```
La clé **privée** correspondante devra être déposée sur db-01 en
`/root/.ssh/storagebox` ([04 — Nœud de données](./04-noeud-donnees.md)).
> **Activez les *snapshots* de la Storage Box** (gratuits, dans son interface).
> Ils protègent contre le cas où un rançongiciel présent sur db-01 chiffrerait
> ou effacerait aussi les sauvegardes distantes via la connexion SSH existante.
---
## 8. Sauvegarder l'état Terraform
`terraform.tfstate` décrit toute votre infrastructure. Il est gitignoré à
dessein (il contient des données sensibles), mais **le perdre signifie que
Terraform ne reconnaîtra plus vos ressources** et proposera de tout recréer.
```bash
# Sauvegarde chiffrée dans le dépôt
sops -e terraform.tfstate > ../terraform-state-backup.sops.json
```
Ou, mieux, migrez vers un backend S3 sur Hetzner Object Storage : le bloc
`backend "s3"` est prêt, en commentaire, dans `versions.tf`.
---
## 9. Contrôle
```
[ ] terraform apply passé sans erreur
[ ] SSH fonctionne sur app-01 et db-01 avec la clé d'administration
[ ] Le ping app-01 → 10.10.1.20 répond
[ ] nmap depuis une IP non autorisée : tout est filtered
[ ] Storage Box commandée, SSH activé, Samba/WebDAV désactivés
[ ] Snapshots de la Storage Box activés
[ ] État Terraform sauvegardé
[ ] Les deux IP publiques notées dans le gestionnaire de mots de passe
```
→ **Suite : [03 — Durcissement des serveurs](./03-durcissement-serveurs.md)**

View File

@ -0,0 +1,180 @@
# 03 — Durcissement des serveurs
**Durée : environ 1 h pour les deux serveurs.**
cloud-init a posé le minimum vital (compte `deploy`, SSH par clé, UFW fermé).
Ce script va nettement plus loin et rend le résultat vérifiable.
---
## 1. Ce que fait `00-bootstrap-common.sh`
| Domaine | Mesure | Pourquoi |
|---|---|---|
| Mises à jour | `unattended-upgrades`, redémarrage automatique à 04h30 si le noyau l'exige | Une faille non corrigée pendant six mois est la première cause de compromission d'un serveur laissé seul. |
| SSH | Clé uniquement, `root` interdit, 3 tentatives, algorithmes modernes, `AllowUsers deploy` | Supprime l'attaque par mot de passe et réduit la surface cryptographique. |
| fail2ban | Bannissement 24 h après 3 échecs, réseau privé exclu | Ralentit le balayage automatisé permanent d'Internet. |
| sysctl | Anti-spoofing, pas de redirections ICMP, `kptr_restrict`, `ptrace_scope` | Rend l'escalade locale nettement plus difficile après une compromission applicative. |
| journald | Plafonné à 2 Go, 30 jours | Des journaux qui remplissent le disque provoquent une panne totale. |
| auditd | Trace `passwd`, `shadow`, `sudoers`, `sshd_config`, commandes root | Sans journal d'audit, une intrusion est indémontrable. |
| chrony | Fuseau Europe/Paris, NTP | Une horloge décalée invalide les JWT, casse TLS et rend les `audit_logs` inexploitables. |
| UFW | Refus par défaut, ouvertures selon le rôle | Le firewall Hetzner ne filtre **que** les interfaces publiques : le trafic du réseau privé n'est filtré que par UFW. |
---
## 2. Exécution
### app-01
```bash
scp -i ~/.ssh/xpeditis_prod infra/prod/scripts/00-bootstrap-common.sh \
deploy@<app_public_ipv4>:/tmp/
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> \
'sudo bash /tmp/00-bootstrap-common.sh app'
```
### db-01
`APP_PRIVATE_IP` détermine qui a le droit d'atteindre PostgreSQL et Redis.
```bash
scp -i ~/.ssh/xpeditis_prod infra/prod/scripts/00-bootstrap-common.sh \
deploy@<db_public_ipv4>:/tmp/
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> \
'sudo APP_PRIVATE_IP=10.10.1.10 bash /tmp/00-bootstrap-common.sh data'
```
> **Gardez la session SSH ouverte** pendant que le script tourne. Il redémarre
> `sshd` : si la configuration était invalide, `sshd -t` échouerait avant le
> redémarrage, mais mieux vaut pouvoir corriger sans passer par la console de
> secours Hetzner. Ouvrez un **second terminal** et vérifiez que vous arrivez
> encore à vous connecter **avant** de fermer le premier.
---
## 3. Vérifications
Sur chaque serveur :
```bash
ssh deploy@<ip> '
echo "--- SSH ---"
sudo sshd -T | grep -E "^(permitrootlogin|passwordauthentication|maxauthtries|allowusers)"
echo "--- Services ---"
systemctl is-active fail2ban unattended-upgrades auditd chrony
echo "--- Pare-feu ---"
sudo ufw status verbose
echo "--- Horloge ---"
timedatectl | grep -E "Time zone|synchronized"
'
```
Attendu :
```
permitrootlogin no
passwordauthentication no
maxauthtries 3
allowusers deploy
active × 4
Status: active (avec les règles correspondant au rôle)
System clock synchronized: yes
```
### Règles UFW attendues
**app-01**
```
22/tcp ALLOW Anywhere # SSH (filtré en amont par Hetzner)
80/tcp ALLOW Anywhere # HTTP (filtré en amont par Cloudflare)
443/tcp ALLOW Anywhere # HTTPS
6443/tcp ALLOW Anywhere # API k3s (filtré en amont)
```
**db-01**
```
22/tcp ALLOW Anywhere
5432/tcp ALLOW 10.10.1.10 # PostgreSQL ← app-01 uniquement
6379/tcp ALLOW 10.10.1.10 # Redis ← app-01 uniquement
```
Si `5432 ALLOW Anywhere` apparaît sur db-01, **arrêtez et corrigez** : la base
serait joignable depuis n'importe quelle machine du réseau privé.
---
## 4. Durcissement complémentaire (recommandé)
### 4.1 Protéger la console Hetzner
La console web Hetzner donne un accès clavier au serveur, **sans passer par
SSH**. Elle contourne donc tout ce qui précède.
- Activez la 2FA sur le compte Hetzner (si ce n'est pas déjà fait).
- Ne définissez **jamais** de mot de passe root sur les serveurs : sans mot de
passe, la console ne permet pas de se connecter.
Vérifiez qu'aucun mot de passe n'est défini :
```bash
ssh deploy@<ip> 'sudo passwd -S root' # doit afficher "L" (locked)
```
### 4.2 Alertes de connexion SSH
Pour être prévenu de toute connexion réussie :
```bash
ssh deploy@<ip> 'sudo tee /etc/profile.d/99-ssh-alert.sh >/dev/null' <<'EOF'
#!/bin/sh
# Notifie Discord a chaque ouverture de session SSH interactive.
[ -n "$SSH_CONNECTION" ] || return 0
[ -f /etc/xpeditis/discord-webhook ] || return 0
WEBHOOK=$(cat /etc/xpeditis/discord-webhook)
curl -sf -m 5 -H 'Content-Type: application/json' \
-d "{\"content\":\"SSH sur \`$(hostname)\` : ${USER} depuis ${SSH_CONNECTION%% *}\"}" \
"$WEBHOOK" >/dev/null 2>&1 &
EOF
ssh deploy@<ip> '
sudo mkdir -p /etc/xpeditis
echo "<webhook_discord_alertes>" | sudo tee /etc/xpeditis/discord-webhook >/dev/null
sudo chmod 600 /etc/xpeditis/discord-webhook
'
```
Une notification pour chacune de vos propres connexions est le prix à payer
pour repérer immédiatement celle qui n'est pas la vôtre.
### 4.3 Bannissement permanent des récidivistes
```bash
ssh deploy@<ip> 'sudo tee /etc/fail2ban/jail.d/recidive.local >/dev/null' <<'EOF'
[recidive]
enabled = true
logpath = /var/log/fail2ban.log
banaction = iptables-allports
bantime = 1w
findtime = 1d
maxretry = 3
EOF
ssh deploy@<ip> 'sudo systemctl restart fail2ban'
```
---
## 5. Contrôle
```
[ ] Script exécuté sur app-01 (rôle app) et db-01 (rôle data)
[ ] SSH : root interdit, mot de passe interdit, AllowUsers deploy
[ ] fail2ban, unattended-upgrades, auditd, chrony actifs sur les deux
[ ] UFW db-01 : 5432 et 6379 restreints à 10.10.1.10
[ ] Horloge synchronisée sur les deux
[ ] Compte root verrouillé (passwd -S root → L)
[ ] Une seconde session SSH a été testée avant de fermer la première
[ ] Alertes SSH Discord en place (optionnel mais recommandé)
```
→ **Suite : [04 — Nœud de données](./04-noeud-donnees.md)**

View File

@ -0,0 +1,337 @@
# 04 — Nœud de données (PostgreSQL + Redis)
**Durée : environ 2 h.** C'est l'étape la plus délicate : c'est la seule dont
les erreurs ne se rattrapent pas en redéployant.
> **Prérequis** — WAL-G archive les journaux de transaction dès le premier
> démarrage de PostgreSQL. Créez d'abord le bucket de sauvegarde et ses clés :
> [07 — Stockage objet, sections 1 et 2](./07-stockage-objet-s3.md). Revenez
> ensuite ici. Sans le bucket, PostgreSQL démarre quand même mais accumule ses
> WAL sur disque jusqu'à saturation.
---
## 1. Choix d'architecture, et pourquoi
**PostgreSQL est hors de Kubernetes.** Ce n'est pas un raccourci, c'est un
choix :
- Un `StatefulSet` PostgreSQL impose des volumes persistants, un ordre de
démarrage, et transforme chaque montée de version majeure en opération à
risque.
- Hors cluster, `pg_dump`, WAL-G, la restauration à un instant T et les tests
de restauration sont des commandes ordinaires.
- Le nœud applicatif redevient entièrement jetable : on peut le détruire et le
reconstruire sans jamais approcher les données.
**Le trafic vers la base est chiffré.** Le réseau privé Hetzner isole les
projets mais ne chiffre pas les paquets. `pg_hba.conf` n'accepte que des lignes
`hostssl` : une connexion en clair est refusée, y compris depuis app-01.
---
## 2. Installation
```bash
scp -i ~/.ssh/xpeditis_prod infra/prod/scripts/01-setup-data-node.sh \
deploy@<db_public_ipv4>:/tmp/
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> \
'sudo bash /tmp/01-setup-data-node.sh'
```
Le script :
1. formate (si vierge) et monte le volume Hetzner sur `/var/lib/xpeditis/pgdata`,
avec `nofail` pour que le serveur démarre même si le volume manque ;
2. installe Docker Engine depuis le dépôt officiel et le durcit
(`no-new-privileges`, `icc: false`, rotation des journaux) ;
3. génère le certificat TLS interne de PostgreSQL ;
4. installe `age` ;
5. installe les timers systemd de sauvegarde.
Il **ne démarre pas** la base : les secrets ne sont pas encore là.
### Vérifier le volume
```bash
ssh deploy@<db_public_ipv4> 'df -h /var/lib/xpeditis/pgdata; lsblk'
```
Le point de montage doit apparaître avec la taille du volume (50 Go), pas celle
du disque système. Si le volume n'a pas été détecté, `/var/lib/xpeditis/pgdata`
est resté sur le disque système : corrigez **avant** d'écrire la moindre donnée.
---
## 3. Copier la configuration
```bash
# Depuis le dépôt, sur votre poste
rsync -az -e "ssh -i ~/.ssh/xpeditis_prod" \
infra/prod/data-node/ \
deploy@<db_public_ipv4>:/tmp/data-node/
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> '
sudo mkdir -p /opt/xpeditis/data-node
sudo rsync -a /tmp/data-node/ /opt/xpeditis/data-node/
sudo chown -R root:root /opt/xpeditis/data-node
sudo chmod +x /opt/xpeditis/data-node/backup/*.sh
rm -rf /tmp/data-node
'
```
Relancez ensuite le script d'installation. Il est idempotent : cette seconde
passe ne refait rien de ce qui est déjà en place, mais elle trouve désormais les
fichiers `backup/` et installe les timers systemd.
```bash
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> \
'sudo bash /tmp/01-setup-data-node.sh'
```
---
## 4. Secrets
### 4.1 Générer les mots de passe
Sur **votre poste**, jamais sur le serveur (l'historique du shell garde tout) :
```bash
echo "POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -d '\n/+=' | head -c 40)"
echo "REDIS_PASSWORD=$(openssl rand -base64 32 | tr -d '\n/+=' | head -c 40)"
# WAL-G attend 32 octets en HEXADÉCIMAL (64 caractères), pas en base64.
echo "WALG_LIBSODIUM_KEY=$(openssl rand -hex 32)"
```
Générez aussi la paire age dédiée au chiffrement des dumps :
```bash
age-keygen -o /tmp/backup-age.key
grep 'public key' /tmp/backup-age.key # → BACKUP_AGE_RECIPIENT
```
> `WALG_LIBSODIUM_KEY` et la clé privée `backup-age.key` sont **les deux clés
> sans lesquelles aucune restauration n'est possible**. Sauvegardez-les dans
> votre gestionnaire de mots de passe **et** hors ligne, avant d'aller plus
> loin. Une sauvegarde chiffrée dont on a perdu la clé est un fichier inutile.
### 4.2 Composer le fichier d'environnement
```bash
cp infra/prod/env/data-node.env.example /tmp/.env.data
$EDITOR /tmp/.env.data
```
Renseignez : `POSTGRES_PASSWORD`, `REDIS_PASSWORD`, les identifiants WAL-G
(bucket créé en [07](./07-stockage-objet-s3.md)), `WALG_LIBSODIUM_KEY`,
`BACKUP_AGE_RECIPIENT`, les coordonnées de la Storage Box, les webhooks.
### 4.3 Chiffrer pour Git, déposer en clair sur le serveur
```bash
# Version chiffrée, versionnée
cd infra/prod
sops -e --input-type dotenv --output-type dotenv /tmp/.env.data \
> data-node/data-node.sops.env
# Version en clair, uniquement sur db-01
scp -i ~/.ssh/xpeditis_prod /tmp/.env.data deploy@<db_public_ipv4>:/tmp/
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> '
sudo install -m 600 -o root -g root /tmp/.env.data /opt/xpeditis/data-node/.env.data
shred -u /tmp/.env.data
'
# Effacer la copie locale
shred -u /tmp/.env.data
```
### 4.4 Clés privées sur db-01
```bash
# Clé age de déchiffrement des dumps
scp -i ~/.ssh/xpeditis_prod /tmp/backup-age.key deploy@<db_public_ipv4>:/tmp/
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> '
sudo mkdir -p /root/.config/xpeditis && sudo chmod 700 /root/.config/xpeditis
sudo install -m 600 -o root -g root /tmp/backup-age.key /root/.config/xpeditis/backup-age.key
shred -u /tmp/backup-age.key
'
shred -u /tmp/backup-age.key
# Clé SSH vers la Storage Box
scp -i ~/.ssh/xpeditis_prod ~/.ssh/xpeditis_storagebox deploy@<db_public_ipv4>:/tmp/sb.key
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> '
sudo install -m 600 -o root -g root /tmp/sb.key /root/.ssh/storagebox
shred -u /tmp/sb.key
# Enregistre l empreinte de la Storage Box : StrictHostKeyChecking=yes
# échouerait sinon, et le rsync de sauvegarde avec lui.
sudo ssh-keyscan -p 23 uXXXXXX.your-storagebox.de | sudo tee -a /root/.ssh/known_hosts
'
```
---
## 5. Démarrage
```bash
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4>
cd /opt/xpeditis/data-node
sudo docker compose -f docker-compose.data.yml --env-file .env.data up -d --build
```
La construction de l'image PostgreSQL + WAL-G prend deux à trois minutes.
```bash
sudo docker compose -f docker-compose.data.yml --env-file .env.data ps
sudo docker compose -f docker-compose.data.yml --env-file .env.data logs postgres | tail -40
```
Attendu dans les journaux :
```
database system is ready to accept connections
```
Si PostgreSQL refuse de démarrer, la cause est presque toujours l'une de ces
trois :
| Message | Cause | Correction |
|---|---|---|
| `could not load server certificate file` | droits du certificat | `chown 999:999` et `chmod 600` sur `/var/lib/xpeditis/certs/server.key` |
| `unrecognized configuration parameter` | faute de frappe dans `postgresql.conf` | corriger, puis `docker compose restart postgres` |
| `data directory has wrong ownership` | volume monté après le premier démarrage | `chown -R 999:999 /var/lib/xpeditis/pgdata` |
---
## 6. Vérifications
### 6.1 TLS obligatoire
```bash
# Depuis db-01 : connexion locale (socket) — doit fonctionner
sudo docker compose exec -u postgres postgres psql -c 'SELECT version();'
# Le chiffrement est-il actif ?
sudo docker compose exec -u postgres postgres \
psql -c "SELECT name, setting FROM pg_settings WHERE name IN ('ssl','password_encryption');"
```
Attendu : `ssl = on`, `password_encryption = scram-sha-256`.
### 6.2 Depuis app-01, la connexion doit passer en TLS et **uniquement** en TLS
```bash
ssh deploy@<app_public_ipv4>
sudo apt-get install -y postgresql-client
# Doit RÉUSSIR
PGPASSWORD='<POSTGRES_PASSWORD>' psql "host=10.10.1.20 port=5432 dbname=xpeditis_prod user=xpeditis sslmode=require" -c '\conninfo'
# Doit ÉCHOUER — c'est le résultat attendu
PGPASSWORD='<POSTGRES_PASSWORD>' psql "host=10.10.1.20 port=5432 dbname=xpeditis_prod user=xpeditis sslmode=disable" -c 'SELECT 1;'
```
Le second doit renvoyer :
```
FATAL: no pg_hba.conf entry for host "10.10.1.10", ... SSL off
```
Si la connexion **sans** TLS réussit, `pg_hba.conf` n'a pas été pris en compte :
vérifiez que le conteneur est bien lancé avec `-c hba_file=/etc/postgresql/pg_hba.conf`.
### 6.3 Redis
```bash
# Depuis app-01
redis-cli -h 10.10.1.20 -a '<REDIS_PASSWORD>' --no-auth-warning ping # PONG
redis-cli -h 10.10.1.20 --no-auth-warning ping # NOAUTH
```
### 6.4 Rien n'est exposé publiquement
**Depuis un autre réseau :**
```bash
nmap -Pn -p 5432,6379,9187 <db_public_ipv4>
```
Les trois doivent être `filtered`.
---
## 7. Première sauvegarde
Elle doit être prise **avant** que la moindre donnée réelle n'existe : cela
valide la chaîne complète pendant que l'enjeu est nul.
```bash
ssh deploy@<db_public_ipv4> 'sudo systemctl start xpeditis-backup.service'
ssh deploy@<db_public_ipv4> 'sudo journalctl -u xpeditis-backup -n 60 --no-pager'
```
Attendu :
```
WAL-G basebackup : termine
pg_dump : NNNNN octets
Envoi vers la Storage Box ...
Sauvegarde terminee.
```
Vérifiez les deux destinations :
```bash
# WAL-G sur Object Storage
ssh deploy@<db_public_ipv4> \
'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres wal-g backup-list --detail'
# Dumps sur la Storage Box
ssh -p 23 -i ~/.ssh/xpeditis_storagebox uXXXXXX@uXXXXXX.your-storagebox.de \
'ls -lh xpeditis-prod/dumps/'
```
### Timers actifs
```bash
ssh deploy@<db_public_ipv4> "systemctl list-timers 'xpeditis-*' --no-pager"
```
Attendu : `xpeditis-backup.timer` (quotidien 02h30) et
`xpeditis-backup-verify.timer` (dimanche 04h00).
---
## 8. Réglages à connaître
| Paramètre | Valeur | Conséquence |
|---|---|---|
| `shared_buffers` | 2 Go | Sur 8 Go de RAM, dont 1,5 Go plafonnés pour Redis. |
| `max_connections` | 150 | 2 replicas backend + marge d'exploitation. Une alerte se déclenche à 80 %. |
| `statement_timeout` | 5 min | Borne les requêtes folles tout en laissant passer les imports de grilles CSV. |
| `archive_timeout` | 5 min | **Plafonne la perte de données à 5 minutes** en cas de perte totale du serveur. |
| `log_min_duration_statement` | 500 ms | Toute requête lente est tracée dans Loki. |
| `log_statement` | `ddl` | Chaque changement de schéma (migration) laisse une trace. |
| Redis `maxmemory-policy` | `volatile-lru` | Seules les clés à TTL sont évincées. Voir l'avertissement ci-dessous. |
> **Redis et l'éviction.** `volatile-lru` n'évince que les clés porteuses d'un
> TTL — les cotations tarifaires, qui sont sacrifiables. Mais les entrées de
> révocation de jetons ont elles aussi un TTL : sous saturation mémoire, l'une
> d'elles pourrait être évincée, redonnant validité à un jeton révoqué.
> `maxmemory` est fixé à 1 Go pour 0,5 Go estimé, et une alerte se déclenche à
> 75 % d'occupation. Si elle se déclenche, augmentez `maxmemory` — ne changez
> pas la politique d'éviction.
---
## 9. Contrôle
```
[ ] Volume Hetzner monté sur /var/lib/xpeditis/pgdata (bonne taille)
[ ] PostgreSQL démarré, ssl = on, password_encryption = scram-sha-256
[ ] Connexion TLS depuis app-01 : OK
[ ] Connexion NON TLS depuis app-01 : REFUSÉE
[ ] Redis répond avec mot de passe, refuse sans
[ ] 5432 / 6379 / 9187 filtered depuis Internet
[ ] Première sauvegarde réussie, visible sur Object Storage ET Storage Box
[ ] Timers xpeditis-backup et xpeditis-backup-verify actifs
[ ] WALG_LIBSODIUM_KEY et backup-age.key sauvegardées hors ligne
[ ] .env.data en clair effacé du poste et de /tmp du serveur
```
→ **Suite : [05 — Cluster k3s](./05-cluster-k3s.md)**

View File

@ -0,0 +1,181 @@
# 05 — Cluster k3s
**Durée : environ 1 h 30.**
k3s est une distribution Kubernetes complète en un seul binaire. Sur un nœud, il
consomme environ 500 Mo de RAM — comparable à Docker Swarm, pour un modèle de
déploiement bien plus riche (sondes, rolling updates sans coupure, politiques
réseau, quotas).
---
## 1. Installation
```bash
scp -i ~/.ssh/xpeditis_prod infra/prod/scripts/02-setup-k3s-server.sh \
deploy@<app_public_ipv4>:/tmp/
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> \
"sudo K3S_VERSION=v1.31.5+k3s1 PUBLIC_IP=<app_public_ipv4> PRIVATE_IP=10.10.1.10 \
bash /tmp/02-setup-k3s-server.sh"
```
Comptez trois à cinq minutes.
### Ce que le script durcit, et pourquoi
| Option | Effet |
|---|---|
| `--secrets-encryption` | Les `Secret` sont chiffrés dans la base d'état de k3s. Sans cela, un instantané de disque ou un vol de volume livre **tous** les secrets en clair. |
| `--protect-kernel-defaults` | kubelet refuse de démarrer si les `sysctl` attendus ne sont pas posés. Échec bruyant plutôt que dérive silencieuse. |
| `audit-log-*` | Journal d'audit de l'API, 30 jours. Sans lui, « qui a supprimé ce déploiement ? » reste sans réponse. La politique fournie ne journalise **jamais** le contenu des `Secret`. |
| `--write-kubeconfig-mode=0600` | Le kubeconfig n'est pas lisible par tous les utilisateurs du serveur. |
| `--advertise-address=10.10.1.10` | Le cluster s'annonce sur le réseau privé. |
| `--etcd-expose-metrics=false` | Réduit la surface exposée. |
### Traefik
Le script écrit `/var/lib/rancher/k3s/server/manifests/traefik-config.yaml`
**avant** l'installation, pour que k3s applique la configuration dès le premier
démarrage :
- redirection HTTP → HTTPS au niveau de l'*entrypoint* — aucun Ingress ne peut
l'oublier ;
- **IP Cloudflare déclarées de confiance** pour `X-Forwarded-For` : sans cela,
la limitation de débit et les `audit_logs` verraient tous l'IP de Cloudflare,
et un seul abus bloquerait tout le monde ;
- journaux d'accès en JSON, en-têtes filtrés (seuls `User-Agent` et
`Cf-Connecting-Ip` sont conservés) — les autres peuvent contenir des jetons ;
- **tableau de bord Traefik désactivé** ;
- métriques Prometheus activées.
---
## 2. Récupérer le kubeconfig
```bash
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> 'cat ~/.kube/config' \
| sed "s/127.0.0.1/<app_public_ipv4>/" > ~/.kube/xpeditis-prod.yaml
chmod 600 ~/.kube/xpeditis-prod.yaml
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
kubectl get nodes -o wide
```
> Ce fichier donne **les pleins pouvoirs** sur le cluster. Traitez-le comme une
> clé privée : `chmod 600`, jamais dans Git, jamais dans un secret GitHub.
> Il n'est utilisable que depuis vos IP d'administration (firewall Hetzner).
Pour l'utiliser en permanence :
```bash
echo 'export KUBECONFIG=~/.kube/xpeditis-prod.yaml' >> ~/.zshrc
```
---
## 3. Vérifications
```bash
kubectl get nodes
# NAME STATUS ROLES VERSION
# xpeditis-prod-app-01 Ready control-plane,master v1.31.5+k3s1
kubectl -n kube-system get pods
# coredns, local-path-provisioner, metrics-server, traefik : tous Running
# Chiffrement des Secrets au repos
ssh deploy@<app_public_ipv4> 'sudo k3s secrets-encrypt status'
# Encryption Status: Enabled
# Journal d'audit alimenté
ssh deploy@<app_public_ipv4> 'sudo tail -2 /var/log/k3s/audit.log | head -c 300'
# Traefik écoute bien sur 80 et 443
ssh deploy@<app_public_ipv4> 'sudo ss -tlnp | grep -E ":(80|443) "'
```
---
## 4. Composants du cluster
Depuis votre poste, kubeconfig chargé :
```bash
cd infra/prod
REGISTRY_TOKEN='<jeton Scaleway>' bash scripts/03-install-cluster-addons.sh
```
Le script installe :
1. les namespaces `xpeditis-prod` et `monitoring`, avec l'*admission de sécurité
des pods* — `restricted` pour l'application, `baseline` pour la supervision
(Promtail doit lire les journaux de l'hôte) ;
2. **cert-manager** depuis son manifeste officiel épinglé (pas de Helm à
maintenir) ;
3. le `Secret` d'accès au registre Scaleway (`regcred`) ;
4. le `ClusterIssuer` Let's Encrypt — seulement si le secret Cloudflare existe
déjà, sinon il vous le rappelle. C'est normal à ce stade : il sera appliqué
après [06 — Secrets](./06-secrets-sops.md).
```bash
kubectl -n cert-manager get pods # 3 pods Running
kubectl get ns # xpeditis-prod, monitoring, cert-manager
kubectl -n xpeditis-prod get secret regcred
```
---
## 5. Éprouver les garde-fous
Cette étape est facultative mais rassurante : elle vérifie que les protections
mordent réellement.
```bash
# Un pod privilégié doit être REFUSÉ par le namespace restricted
kubectl -n xpeditis-prod run test-privilegie --image=alpine --restart=Never \
--overrides='{"spec":{"containers":[{"name":"c","image":"alpine","securityContext":{"privileged":true}}]}}' \
-- sleep 10
# Attendu : "violates PodSecurity restricted:latest"
# Un pod root doit être REFUSÉ
kubectl -n xpeditis-prod run test-root --image=alpine --restart=Never \
--overrides='{"spec":{"containers":[{"name":"c","image":"alpine","securityContext":{"runAsUser":0}}]}}' \
-- sleep 10
# Attendu : refus également
```
Si l'un des deux est **accepté**, les étiquettes du namespace n'ont pas été
appliquées :
```bash
kubectl apply -f infra/prod/k8s/base/00-namespaces.yaml
kubectl get ns xpeditis-prod -o jsonpath='{.metadata.labels}' | jq
```
---
## 6. Ce qu'il ne faut pas faire
| À éviter | Pourquoi |
|---|---|
| Ouvrir 6443 à `0.0.0.0/0` | L'API Kubernetes exposée publiquement est une cible permanente. Terraform la restreint à vos IP. |
| Mettre le kubeconfig dans un secret GitHub | Un dépôt compromis donnerait le contrôle total du cluster. La CI passe par SSH avec une clé restreinte à un script. |
| Installer un tableau de bord Kubernetes | Surface d'attaque considérable pour un gain nul face à `kubectl` et Grafana. |
| Exécuter des charges de travail en `hostNetwork` | Seul `node-exporter` le fait, et c'est déjà un compromis assumé. |
| Désactiver les `NetworkPolicy` | Elles empêchent un pod compromis de balayer le réseau privé. |
---
## 7. Contrôle
```
[ ] Nœud Ready, k3s v1.31.x
[ ] secrets-encrypt status = Enabled
[ ] Journal d'audit alimenté dans /var/log/k3s/audit.log
[ ] Traefik écoute sur 80 et 443, tableau de bord désactivé
[ ] cert-manager : 3 pods Running
[ ] Namespaces xpeditis-prod (restricted) et monitoring (baseline) créés
[ ] Secret regcred présent
[ ] Un pod privilégié est refusé
[ ] kubeconfig récupéré en chmod 600, hors de Git
```
→ **Suite : [06 — Secrets SOPS](./06-secrets-sops.md)**

View File

@ -0,0 +1,251 @@
# 06 — Secrets (SOPS + age)
**Durée : environ 1 h.**
---
## 1. Pourquoi SOPS et pas autre chose
| Approche | Pourquoi elle a été écartée |
|---|---|
| Secrets en clair dans le YAML | C'est ce que fait la preprod aujourd'hui. Résultat : mot de passe de base, `JWT_SECRET` et **clé SMTP Brevo** publiés dans l'historique Git. |
| Sealed Secrets | Bonne solution, mais elle ne chiffre **que** des `Secret` Kubernetes. Le fichier `.env.data` de db-01 (hors cluster) resterait sans solution. |
| Vault / OpenBao | Excellent, et une pièce d'infrastructure de plus à héberger, sauvegarder et desceller après chaque redémarrage. Disproportionné ici. |
| Secrets GitHub uniquement | Le dépôt deviendrait le coffre-fort de la production. Un jeton d'accès compromis livrerait les mots de passe de la base. |
**SOPS + age** couvre les deux mondes avec une seule clé, sans composant à
héberger, et produit des différentiels lisibles en revue (seules les valeurs
sont chiffrées, pas les noms de clés).
---
## 2. Créer les clés
### Clé principale
```bash
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txt
grep 'public key' ~/.config/sops/age/keys.txt
# public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
```
### Clé de secours
**Ne sautez pas cette étape.** Sans elle, perdre votre poste signifie perdre
définitivement l'accès à tous les secrets de production.
```bash
age-keygen -o /tmp/xpeditis-backup-key.txt
cat /tmp/xpeditis-backup-key.txt
```
Trois destinations, au moins deux d'entre elles :
- imprimée sur papier, dans un coffre ou un lieu physique distinct ;
- sur une clé USB chiffrée, rangée ailleurs que votre poste ;
- dans un gestionnaire de mots de passe **différent** de celui du quotidien.
```bash
shred -u /tmp/xpeditis-backup-key.txt
```
### Déclarer les deux clés
```bash
cd infra/prod
$EDITOR .sops.yaml
```
Remplacez `AGE_RECIPIENT_PRIMARY` et `AGE_RECIPIENT_BACKUP` par les deux clés
publiques (`age1…`).
---
## 3. Composer les secrets
```bash
cp k8s/base/03-secrets.template.yaml /tmp/secrets.yaml
$EDITOR /tmp/secrets.yaml
```
### Générer les valeurs
```bash
# JWT_SECRET — minimum 32 caractères (validé par Joi au démarrage)
openssl rand -base64 64 | tr -d '\n'
# DOCUMENT_PASSWORD_SECRET — minimum 16
openssl rand -base64 32 | tr -d '\n'
```
### Correspondances à ne pas manquer
| Secret Kubernetes | Doit être identique à |
|---|---|
| `DATABASE_PASSWORD` | `POSTGRES_PASSWORD` de `/opt/xpeditis/data-node/.env.data` |
| `REDIS_PASSWORD` | `REDIS_PASSWORD` du même fichier |
Une divergence produit un backend qui démarre puis boucle en
`CrashLoopBackOff` avec `password authentication failed`.
### Trois pièges
**1. Les identifiants de tarif Stripe.** Le code lit
`STRIPE_SILVER_*`, `STRIPE_GOLD_*`, `STRIPE_PLATINIUM_*`. Le stack de preprod
définit `STRIPE_STARTER_*`, `STRIPE_PRO_*`, `STRIPE_ENTERPRISE_*` — **des noms
que personne ne lit**, si bien qu'en preprod ces identifiants sont en réalité
absents. Utilisez les noms attendus par le code, ceux du gabarit.
**2. `DOCUMENT_PASSWORD_SECRET`.** S'il est vide, le code retombe sur
`JWT_SECRET`. Une rotation ultérieure du `JWT_SECRET` rendrait alors illisibles
tous les documents transporteurs déjà émis. **Définissez-le explicitement, et
ne le changez plus jamais** sans plan de migration.
**3. `SWAGGER_USERNAME` / `SWAGGER_PASSWORD`.** Laissez-les **vides**. `main.ts`
désactive alors totalement `/api/docs` en production. Les renseigner expose la
documentation complète de l'API derrière une simple authentification Basic.
---
## 4. Chiffrer
```bash
cd infra/prod
sops -e /tmp/secrets.yaml > k8s/base/03-secrets.sops.yaml
shred -u /tmp/secrets.yaml
```
### Vérifier avant de commiter
```bash
# Les noms de clés restent lisibles, les valeurs sont chiffrées
grep -A3 'DATABASE_PASSWORD' k8s/base/03-secrets.sops.yaml
# DATABASE_PASSWORD: ENC[AES256_GCM,data:...,tag:...]
# Aucune valeur en clair ne subsiste
grep -c 'ENC\[' k8s/base/03-secrets.sops.yaml # doit être > 20
# Le déchiffrement fonctionne
sops -d k8s/base/03-secrets.sops.yaml | head -20
```
```bash
make secrets-check # refuse tout fichier sensible non ignoré
git add k8s/base/03-secrets.sops.yaml
git commit -m "prod: secrets chiffres SOPS"
```
---
## 5. Appliquer sur le cluster
```bash
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
make secrets-apply
```
Le déchiffré ne touche jamais le disque : `sops` écrit sur la sortie standard,
`kubectl` lit sur l'entrée standard.
```bash
kubectl -n xpeditis-prod get secrets
kubectl -n monitoring get secrets
kubectl -n cert-manager get secret cloudflare-api-token
```
Appliquez maintenant le `ClusterIssuer`, qui attendait ce secret :
```bash
kubectl apply -f k8s/cluster/cluster-issuer.yaml
kubectl get clusterissuer
```
---
## 6. Modifier un secret
```bash
sops k8s/base/03-secrets.sops.yaml # ouvre votre éditeur, rechiffre en sortie
make secrets-apply
# Indispensable : modifier un Secret ne redémarre pas les pods
kubectl -n xpeditis-prod rollout restart deploy/xpeditis-backend
```
---
## 7. Rotation
| Secret | Fréquence | Effet |
|---|---|---|
| `JWT_SECRET` | annuelle, ou immédiatement en cas de fuite | **Déconnecte tous les utilisateurs.** À faire hors heures ouvrées. |
| `DATABASE_PASSWORD` | annuelle | Changer d'abord dans PostgreSQL (`ALTER ROLE`), puis dans le Secret, puis redémarrer. Brève coupure. |
| `REDIS_PASSWORD` | annuelle | Vider le cache est sans conséquence, les sessions le sont davantage : prévoyez une déconnexion. |
| `STRIPE_SECRET_KEY` | sur incident | Rotation depuis le tableau de bord Stripe. |
| Clé SMTP Brevo | sur incident | **À faire maintenant** : celle de preprod est publiée. |
| `DOCUMENT_PASSWORD_SECRET` | **jamais** | Rendrait illisibles les documents déjà émis. |
| Clé age | tous les 2 ans | `sops updatekeys` sur tous les fichiers après avoir modifié `.sops.yaml`. |
### Rotation du mot de passe PostgreSQL, sans surprise
```bash
# 1. Nouveau mot de passe côté base
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && \
sudo docker compose exec -T -u postgres postgres \
psql -c "ALTER ROLE xpeditis WITH PASSWORD '"'"'<nouveau>'"'"';"'
# 2. Le fichier .env.data de db-01 (postgres-exporter l'utilise)
ssh deploy@<db_ip> 'sudo $EDITOR /opt/xpeditis/data-node/.env.data'
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && \
sudo docker compose --env-file .env.data up -d postgres-exporter'
# 3. Le Secret Kubernetes
sops k8s/base/03-secrets.sops.yaml
make secrets-apply
kubectl -n xpeditis-prod rollout restart deploy/xpeditis-backend
# 4. La version chiffrée du .env.data
sops -e --input-type dotenv --output-type dotenv <fichier> > data-node/data-node.sops.env
```
---
## 8. Perte de la clé age
Si vous perdez la clé principale **et** la clé de secours, les secrets chiffrés
dans Git sont définitivement illisibles. La production continue de tourner (les
`Secret` sont déjà dans le cluster), mais vous ne pouvez plus les modifier ni
les redéployer ailleurs.
Récupération d'urgence, tant que le cluster fonctionne :
```bash
# Extraire les valeurs depuis le cluster
kubectl -n xpeditis-prod get secret xpeditis-backend-secrets -o json \
| jq -r '.data | to_entries[] | "\(.key)=\(.value|@base64d)"'
```
Puis recréez une paire de clés age et rechiffrez. Faites-le **immédiatement** :
si le cluster tombe entre-temps, les secrets sont perdus.
---
## 9. Contrôle
```
[ ] Clé age principale créée, chmod 600
[ ] Clé de secours créée et rangée dans DEUX endroits physiques distincts
[ ] .sops.yaml renseigné avec les deux clés publiques
[ ] 03-secrets.sops.yaml créé, > 20 valeurs ENC[…]
[ ] DATABASE_PASSWORD et REDIS_PASSWORD identiques à ceux de db-01
[ ] Identifiants de tarif Stripe aux noms SILVER / GOLD / PLATINIUM
[ ] DOCUMENT_PASSWORD_SECRET défini explicitement
[ ] SWAGGER_USERNAME et SWAGGER_PASSWORD laissés vides
[ ] Secrets appliqués sur le cluster
[ ] ClusterIssuer letsencrypt-prod créé
[ ] make secrets-check passe
[ ] /tmp/secrets.yaml effacé avec shred
```
→ **Suite : [07 — Stockage objet](./07-stockage-objet-s3.md)**

View File

@ -0,0 +1,173 @@
# 07 — Stockage objet (Hetzner Object Storage)
**Durée : environ 45 min.**
Hetzner Object Storage remplace MinIO en production. C'est un service
compatible S3 : **aucune ligne de code applicatif ne change**, seules les
variables `AWS_S3_ENDPOINT`, `AWS_REGION` et les clés diffèrent.
| | MinIO auto-hébergé | Hetzner Object Storage |
|---|---|---|
| Coût | « gratuit » + le disque + votre temps | ~6 €/To/mois |
| À sauvegarder | oui, un volume de plus | non, réplication incluse |
| CVE à suivre | oui | non |
| Console à protéger | oui | non |
| Perte si app-01 brûle | **totale** | aucune |
Le dernier point suffit à trancher : les connaissements et confirmations de
réservation sont des documents contractuels.
---
## 1. Créer les buckets
Console Hetzner → projet `xpeditis-prod` → **Object Storage** → région `fsn1`.
Créez **deux** buckets :
| Bucket | Contenu | Visibilité |
|---|---|---|
| `xpeditis-prod-documents` | PDF, connaissements, imports CSV | **Privé** |
| `xpeditis-prod-pgbackup` | Sauvegardes WAL-G | **Privé** |
> **Deux buckets, deux jeux de clés.** Si les clés applicatives fuitent (fuite
> de secret, faille d'injection), l'attaquant atteint les documents mais **pas
> les sauvegardes**. C'est précisément ce qui permet de se relever d'un
> rançongiciel. Ne mutualisez pas.
Vérifiez que les deux sont bien en **Private**. Un bucket public exposerait des
documents commerciaux nominatifs à toute personne devinant une URL.
---
## 2. Créer les clés d'accès
**Object Storage → Credentials → Generate credentials**, deux fois :
| Nom | Bucket | Destination |
|---|---|---|
| `xpeditis-app` | `xpeditis-prod-documents` | Secret Kubernetes `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` |
| `xpeditis-walg` | `xpeditis-prod-pgbackup` | `.env.data` de db-01, `WALG_ACCESS_KEY_ID` / `WALG_SECRET_ACCESS_KEY` |
Le secret n'est affiché **qu'une fois**. Copiez-le immédiatement.
> Hetzner Object Storage ne propose pas encore de politique par bucket aussi
> fine qu'AWS IAM. La séparation repose donc sur l'usage de deux jeux de clés
> distincts, chacun n'étant présent qu'à un seul endroit. Ne copiez jamais les
> clés WAL-G dans un `Secret` Kubernetes.
---
## 3. Configuration applicative
Déjà en place dans `infra/prod/k8s/base/02-configmap-backend.yaml` :
```yaml
AWS_REGION: "fsn1"
AWS_S3_ENDPOINT: "https://fsn1.your-objectstorage.com"
AWS_S3_BUCKET: "xpeditis-prod-documents"
```
Les clés vont dans le `Secret` ([06](./06-secrets-sops.md)) :
```yaml
AWS_ACCESS_KEY_ID: "<clé xpeditis-app>"
AWS_SECRET_ACCESS_KEY: "<secret xpeditis-app>"
```
Et WAL-G dans `.env.data` sur db-01 ([04](./04-noeud-donnees.md)) :
```
WALG_S3_PREFIX=s3://xpeditis-prod-pgbackup
WALG_S3_ENDPOINT=https://fsn1.your-objectstorage.com
WALG_S3_REGION=fsn1
WALG_ACCESS_KEY_ID=<clé xpeditis-walg>
WALG_SECRET_ACCESS_KEY=<secret xpeditis-walg>
```
---
## 4. Vérifier
Avec le client AWS ou `s3cmd` :
```bash
export AWS_ACCESS_KEY_ID='<clé xpeditis-app>'
export AWS_SECRET_ACCESS_KEY='<secret xpeditis-app>'
# Lister
aws --endpoint-url https://fsn1.your-objectstorage.com s3 ls s3://xpeditis-prod-documents/
# Écrire puis relire
echo "test $(date -Is)" > /tmp/test.txt
aws --endpoint-url https://fsn1.your-objectstorage.com s3 cp /tmp/test.txt s3://xpeditis-prod-documents/
aws --endpoint-url https://fsn1.your-objectstorage.com s3 cp s3://xpeditis-prod-documents/test.txt -
aws --endpoint-url https://fsn1.your-objectstorage.com s3 rm s3://xpeditis-prod-documents/test.txt
```
### Le bucket est-il vraiment privé ?
```bash
curl -sI https://fsn1.your-objectstorage.com/xpeditis-prod-documents/test.txt | head -1
```
Attendu : `HTTP/1.1 403 Forbidden`. Si vous obtenez `200`, le bucket est
public : corrigez immédiatement.
### Les clés sont-elles bien cloisonnées ?
```bash
# Les clés applicatives ne doivent PAS voir le bucket de sauvegarde
aws --endpoint-url https://fsn1.your-objectstorage.com s3 ls s3://xpeditis-prod-pgbackup/
```
Un refus est le résultat attendu. S'il réussit, vous avez utilisé les mêmes
clés pour les deux : régénérez-en un jeu séparé.
---
## 5. Durée de conservation
Une règle de cycle de vie évite que les documents s'accumulent indéfiniment —
utile pour la facture comme pour le RGPD ([16](./16-rgpd-conformite.md)).
Attention : les documents contractuels maritimes ont des obligations de
conservation longues (généralement 10 ans en France pour les pièces
comptables). **Ne posez pas de règle de suppression automatique sur
`xpeditis-prod-documents`** sans validation juridique.
Sur `xpeditis-prod-pgbackup`, en revanche, la purge est gérée par WAL-G
(`wal-g delete retain FULL 7`, dans `pg-backup.sh`). N'ajoutez pas de règle de
cycle de vie côté bucket : les deux mécanismes se contrediraient et vous
risqueriez de supprimer des WAL encore nécessaires à une restauration.
---
## 6. Et le CDN ?
Le fichier de prévisions retient Bunny.net (0,005 €/Go, ~1 €/mois en phase 1)
comme CDN si Hetzner est choisi.
**Ce n'est pas nécessaire au lancement.** Cloudflare met déjà en cache les
ressources statiques de Next.js (`/_next/static/*`) gratuitement, et le trafic
de la phase 1 (10 Go/mois selon les hypothèses) ne justifie pas un service
supplémentaire.
Reconsidérez la question quand :
- le trafic sortant dépasse 500 Go/mois ; ou
- des utilisateurs hors d'Europe se plaignent de la latence ; ou
- vous servez des documents volumineux directement depuis Object Storage.
---
## 7. Contrôle
```
[ ] Bucket xpeditis-prod-documents créé, PRIVÉ
[ ] Bucket xpeditis-prod-pgbackup créé, PRIVÉ
[ ] Deux jeux de clés DISTINCTS générés
[ ] Écriture / lecture / suppression testées sur le bucket documents
[ ] Accès HTTP anonyme : 403
[ ] Les clés applicatives ne peuvent PAS lire le bucket de sauvegarde
[ ] Clés applicatives dans le Secret Kubernetes
[ ] Clés WAL-G uniquement dans .env.data de db-01
[ ] Aucune règle de cycle de vie sur le bucket de sauvegarde
```
→ **Suite : [08 — DNS, TLS, Cloudflare](./08-dns-tls-cloudflare.md)**

View File

@ -0,0 +1,294 @@
# 08 — DNS, TLS et Cloudflare
**Durée : environ 1 h 30**, dont des délais d'attente incompressibles.
La référence complète des réglages Cloudflare est dans
[`infra/prod/cloudflare/README.md`](../../infra/prod/cloudflare/README.md).
Ce document donne l'ordre d'exécution et les vérifications.
---
## 1. Ordre à respecter
L'ordre compte : émettre un certificat avant que le DNS ne réponde échoue, et
Let's Encrypt limite la production à **5 échecs par heure**.
```
1. Enregistrements DNS chez Cloudflare (propagation : quelques minutes)
2. Réglages SSL/TLS Cloudflare
3. Certificat de TEST (staging) ← valide la chaîne DNS-01
4. Certificat de PRODUCTION
5. Vérifications
6. Règles WAF et cache
```
---
## 2. Enregistrements DNS
```bash
cd infra/prod/terraform && terraform output dns_records_to_create
```
Créez dans Cloudflare, **tous proxifiés (nuage orange)** :
| Type | Nom | Valeur |
|---|---|---|
| A | `xpeditis.com` | `<app_public_ipv4>` |
| A | `www` | `<app_public_ipv4>` |
| A | `app` | `<app_public_ipv4>` |
| A | `api` | `<app_public_ipv4>` |
| A | `grafana` | `<app_public_ipv4>` |
**Aucun enregistrement ne pointe vers db-01.**
> Le proxy n'est pas optionnel : le firewall Hetzner n'accepte 80/443 que depuis
> les rangs Cloudflare. Un enregistrement en nuage gris (DNS only) donnerait un
> domaine injoignable — comportement voulu, mais déroutant si on l'a oublié.
### E-mail — à faire maintenant
```
TXT xpeditis.com v=spf1 include:spf.brevo.com -all
TXT mail._domainkey <clé DKIM fournie par Brevo>
TXT _dmarc v=DMARC1; p=quarantine; rua=mailto:dmarc@xpeditis.com; pct=100
```
Sans SPF/DKIM/DMARC, les confirmations de réservation et les liens magiques
transporteurs partent en indésirables. C'est un défaut de production silencieux
et coûteux : personne ne se plaint, les clients pensent simplement que le
service ne marche pas.
### Vérifier
```bash
dig +short app.xpeditis.com # doit renvoyer des IP Cloudflare (104.x, 172.6x…)
dig +short TXT xpeditis.com
dig +short TXT _dmarc.xpeditis.com
```
---
## 3. Réglages SSL/TLS Cloudflare
**SSL/TLS → Overview**
| Réglage | Valeur |
|---|---|
| Mode de chiffrement | **Full (strict)** |
| Always Use HTTPS | Activé |
| Minimum TLS Version | 1.2 |
| TLS 1.3 | Activé |
| Automatic HTTPS Rewrites | Activé |
| HSTS | **Pas encore** — voir §6 |
> **« Full (strict) » et rien d'autre.** En mode *Flexible*, Cloudflare parle en
> clair à votre serveur : le cadenas s'affiche dans le navigateur alors que le
> trafic circule en clair sur Internet. C'est pire que pas de HTTPS du tout,
> puisque personne ne s'en aperçoit.
---
## 4. Certificat de test
On valide d'abord la chaîne DNS-01 avec l'émetteur *staging*, qui n'a pas de
quota serré.
```bash
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
kubectl -n xpeditis-prod patch certificate xpeditis-wildcard --type=merge \
-p '{"spec":{"issuerRef":{"name":"letsencrypt-staging","kind":"ClusterIssuer"}}}'
kubectl -n xpeditis-prod delete secret xpeditis-wildcard-tls --ignore-not-found
# Suivre l'émission
kubectl -n xpeditis-prod get certificate -w
kubectl -n xpeditis-prod describe certificaterequest
kubectl -n cert-manager logs -l app=cert-manager --tail=50 -f
```
Deux à cinq minutes (propagation de l'enregistrement TXT `_acme-challenge`).
`READY: True` signifie que le jeton Cloudflare, les permissions et la zone sont
corrects. Si l'émission échoue :
| Message | Cause |
|---|---|
| `Cloudflare API error 10000` | Jeton invalide ou permissions insuffisantes (`Zone / DNS / Edit` requis). |
| `could not find zone` | Le jeton ne couvre pas `xpeditis.com`, ou la zone n'est pas active. |
| `propagation check failed` | Attendre. Si cela persiste au-delà de 10 min, vérifier qu'aucun autre DNS ne fait autorité. |
---
## 5. Certificat de production
Une fois le test au vert :
```bash
kubectl -n xpeditis-prod patch certificate xpeditis-wildcard --type=merge \
-p '{"spec":{"issuerRef":{"name":"letsencrypt-prod","kind":"ClusterIssuer"}}}'
kubectl -n xpeditis-prod delete secret xpeditis-wildcard-tls --ignore-not-found
kubectl -n monitoring patch certificate grafana-tls --type=merge \
-p '{"spec":{"issuerRef":{"name":"letsencrypt-prod","kind":"ClusterIssuer"}}}'
kubectl -n monitoring delete secret grafana-tls --ignore-not-found
kubectl get certificate -A -w
```
Vérifiez l'émetteur réel du certificat servi :
```bash
echo | openssl s_client -connect api.xpeditis.com:443 -servername api.xpeditis.com 2>/dev/null \
| openssl x509 -noout -issuer -subject -dates
```
Attendu : `issuer=C=US, O=Let's Encrypt, CN=R11` (ou équivalent).
Si vous lisez `(STAGING)`, le certificat de test est encore en place :
supprimez le `Secret` et relancez.
> Avec Cloudflare en proxy, le navigateur voit le certificat **Cloudflare**, pas
> le vôtre. La commande ci-dessus interroge Cloudflare. Pour vérifier le
> certificat d'origine, il faut se connecter depuis app-01 :
> ```bash
> ssh deploy@<app_ip> \
> "echo | openssl s_client -connect 127.0.0.1:443 -servername api.xpeditis.com 2>/dev/null | openssl x509 -noout -issuer -dates"
> ```
---
## 6. HSTS
**À faire seulement quand tous les sous-domaines servent du HTTPS valide.**
Traefik pose déjà l'en-tête (`k8s/base/08-traefik-middlewares.yaml`,
`stsSeconds: 31536000`). Activer HSTS **aussi** chez Cloudflare le fait
appliquer avant même que la requête n'atteigne le serveur.
**SSL/TLS → Edge Certificates → HTTP Strict Transport Security → Enable**
| Réglage | Valeur |
|---|---|
| Max Age | 12 mois |
| Include subdomains | Oui |
| Preload | Oui |
| No-Sniff | Oui |
> **C'est irréversible pendant un an.** Une fois HSTS envoyé, les navigateurs
> refuseront tout accès HTTP à `xpeditis.com` et à ses sous-domaines pendant la
> durée annoncée, même si vous désactivez le réglage. Un sous-domaine qui
> n'aurait pas de certificat valide deviendrait inaccessible sans recours.
>
> Commencez par `Max Age = 1 mois`, sans *preload*. Passez à 12 mois et
> *preload* après deux semaines sans incident.
---
## 7. Règles WAF et cache
Appliquez les règles décrites dans
[`infra/prod/cloudflare/README.md`](../../infra/prod/cloudflare/README.md) :
1. **Bypass cache sur `api.xpeditis.com/*`** — la plus importante. Une réponse
d'API mise en cache servirait les données d'un utilisateur à un autre.
2. Blocage de `/api/docs`.
3. Limitation de débit sur `/api/v1/auth/login` (10 requêtes/minute/IP).
4. Restriction du webhook Stripe aux IP de Stripe.
5. Cache long sur `/_next/static/*`.
---
## 8. Vérifications finales
```bash
# Les redirections
curl -sI http://xpeditis.com | head -3 # 301 → https
curl -sI https://www.xpeditis.com | head -1 # 200
curl -sI https://app.xpeditis.com | head -1 # 200
curl -sI https://api.xpeditis.com/api/v1/health | head -1 # 200
# Les en-têtes de sécurité
curl -sI https://app.xpeditis.com | grep -iE 'strict-transport|x-frame|x-content-type|referrer-policy'
# L'API n'est pas mise en cache
curl -sI https://api.xpeditis.com/api/v1/health | grep -i 'cf-cache-status'
# Attendu : BYPASS ou DYNAMIC, jamais HIT
# L'origine est-elle contournable ?
curl -sS --max-time 5 --connect-to app.xpeditis.com:443:<app_public_ipv4>:443 \
https://app.xpeditis.com/ 2>&1 | head -2
# Attendu : timeout — le firewall Hetzner bloque tout ce qui n'est pas Cloudflare
```
Analyse externe : [ssllabs.com/ssltest](https://www.ssllabs.com/ssltest/) sur
`app.xpeditis.com` — visez A ou A+.
---
## 9. Dépannage
### Le certificat ne s'émet pas
```bash
kubectl -n xpeditis-prod describe certificate xpeditis-wildcard
kubectl -n xpeditis-prod get certificaterequest,order,challenge
kubectl -n cert-manager logs -l app=cert-manager --tail=100
```
Le `Challenge` indique précisément à quelle étape cela bloque.
### Le certificat approche de l'expiration
cert-manager renouvelle 30 jours avant l'échéance. L'alerte
`CertificatBientotExpire` se déclenche à 15 jours — c'est-à-dire uniquement si
le renouvellement automatique a cessé de fonctionner.
Forcer un renouvellement :
```bash
kubectl -n xpeditis-prod delete secret xpeditis-wildcard-tls
# cert-manager réémet dans la minute
```
### « Too many certificates already issued »
Vous avez atteint la limite Let's Encrypt (50 par domaine et par semaine).
Elle se réinitialise glissant sur 7 jours. En attendant, utilisez
`letsencrypt-staging` — et c'est exactement pourquoi la validation se fait
d'abord en staging.
### Erreur 521 / 522 chez Cloudflare
Cloudflare n'atteint pas l'origine.
```bash
ssh deploy@<app_ip> 'sudo systemctl status k3s; sudo ss -tlnp | grep -E ":(80|443) "'
kubectl -n kube-system get pods -l app.kubernetes.io/name=traefik
```
Cause fréquente : les rangs d'IP Cloudflare ont changé.
```bash
bash infra/prod/scripts/refresh-cloudflare-ips.sh
```
---
## 10. Contrôle
```
[ ] 5 enregistrements A créés, tous proxifiés
[ ] Aucun enregistrement ne pointe vers db-01
[ ] SPF, DKIM, DMARC publiés
[ ] Mode SSL : Full (strict)
[ ] Certificat de test émis avec succès (chaîne DNS-01 validée)
[ ] Certificat de production émis, Ready: True, émetteur non-staging
[ ] HTTP redirige en 301 vers HTTPS
[ ] En-têtes de sécurité présents
[ ] cf-cache-status = BYPASS sur l'API
[ ] L'origine n'est pas joignable en contournant Cloudflare
[ ] Règles WAF appliquées
[ ] Note SSL Labs ≥ A
[ ] HSTS activé (après vérification, max-age court d'abord)
```
→ **Suite : [09 — Déploiement applicatif](./09-deploiement-application.md)**

View File

@ -0,0 +1,337 @@
# 09 — Déploiement de l'application
**Durée : environ 2 h.** Premier déploiement, réalisé à la main. L'automatisation
vient à l'étape suivante — on ne débogue pas un premier déploiement à travers
une chaîne CI.
---
## Le premier administrateur
La migration `1730000000007-SeedTestUsers` créait trois comptes dont le mot de
passe est écrit **en clair dans le dépôt** — dont `admin@xpeditis.com`, rôle
ADMIN, mot de passe `Password123!`. Sur une base de production neuve, appliquer
les migrations créait donc un administrateur aux identifiants publics.
**Trois mécanismes remplacent cela**, et fonctionnent sans intervention :
| Migration | Effet |
|---|---|
| `1730000000007-SeedTestUsers` (modifiée) | **Ne s'exécute plus** quand `NODE_ENV=production`. Les comptes ne sont jamais créés. |
| `1756000000000-NeutralizeSeedAccountsInProduction` | Filet de sécurité : si ces comptes existent malgré tout (base migrée avant la garde, restauration ancienne), ils sont renommés, rendus inauthentifiables et désactivés. La migration **échoue** si le nettoyage est incomplet. |
| `1756000000001-BootstrapAdminFromEnv` | Crée **votre** administrateur à partir de `BOOTSTRAP_ADMIN_EMAIL`. |
### Comment votre mot de passe est défini
Dans le cas nominal — `BOOTSTRAP_ADMIN_EMAIL` renseigné dans le ConfigMap,
`BOOTSTRAP_ADMIN_PASSWORD_HASH` laissé **vide** — le compte est créé avec le
hash Argon2id d'un secret aléatoire immédiatement perdu. Le compte existe, il
est actif, mais **aucun mot de passe ne peut y correspondre**. Vous définissez
le vôtre via « mot de passe oublié », qui envoie un jeton à usage unique,
valable une heure, stocké haché en base.
Conséquence : **aucun secret n'existe nulle part** — ni dans Git, ni dans le
Secret Kubernetes, ni dans l'historique du shell, ni dans les journaux de
migration. Il n'y a rien à faire fuiter. Effet de bord utile : la réception du
courriel prouve que la chaîne SMTP fonctionne.
> **Variante, si SMTP n'est pas encore opérationnel.** Générez un hash hors
> ligne (`node apps/backend/scripts/setup/generate-admin-hash.js`) et placez-le
> dans `BOOTSTRAP_ADMIN_PASSWORD_HASH`, côté **Secret** et jamais ConfigMap. Un
> hash reste attaquable hors ligne : changez le mot de passe dès la première
> connexion, puis retirez la variable et réappliquez le Secret. Un mot de passe
> en clair placé dans cette variable fait échouer la migration.
Garde-fous : la migration ne fait rien s'il existe déjà un administrateur actif
— elle ne peut donc pas en créer un second lors d'un déploiement ultérieur. Et
si un compte porte déjà cette adresse, il est promu ADMIN **sans que son mot de
passe ne soit touché**.
---
## 1. Construire les premières images
La chaîne de preprod produit les images `preprod-<sha>`. Pour le premier
déploiement, on les fabrique à la main.
```bash
export SHA=$(git rev-parse --short=7 HEAD)
export REGISTRY=rg.fr-par.scw.cloud/weworkstudio
docker login "$REGISTRY" -u nologin -p '<REGISTRY_TOKEN>'
# Backend — aucune variable de build : tout est lu au démarrage
docker buildx build --platform linux/amd64 \
-t "$REGISTRY/xpeditis-backend:prod-$SHA" \
-f apps/backend/Dockerfile apps/backend --push
# Frontend — les URLs sont FIGÉES ici, pas à l'exécution
docker buildx build --platform linux/amd64 \
--build-arg NEXT_PUBLIC_API_URL=https://api.xpeditis.com \
--build-arg NEXT_PUBLIC_APP_URL=https://app.xpeditis.com \
-t "$REGISTRY/xpeditis-frontend:prod-$SHA" \
-f apps/frontend/Dockerfile apps/frontend --push
# Log exporter
docker buildx build --platform linux/amd64 \
-t "$REGISTRY/xpeditis-log-exporter:prod-$SHA" \
-f apps/log-exporter/Dockerfile apps/log-exporter --push
```
> **`next.config.js` fige `NEXT_PUBLIC_API_URL` au moment du build.** Une image
> construite avec l'URL de preprod appellera `api.preprod.xpeditis.com` en
> production, quelles que soient les variables injectées dans le pod. C'est
> pour cette raison que le frontend est **reconstruit** pour la production et
> jamais promu depuis la preprod.
Vérifiez que l'URL de preprod n'a pas fuité dans le bundle :
```bash
CID=$(docker create "$REGISTRY/xpeditis-frontend:prod-$SHA")
docker cp "$CID:/app/.next" /tmp/next-check && docker rm "$CID"
grep -rl "api.preprod.xpeditis.com" /tmp/next-check && echo "PROBLEME" || echo "OK"
rm -rf /tmp/next-check
```
---
## 2. Appliquer la configuration
```bash
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
cd infra/prod
kubectl apply -f k8s/base/00-namespaces.yaml
kubectl apply -f k8s/base/01-limits.yaml
kubectl apply -f k8s/base/02-configmap-backend.yaml
kubectl apply -f k8s/base/08-traefik-middlewares.yaml
kubectl apply -f k8s/base/10-network-policies.yaml
kubectl apply -f k8s/base/11-certificate.yaml
```
Relisez la configuration avant d'aller plus loin :
```bash
kubectl -n xpeditis-prod get cm xpeditis-backend-config -o yaml | grep -E 'DATABASE_HOST|DATABASE_SSL|COOKIE_DOMAIN|CORS_ORIGIN|AWS_S3'
```
Cinq valeurs à ne pas rater :
- `DATABASE_SSL: "true"` — `pg_hba` n'accepte que `hostssl` ;
- `COOKIE_DOMAIN: ".xpeditis.com"` — avec le point initial ;
- `CORS_ORIGIN` — doit contenir **exactement** les origines du frontend
(`credentials: true` interdit le joker `*`) ;
- `NODE_ENV: "production"` — c'est ce qui empêche `SeedTestUsers` de s'exécuter ;
- `BOOTSTRAP_ADMIN_EMAIL` — une adresse que vous **relevez réellement**, c'est
par elle que passera le lien de définition du mot de passe.
---
## 3. Migrations, puis démarrage
### 3.1 Migrations
```bash
sed "s|__IMAGE_TAG__|prod-$SHA|g" k8s/base/07-migration-job.yaml | kubectl apply -f -
kubectl -n xpeditis-prod wait --for=condition=complete "job/xpeditis-migrate-prod-$SHA" --timeout=900s
kubectl -n xpeditis-prod logs "job/xpeditis-migrate-prod-$SHA"
```
40 migrations doivent s'appliquer. En cas d'échec, les journaux du Job donnent
la requête SQL fautive.
Trois lignes à repérer dans la sortie :
```
SeedTestUsers ignore : NODE_ENV=production. ...
[neutralisation] Aucun compte de démonstration présent.
[amorçage admin] Administrateur ops@xpeditis.com créé (aucun mot de passe — à définir via « mot de passe oublié »).
```
Si vous lisez `Seeded test users successfully`, **arrêtez-vous** : `NODE_ENV`
n'est pas positionné à `production` dans le ConfigMap. Corrigez, puis exécutez
`infra/prod/scripts/harden-seed-data.sh` sur db-01 avant de continuer.
> **Pourquoi un Job.** L'image lance déjà les migrations à chaque démarrage de
> pod (`scripts/setup/startup.js`). Avec deux replicas, deux processus migrent
> simultanément ; TypeORM ne sérialise pas entre processus et l'un des deux
> part en `CrashLoopBackOff`. Le Job (parallélisme 1) applique tout d'abord ;
> `startup.js` ne fait ensuite que constater qu'il n'y a rien à migrer.
### 3.2 Vérifier l'état des comptes avant d'ouvrir quoi que ce soit
```bash
ssh deploy@<db_public_ipv4> 'cd /opt/xpeditis/data-node && \
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c \
"SELECT email, role, is_active FROM users ORDER BY role, email;"'
```
Attendu : **une seule ligne**, votre administrateur, `is_active = t`.
Aucune adresse `@xpeditis.com` de démonstration ne doit apparaître.
### 3.3 Démarrer l'application
```bash
kubectl apply -f k8s/base/04-backend.yaml
kubectl apply -f k8s/base/05-frontend.yaml
kubectl apply -f k8s/base/06-log-exporter.yaml
kubectl -n xpeditis-prod set image deploy/xpeditis-backend "backend=$REGISTRY/xpeditis-backend:prod-$SHA"
kubectl -n xpeditis-prod set image deploy/xpeditis-frontend "frontend=$REGISTRY/xpeditis-frontend:prod-$SHA"
kubectl -n xpeditis-prod set image deploy/xpeditis-log-exporter "log-exporter=$REGISTRY/xpeditis-log-exporter:prod-$SHA"
kubectl -n xpeditis-prod rollout status deploy/xpeditis-backend --timeout=300s
kubectl -n xpeditis-prod rollout status deploy/xpeditis-frontend --timeout=300s
```
### 3.4 Exposer
```bash
kubectl apply -f k8s/base/09-ingress.yaml
kubectl -n xpeditis-prod get ingress
```
---
## 4. Vérifications
```bash
kubectl -n xpeditis-prod get pods -o wide
kubectl -n xpeditis-prod logs -l app.kubernetes.io/name=xpeditis-backend --tail=50
```
Le backend doit afficher `PostgreSQL is ready`, `No pending migrations`, puis
la bannière de démarrage.
```bash
# De l'extérieur
curl -s https://api.xpeditis.com/api/v1/health | jq
curl -sI https://app.xpeditis.com/ | head -1
# Les comptes de démonstration sont bien morts
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api.xpeditis.com/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@xpeditis.com","password":"Password123!"}'
# Attendu : 401
# Swagger désactivé
curl -s -o /dev/null -w '%{http_code}\n' https://api.xpeditis.com/api/docs
# Attendu : 404 (ou 401 si vous avez choisi de le protéger)
bash scripts/smoke-test.sh
```
### Symptômes fréquents
| Symptôme | Cause probable | Vérification |
|---|---|---|
| `CrashLoopBackOff`, `password authentication failed` | `DATABASE_PASSWORD` ≠ `POSTGRES_PASSWORD` de db-01 | Comparer les deux |
| `CrashLoopBackOff`, `no pg_hba.conf entry ... SSL off` | `DATABASE_SSL` absent ou à `false` | ConfigMap |
| `CrashLoopBackOff`, `config validation error` | Une variable requise par Joi manque | Les journaux la nomment |
| `ImagePullBackOff` | `regcred` absent ou périmé | `kubectl -n xpeditis-prod get secret regcred` |
| Connexion OK mais retour sur `/login` | `COOKIE_DOMAIN` sans point initial | ConfigMap |
| Erreurs CORS dans la console navigateur | `CORS_ORIGIN` incomplet | ConfigMap |
| L'app appelle `api.preprod…` | Image frontend promue au lieu d'être reconstruite | Reconstruire |
---
## 5. Définir le mot de passe de votre administrateur
Le compte a été créé par la migration, sans mot de passe utilisable. Vous
définissez le vôtre par le flux de réinitialisation — le même que celui de vos
utilisateurs, ce qui le valide au passage.
```bash
# 1. Demander le lien
curl -sS -X POST https://api.xpeditis.com/api/v1/auth/forgot-password \
-H 'Content-Type: application/json' \
-d '{"email":"ops@xpeditis.com"}'
# Réponse toujours 200, même pour une adresse inconnue (anti-énumération).
```
Ou simplement depuis `https://app.xpeditis.com/fr/forgot-password`.
```
2. Ouvrir le lien reçu par courriel (valable 1 heure, à usage unique)
3. Définir un mot de passe long et aléatoire, issu de votre gestionnaire
4. Se connecter sur https://app.xpeditis.com/fr/login
```
### Contrôles
```bash
# Un seul ADMIN actif, le vôtre
ssh deploy@<db_public_ipv4> 'cd /opt/xpeditis/data-node && \
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c \
"SELECT email, role, is_active FROM users WHERE role = '"'"'ADMIN'"'"';"'
```
> Si vous avez utilisé la variante `BOOTSTRAP_ADMIN_PASSWORD_HASH` : connectez-vous,
> **changez le mot de passe depuis l'interface**, puis retirez la variable du
> Secret et réappliquez-le. Un hash qui reste dans un coffre-fort est une cible
> d'attaque hors ligne sans aucune contrepartie une fois le compte opérationnel.
### Si le courriel n'arrive pas
C'est la chaîne SMTP qui est en cause :
```bash
kubectl -n xpeditis-prod logs -l app.kubernetes.io/name=xpeditis-backend | grep -i smtp
```
Causes habituelles : clé SMTP Brevo invalide (celle de preprod est compromise
et doit avoir été remplacée), domaine expéditeur non vérifié chez Brevo, SPF ou
DKIM absents ([08](./08-dns-tls-cloudflare.md)).
---
## 6. Données de référence
Les migrations installent déjà les ports (`SeedMajorPorts`), les transporteurs
(`SeedCarriersAndOrganizations`) et les abonnements gratuits
(`SeedFreeSubscriptions`).
```bash
ssh deploy@<db_public_ipv4> 'cd /opt/xpeditis/data-node && \
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c "
SELECT '\''ports'\'' AS t, count(*) FROM ports
UNION ALL SELECT '\''carriers'\'', count(*) FROM carriers
UNION ALL SELECT '\''organizations'\'', count(*) FROM organizations
UNION ALL SELECT '\''users'\'', count(*) FROM users;"'
```
Trois **organisations de démonstration** subsistent (`Test Freight Forwarder
Inc.`, `Demo Shipping Company`, `Sample Shipper Ltd.`). Elles ne sont pas
supprimées automatiquement : les comptes désactivés y sont rattachés et une
suppression en cascade toucherait `audit_logs`. Renommez-les ou masquez-les
depuis l'interface d'administration si elles gênent.
### Grilles tarifaires
Les grilles se chargent par l'interface d'administration (import CSV 33
colonnes). Chargez au moins une grille export **avant l'ouverture** : sans
grille, la recherche de tarifs ne renvoie rien et la plateforme paraît cassée.
---
## 7. Contrôle
```
[ ] Images prod-<sha> construites et poussées (frontend reconstruit)
[ ] Aucune URL de preprod dans le bundle frontend
[ ] ConfigMap vérifié : NODE_ENV, DATABASE_SSL, COOKIE_DOMAIN, CORS_ORIGIN,
BOOTSTRAP_ADMIN_EMAIL
[ ] Job de migration terminé, 40 migrations appliquées
[ ] Journal du Job : « SeedTestUsers ignore : NODE_ENV=production »
[ ] Journal du Job : « [amorçage admin] Administrateur ... créé »
[ ] La table users ne contient QUE votre administrateur
[ ] Connexion admin@xpeditis.com / Password123! → 401
[ ] Backend et frontend : 2 pods Running chacun
[ ] Ingress créés, https://api.xpeditis.com/api/v1/health → 200
[ ] Swagger inaccessible
[ ] smoke-test.sh au vert
[ ] Lien « mot de passe oublié » reçu, mot de passe défini, connexion réussie
[ ] Un seul ADMIN actif en base
[ ] Au moins une grille tarifaire chargée
```
→ **Suite : [10 — CI/CD](./10-cicd-github-actions.md)**

View File

@ -0,0 +1,260 @@
# 10 — CI/CD GitHub Actions
**Durée : environ 1 h 30.**
Une fois le premier déploiement manuel réussi, on automatise. Le workflow
`.github/workflows/cd-main.yml` a été réécrit pour cette infrastructure.
---
## 1. Enchaînement
```
push sur main
│
├─ Lint + type-check + tests unitaires (backend, frontend)
│
├─ Vérification : l'image preprod-<sha> existe-t-elle ?
│ Si non → BLOCAGE. Ce commit n'est pas passé par la preprod.
│
├─ Promotion du backend preprod-<sha> → prod-<sha> (aucun rebuild)
├─ Reconstruction du frontend avec les URLs de production
│ + contrôle : aucune URL de preprod dans le bundle
│
├─ Déploiement (environnement protégé « production »)
│ 1. ouverture du port 22 pour l'IP du runner (firewall Hetzner dédié)
│ 2. rsync de infra/prod vers app-01
│ 3. ssh « deploy prod-<sha> » → migrations, images, attente
│ 4. tests de fumée depuis l'extérieur
│ 5. retour arrière automatique en cas d'échec
│ 6. fermeture du firewall — TOUJOURS, même sur échec ou annulation
│
└─ Notification Discord
```
---
## 2. Les trois décisions structurantes
### 2.1 Le backend est promu, le frontend est reconstruit
Promouvoir garantit que le binaire déployé est **exactement** celui qui a passé
la chaîne de preprod, au condensat près. Un rebuild casserait cette garantie.
Mais `next.config.js` fige `NEXT_PUBLIC_API_URL` au moment du build. L'ancien
workflow re-taguait l'image frontend de preprod vers la production : le résultat
aurait été une application appelant `api.preprod.xpeditis.com` en production.
Le frontend est donc **reconstruit** depuis le commit exact déjà vérifié, avec
les URLs de production, et une étape de contrôle échoue si la chaîne
`api.preprod.xpeditis.com` se retrouve malgré tout dans le bundle.
### 2.2 Le déploiement passe par SSH, pas par l'API Kubernetes
L'API k3s (6443) n'est ouverte qu'à vos IP d'administration. Les runners GitHub
n'ont pas d'IP fixe, et leurs rangs publiés sont trop vastes et trop mouvants
pour une liste blanche.
Le job ouvre donc le port 22 pour **la seule IP du runner en cours**, via un
firewall Hetzner dédié (`xpeditis-prod-fw-cicd`), puis le referme dans une
étape `if: always()` — donc y compris si le déploiement échoue, si le job est
annulé ou s'il expire.
### 2.3 Le kubeconfig et la clé SOPS ne sont pas dans GitHub
- Le **kubeconfig** donne les pleins pouvoirs sur le cluster. Un dépôt compromis
ne doit pas les offrir. La CI utilise le kubeconfig local du serveur, à
travers une clé SSH restreinte à un script.
- La **clé age** déchiffre tous les secrets de production. Les secrets sont
appliqués depuis votre poste (`make secrets-apply`), jamais par la CI.
---
## 3. Configurer GitHub
### 3.1 Environnement protégé
**Settings → Environments → New environment → `production`**
| Réglage | Valeur |
|---|---|
| Required reviewers | **vous** (au moins une personne) |
| Deployment branches | `main` uniquement |
| Wait timer | 0 |
> Sans `Required reviewers`, tout `push` sur `main` déploie en production sans
> validation humaine. Avec, chaque déploiement demande une confirmation
> explicite — deux secondes qui ont déjà sauvé beaucoup de vendredis soir.
### 3.2 Secrets et variables
La liste complète, avec la façon d'obtenir chaque valeur, est dans
[`infra/prod/env/github-secrets.md`](../../infra/prod/env/github-secrets.md).
Résumé :
**Secrets** : `REGISTRY_TOKEN`, `HCLOUD_TOKEN_CICD`, `PROD_SSH_HOST`,
`PROD_SSH_USER`, `PROD_SSH_KEY`, `PROD_SSH_KNOWN_HOSTS`,
`NEXT_PUBLIC_API_URL_PROD`, `NEXT_PUBLIC_APP_URL_PROD`, `DISCORD_WEBHOOK_URL`.
**Variables** : `PROD_API_URL`, `PROD_APP_URL`, `HCLOUD_CICD_FIREWALL`.
```bash
# Empreinte du serveur, pour PROD_SSH_KNOWN_HOSTS
ssh-keyscan -H <app_public_ipv4>
```
> L'empreinte épinglée n'est pas une formalité : sans elle, un détournement DNS
> ou BGP pourrait rediriger le déploiement — clé SSH comprise — vers une
> machine tierce.
---
## 4. Préparer le serveur
### 4.1 Répertoire de travail
```bash
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> '
sudo mkdir -p /opt/xpeditis/infra-prod
sudo chown -R deploy:deploy /opt/xpeditis
'
```
### 4.2 Clé de déploiement restreinte
```bash
# Clé publique de la CI (générée en 01-prerequis.md)
cat ~/.ssh/xpeditis_ci.pub
```
Sur app-01, ajoutez-la **avec des restrictions** :
```bash
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> '
cat >> ~/.ssh/authorized_keys <<EOF
restrict,pty,command="/opt/xpeditis/infra-prod/scripts/ssh-deploy-wrapper.sh" ssh-ed25519 AAAA... github-actions-prod
EOF
chmod 600 ~/.ssh/authorized_keys
'
```
`restrict` désactive le transfert de ports, d'agent et X11.
`command=` force l'exécution du script quelle que soit la commande demandée :
**une clé volée ne donne pas un shell**.
Le wrapper (`infra/prod/scripts/ssh-deploy-wrapper.sh`) n'accepte que quatre
choses : le `rsync` de `infra/prod`, `deploy prod-<sha>` (avec validation
stricte du tag), `rollback` et `status`. Tout le reste est refusé et journalisé.
### 4.3 Premier envoi manuel du wrapper
Le wrapper doit exister avant que la clé restreinte ne puisse servir :
```bash
rsync -az -e "ssh -i ~/.ssh/xpeditis_prod" \
infra/prod/ deploy@<app_public_ipv4>:/opt/xpeditis/infra-prod/
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> \
'chmod +x /opt/xpeditis/infra-prod/scripts/*.sh'
```
### 4.4 Tester la clé restreinte
```bash
# Autorisé
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> "status"
# Refusé — c'est le résultat attendu
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> "cat /opt/xpeditis/data-node/.env.data"
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> # pas de shell
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> "deploy ; rm -rf /"
```
Les refus sont tracés :
```bash
ssh deploy@<app_public_ipv4> 'sudo journalctl -t xpeditis-ssh-deploy -n 20'
```
---
## 5. Premier déploiement automatique
```bash
git checkout preprod && git merge main && git push # chaîne preprod
# ... attendre que cd-preprod.yml passe au vert ...
git checkout main && git merge preprod && git push
```
Suivez l'exécution dans l'onglet Actions. Le job `deploy` attendra votre
approbation.
### Vérifier que le firewall s'est bien refermé
```bash
hcloud firewall describe xpeditis-prod-fw-cicd
```
Attendu : **aucune règle**. S'il en reste une, une exécution a été interrompue
d'une façon qui a contourné le `if: always()` :
```bash
echo '[]' > /tmp/empty.json
hcloud firewall replace-rules xpeditis-prod-fw-cicd --rules-file /tmp/empty.json
```
Ajoutez ce contrôle à votre routine hebdomadaire.
---
## 6. Le workflow de retour arrière
`.github/workflows/rollback.yml` existe déjà. Vérifiez qu'il cible bien la
nouvelle infrastructure ; à défaut, le retour arrière manuel reste disponible :
```bash
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> "rollback"
# ou
make -C infra/prod rollback
```
> **Le retour arrière ne défait pas les migrations.** Si la version retirée
> contenait une migration destructrice (colonne supprimée, type modifié),
> l'ancienne version applicative peut ne plus fonctionner contre le schéma
> courant. Voir
> [15 § Retour arrière avec migration](./15-exploitation-incidents.md#retour-arriere-avec-migration).
---
## 7. Ce que la chaîne ne fait pas
Elle est volontairement conservatrice. Elle **ne fait pas** :
- de tests end-to-end contre la production (Playwright tourne sur la preprod) ;
- de déploiement bleu-vert ou canari — inutile à cette échelle, le
`maxUnavailable: 0` suffit à éviter toute coupure ;
- de sauvegarde avant déploiement — la sauvegarde quotidienne et l'archivage
continu couvrent le besoin. Avant une migration risquée, prenez une
sauvegarde manuelle ([12](./12-sauvegardes-restauration.md)) ;
- d'analyse de vulnérabilité des images. À ajouter (Trivy) quand vous en aurez
le temps ; ce n'est pas bloquant pour le lancement.
---
## 8. Contrôle
```
[ ] Environnement GitHub « production » créé, Required reviewers actif
[ ] Branches de déploiement limitées à main
[ ] 9 secrets renseignés
[ ] 3 variables renseignées
[ ] /opt/xpeditis/infra-prod créé et appartenant à deploy
[ ] Clé CI ajoutée avec restrict + command=
[ ] « status » fonctionne, tout le reste est refusé
[ ] Refus visibles dans journalctl -t xpeditis-ssh-deploy
[ ] Premier déploiement automatique réussi
[ ] Firewall CI vide après exécution
[ ] Notification Discord reçue
```
→ **Suite : [11 — Observabilité](./11-observabilite.md)**

View File

@ -0,0 +1,273 @@
# 11 — Observabilité
**Durée : environ 2 h.**
Principe : **ce qui n'est pas surveillé n'existe pas.** Une panne détectée par
un client est une panne détectée trop tard.
---
## 1. Les composants, et pourquoi chacun est là
| Composant | Rôle | Pourquoi lui |
|---|---|---|
| **Loki** | Journaux | Indexe des étiquettes, pas le texte intégral : ~10× moins gourmand qu'Elasticsearch, largement suffisant ici. |
| **Promtail** | Collecte | Lit `/var/log/pods` (containerd, pas Docker : k3s n'utilise pas Docker). Découpe le JSON pino en champs. |
| **Prometheus** | Métriques | 15 jours de rétention. Sans métriques, on ne voit pas une dégradation venir. |
| **node-exporter** | Métriques système | Présent pour une alerte avant tout : **disque plein**, la panne la plus fréquente d'un serveur laissé seul. |
| **Alertmanager** | Routage | Envoie sur Discord. Règles d'inhibition pour éviter le déluge. |
| **Grafana** | Consultation | Une interface pour les deux sources. |
| **healthchecks.io** ou **BetterStack** | Externe | **Indispensable** : voir §5. |
Coût : 0 €. Empreinte : environ 1,5 Go de RAM sur les 16 du serveur.
---
## 2. Déployer
```bash
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
cd infra/prod
make deploy-monitoring
```
Le script vérifie d'abord que les secrets `grafana-admin` et
`alertmanager-secrets` existent, puis déploie les six composants et attend leur
démarrage.
```bash
kubectl -n monitoring get pods,pvc
```
---
## 3. Grafana
`https://grafana.xpeditis.com` — identifiants dans le `Secret`
`monitoring/grafana-admin`.
### Si vous obtenez un 403
C'est le middleware Traefik `admin-ip-allowlist`. Renseignez votre IP :
```bash
$EDITOR k8s/base/08-traefik-middlewares.yaml # section admin-ip-allowlist
kubectl apply -f k8s/base/08-traefik-middlewares.yaml
```
> Grafana donne accès à l'intégralité de vos journaux applicatifs, jetons
> tronqués et identifiants clients compris. Trois couches le protègent : le
> firewall Hetzner (IP Cloudflare uniquement), le filtrage par IP Traefik, et
> l'authentification Grafana. Si votre IP est dynamique, remplacez la deuxième
> par **Cloudflare Access** (Zero Trust, gratuit) plutôt que d'élargir la liste.
### Vérifier les sources de données
**Connections → Data sources** : `Loki` et `Prometheus` doivent afficher
« Data source is working ».
### Premières requêtes utiles
```logql
# Toutes les erreurs du backend sur 1 h
{namespace="xpeditis-prod", service="xpeditis-backend"} | json | level="error"
# Échecs d'authentification
{namespace="xpeditis-prod"} |= "Unauthorized" or "Invalid credentials"
# Requêtes PostgreSQL lentes (> 500 ms)
{namespace="xpeditis-prod"} |= "duration:" | json | duration > 500
# Suivre une requête de bout en bout par son identifiant
{namespace="xpeditis-prod"} | json | reqId="<uuid>"
```
```promql
# Taux d'erreur 5xx
sum(rate(traefik_service_requests_total{code=~"5.."}[5m]))
/ sum(rate(traefik_service_requests_total[5m]))
# Latence P95 par service
histogram_quantile(0.95, sum(rate(traefik_service_request_duration_seconds_bucket[5m])) by (le, service))
# Connexions PostgreSQL utilisées
sum(pg_stat_database_numbackends) / on() pg_settings_max_connections
# Espace disque restant
node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}
```
---
## 4. Les alertes
Définies dans `k8s/monitoring/03-prometheus.yaml`.
| Alerte | Seuil | Gravité |
|---|---|---|
| `BackendIndisponible` | Aucune instance saine, 2 min | critique |
| `FrontendIndisponible` | Aucune instance saine, 2 min | critique |
| `TauxErreur5xxEleve` | > 5 % pendant 5 min | critique |
| `LatenceElevee` | P95 > 2 s pendant 10 min | avertissement |
| `DisqueBientotPlein` | < 15 % libre, 10 min | critique |
| `MemoireNoeudSaturee` | > 90 %, 10 min | avertissement |
| `ConteneurRedemarreEnBoucle` | > 3 démarrages en 15 min | critique |
| `PostgresInjoignable` | `pg_up == 0`, 2 min | critique |
| `ConnexionsPostgresSaturees` | > 80 %, 5 min | avertissement |
| `CertificatBientotExpire` | < 15 jours | critique |
Les critiques sont répétées toutes les heures tant qu'elles ne sont pas
résolues ; les avertissements toutes les 12 h.
### Tester le routage — avant d'en avoir besoin
```bash
kubectl -n monitoring port-forward svc/alertmanager 9093:9093 &
curl -XPOST http://localhost:9093/api/v2/alerts \
-H 'Content-Type: application/json' \
-d '[{"labels":{"alertname":"TestDeRoutage","severity":"critique"},
"annotations":{"summary":"Test de la chaine d alerte"}}]'
```
Le message doit arriver sur Discord en moins d'une minute. **S'il n'arrive pas,
toutes les alertes ci-dessus sont décoratives.** Vérifiez alors :
```bash
kubectl -n monitoring logs deploy/alertmanager --tail=50
kubectl -n monitoring get secret alertmanager-secrets -o jsonpath='{.data.discord-webhook}' | base64 -d
```
### Vérifier que Prometheus collecte bien
```bash
kubectl -n monitoring port-forward svc/prometheus 9090:9090 &
open http://localhost:9090/targets
```
Toutes les cibles doivent être `UP`. La plus fragile est `postgres`
(`10.10.1.20:9187`) : elle passe par le réseau privé.
```bash
# Si postgres est DOWN
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && sudo docker compose ps postgres-exporter'
kubectl -n monitoring exec deploy/prometheus -- wget -qO- http://10.10.1.20:9187/metrics | head -3
```
---
## 5. Supervision externe — la seule qui compte quand tout brûle
Prometheus, Alertmanager et Grafana tournent **sur le serveur qu'ils
surveillent**. Si app-01 s'éteint, ils s'éteignent avec lui : personne ne vous
prévient. C'est le point aveugle structurel de toute supervision interne.
Il faut donc un observateur **extérieur**.
### 5.1 Disponibilité
Sur [healthchecks.io](https://healthchecks.io) (gratuit) ou BetterStack :
| Contrôle | URL | Fréquence | Alerte après |
|---|---|---|---|
| API | `https://api.xpeditis.com/api/v1/health` | 1 min | 2 échecs |
| Application | `https://app.xpeditis.com/` | 1 min | 2 échecs |
| Vitrine | `https://xpeditis.com/` | 5 min | 2 échecs |
Notification par e-mail **et** SMS (ou notification téléphonique) : un e-mail
n'est pas lu à 3 h du matin.
### 5.2 Battement de cœur des sauvegardes
C'est le **silence** qui doit alerter. Une alerte « la sauvegarde n'a pas
tourné » évaluée par un composant hébergé sur la même machine ne se déclenche
pas quand la machine est éteinte — précisément quand elle serait utile.
1. Créez un *check* de type heartbeat, période 1 jour, marge 6 h.
2. Copiez son URL de ping dans `.env.data` de db-01 :
```bash
ssh deploy@<db_ip> 'sudo $EDITOR /opt/xpeditis/data-node/.env.data'
# BACKUP_HEARTBEAT_URL=https://hc-ping.com/<uuid>
```
3. Déclenchez une sauvegarde pour vérifier :
```bash
ssh deploy@<db_ip> 'sudo systemctl start xpeditis-backup.service'
```
Le *check* doit passer au vert sur healthchecks.io.
### 5.3 Expiration du certificat
Ajoutez un contrôle TLS externe sur `app.xpeditis.com` (alerte à 14 jours).
Il double l'alerte Prometheus, mais fonctionne même quand le cluster est mort.
---
## 6. Sentry
Sentry apporte ce que les journaux ne donnent pas : la pile d'appels, les
variables locales et le regroupement des erreurs identiques.
Le backend embarque `@sentry/node`. Ajoutez `SENTRY_DSN` au `Secret` puis
redémarrez :
```bash
sops k8s/base/03-secrets.sops.yaml # SENTRY_DSN: "https://...@sentry.io/..."
make secrets-apply
kubectl -n xpeditis-prod rollout restart deploy/xpeditis-backend
```
> Vérifiez que le DSN est bien lu au démarrage. Le code appelle
> `configService.get('SENTRY_DSN')` : si l'initialisation n'est pas câblée dans
> `main.ts`, la variable est ignorée en silence. Provoquez une erreur de test et
> vérifiez qu'elle apparaît dans Sentry — sinon la variable ne sert à rien.
Configurez la **purge des données personnelles** côté Sentry (Settings →
Security & Privacy → Data Scrubbing) : sans elle, des e-mails et jetons clients
partent chez un sous-traitant américain — ce qui a des conséquences RGPD
([16](./16-rgpd-conformite.md)).
---
## 7. Ce qu'il faut regarder chaque semaine
Quinze minutes, le lundi :
```bash
make -C infra/prod status
```
1. **Erreurs** — Grafana, `{namespace="xpeditis-prod"} | json | level="error"`
sur 7 jours. Une erreur récurrente est un défaut, pas du bruit.
2. **Latence** — le P95 dérive-t-il ? Une dégradation lente précède la panne.
3. **Disque** — `node_filesystem_avail_bytes`. Projetez : dans combien de
semaines l'alerte se déclenchera-t-elle ?
4. **Sauvegardes** — le *check* heartbeat est-il vert ? La vérification
hebdomadaire de restauration est-elle passée ?
```bash
ssh deploy@<db_ip> 'sudo journalctl -u xpeditis-backup-verify -n 30 --no-pager'
```
5. **Firewall CI** — `hcloud firewall describe xpeditis-prod-fw-cicd` : vide ?
6. **Certificats** — `kubectl get certificate -A` : `Ready: True` partout ?
---
## 8. Contrôle
```
[ ] 6 composants de supervision Running
[ ] Grafana accessible, sources Loki et Prometheus fonctionnelles
[ ] Toutes les cibles Prometheus UP, y compris postgres
[ ] Alerte de test reçue sur Discord
[ ] Contrôles de disponibilité externes configurés (API, app, vitrine)
[ ] Battement de cœur des sauvegardes configuré et vert
[ ] Contrôle externe d'expiration du certificat
[ ] Sentry connecté et erreur de test visible
[ ] Purge des données personnelles activée dans Sentry
[ ] Routine hebdomadaire notée dans l'agenda
```
→ **Suite : [12 — Sauvegardes et restauration](./12-sauvegardes-restauration.md)**

View File

@ -0,0 +1,315 @@
# 12 — Sauvegardes et restauration
**Durée : environ 2 h, dont un test de restauration réel.**
> **Une sauvegarde qui n'a jamais été restaurée n'est pas une sauvegarde.**
> C'est la seule phrase de ce dossier qui mérite d'être affichée au mur.
---
## 1. Objectifs
| Indicateur | Cible | Ce que cela signifie |
|---|---|---|
| **RPO** (perte maximale) | **5 minutes** | `archive_timeout = 300` force la clôture d'un segment WAL toutes les 5 min, même sans activité. |
| **RTO** (temps de remise en service) | **< 2 h** | Reconstruire db-01 et restaurer via WAL-G. |
| Rétention PITR | 7 jours | 7 sauvegardes complètes + les WAL associés. |
| Rétention des dumps | 7 j locaux, 30 j distants | Storage Box. |
Pour une plateforme de réservation, perdre 5 minutes signifie perdre au plus
quelques réservations, identifiables dans les journaux Loki et rejouables
manuellement. C'est un compromis raisonnable à ce stade.
---
## 2. Trois mécanismes, volontairement redondants
```
db-01
│
┌───────────────────────┼───────────────────────┐
│ │ │
WAL-G pg_dump -Fc Snapshots
continu quotidien Hetzner
│ │ │
│ chiffré libsodium │ chiffré age │ (image disque)
▼ ▼ ▼
Object Storage Storage Box Hetzner Cloud
xpeditis-prod-pgbackup uXXXXXX/dumps (rétention 7 j)
```
**Pourquoi trois.**
| Panne | Ce qui vous sauve |
|---|---|
| « J'ai supprimé les réservations de mars » | **WAL-G / PITR** — retour à l'instant précédant l'erreur. |
| Corruption logique découverte 3 jours après | WAL-G ou dump du jour concerné. |
| Compte Object Storage compromis ou supprimé | **Dump sur Storage Box** — autre service, autre identifiant, autre protocole. |
| Bug WAL-G, ou montée de version majeure de PostgreSQL | **Dump logique** — restaurable dans n'importe quelle version. |
| Perte totale du serveur | WAL-G + snapshot Hetzner. |
| Rançongiciel sur db-01 | **Snapshots de la Storage Box** — voir avertissement ci-dessous. |
> **Le point faible d'un rançongiciel.** db-01 possède les identifiants d'écriture
> vers Object Storage **et** vers la Storage Box. Un attaquant ayant obtenu
> root peut donc chiffrer ou effacer les deux. La seule protection réelle est
> l'**instantané côté fournisseur** : activez les snapshots de la Storage Box
> (gratuits, dans son interface) et gardez les sauvegardes Hetzner Cloud
> activées. Ils ne sont pas accessibles depuis le serveur.
---
## 3. Ce qui tourne automatiquement
| Unité | Quand | Fait quoi |
|---|---|---|
| `xpeditis-backup.timer` | tous les jours 02h30 | WAL-G `backup-push`, purge (7 rétentions), `pg_dump` chiffré, envoi Storage Box, purge locale, battement de cœur. |
| `xpeditis-backup-verify.timer` | dimanche 04h00 | Restaure le dernier dump dans une base jetable et contrôle sa cohérence. |
| `archive_command` | en continu | Chaque segment WAL part sur Object Storage dès sa clôture. |
```bash
ssh deploy@<db_ip> "systemctl list-timers 'xpeditis-*' --no-pager"
ssh deploy@<db_ip> 'sudo journalctl -u xpeditis-backup -n 40 --no-pager'
```
### Contrôles de sécurité intégrés
Le script `pg-backup.sh` refuse de considérer une sauvegarde comme réussie si :
- le conteneur PostgreSQL n'est pas démarré ;
- `wal-g backup-push` échoue ;
- `pg_dump` échoue ;
- le dump fait moins de 50 Ko (base vide ou erreur silencieuse) ;
- le chiffrement `age` échoue ;
- le `rsync` vers la Storage Box échoue.
Chaque échec part sur Discord **et** le battement de cœur n'est pas envoyé, ce
qui déclenche l'alerte externe.
---
## 4. Le test de restauration — à faire maintenant
C'est l'étape que tout le monde saute et qui coûte le plus cher le jour venu.
### 4.1 Vérification automatique (sans impact)
```bash
ssh deploy@<db_public_ipv4>
sudo /opt/xpeditis/data-node/backup/pg-restore.sh verify
```
Ce que fait le script : déchiffre le dernier dump, crée
`xpeditis_restore_check`, restaure avec `--exit-on-error`, compte les tables et
les lignes de `users`, `organizations`, `csv_bookings`, `migrations`, échoue si
moins de 10 tables ont été restaurées, puis supprime la base jetable.
Attendu : `SAUVEGARDE VALIDE`.
### 4.2 Restauration à un instant T — le vrai test
**Faites-le avant l'ouverture au public**, pendant que l'enjeu est nul.
```bash
# 1. Marqueur horodaté
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres \
psql -d xpeditis_prod -c "CREATE TABLE test_pitr (t timestamptz DEFAULT now(), note text);
INSERT INTO test_pitr(note) VALUES (:'"'"'avant'"'"');
SELECT * FROM test_pitr;"'
# Notez l'horodatage renvoyé.
sleep 360 # au-delà de archive_timeout, pour garantir l'archivage du WAL
# 2. Destruction volontaire
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres \
psql -d xpeditis_prod -c "DROP TABLE test_pitr;"'
# 3. Arrêter l'application (sinon les écritures pendant la restauration sont perdues)
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0
# 4. Restaurer à l'instant précédant la suppression
ssh deploy@<db_ip>
sudo /opt/xpeditis/data-node/backup/pg-restore.sh pitr "2026-09-01 14:32:00"
# 5. Suivre le rejeu des WAL
cd /opt/xpeditis/data-node && sudo docker compose logs -f postgres
# Attendre : "database system is ready to accept connections"
# 6. Vérifier
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c 'SELECT * FROM test_pitr;'
# La table doit être revenue.
# 7. Nettoyer et redémarrer
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c 'DROP TABLE test_pitr;'
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2
```
**Chronométrez.** Le temps mesuré ici est votre RTO réel, pas celui du tableau.
Le script conserve l'ancien répertoire de données dans
`/var/lib/xpeditis/pgdata-avant-pitr-<horodatage>`. Supprimez-le **seulement**
après avoir validé la restauration — c'est votre filet.
---
## 5. Restauration
### 5.1 Suppression accidentelle de données — PITR
Le plus fréquent, et le plus stressant.
```bash
# 1. NE RIEN FAIRE D'AUTRE. Chaque écriture supplémentaire complique le retour.
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0
# 2. Déterminer l'instant précis, dans les journaux
# Grafana : {namespace="xpeditis-prod"} |= "DELETE" ou "DROP"
# 3. Restaurer à la seconde qui précède
ssh deploy@<db_ip>
sudo /opt/xpeditis/data-node/backup/pg-restore.sh pitr "AAAA-MM-JJ HH:MM:SS"
# 4. Vérifier AVANT de rouvrir
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod \
-c 'SELECT count(*) FROM csv_bookings; SELECT max(created_at) FROM csv_bookings;'
# 5. Rouvrir
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2
```
> **La PITR ramène toute la base à l'instant T.** Les données créées *après*
> cet instant sont perdues aussi. Si seules quelques lignes sont concernées,
> préférez une restauration logique dans une base séparée (§5.2), puis copiez
> uniquement ce qui manque.
### 5.2 Récupérer quelques lignes sans toucher à la production
```bash
ssh deploy@<db_ip>
sudo /opt/xpeditis/data-node/backup/pg-restore.sh logical \
/var/lib/xpeditis/dumps/xpeditis-20260901T023000Z.dump.age \
xpeditis_recuperation
# Copier ce qui manque, et rien d'autre
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c "
INSERT INTO csv_bookings
SELECT * FROM dblink('dbname=xpeditis_recuperation',
'SELECT * FROM csv_bookings WHERE id = ''<uuid>''')
AS t(...);"
```
Plus simple sans `dblink` : exporter les lignes en CSV depuis la base de
récupération, puis les réimporter.
La production n'est jamais touchée. C'est presque toujours la bonne approche.
### 5.3 Perte totale de db-01
RTO visé : moins de 2 h.
```bash
# 1. Recréer le serveur
cd infra/prod/terraform
terraform apply -replace=hcloud_server.db
# Le volume pgdata a prevent_destroy : il survit et sera rattaché.
# 2. Réinstaller
scp infra/prod/scripts/00-bootstrap-common.sh deploy@<nouvelle_ip>:/tmp/
ssh deploy@<nouvelle_ip> 'sudo APP_PRIVATE_IP=10.10.1.10 bash /tmp/00-bootstrap-common.sh data'
scp infra/prod/scripts/01-setup-data-node.sh deploy@<nouvelle_ip>:/tmp/
ssh deploy@<nouvelle_ip> 'sudo bash /tmp/01-setup-data-node.sh'
# 3. Redéposer configuration, .env.data (SOPS), clés age et Storage Box
# → 04-noeud-donnees.md, sections 3 et 4
# 4a. Si le volume a survécu : démarrer, c'est tout
sudo docker compose -f docker-compose.data.yml --env-file .env.data up -d --build
# 4b. Si le volume est perdu : restaurer depuis WAL-G
sudo docker compose run --rm -u postgres postgres \
wal-g backup-fetch /var/lib/postgresql/data/pgdata LATEST
sudo docker compose run --rm -u postgres postgres bash -c \
"touch /var/lib/postgresql/data/pgdata/recovery.signal
echo \"restore_command = 'wal-g wal-fetch \\\"%f\\\" \\\"%p\\\"'\" \
>> /var/lib/postgresql/data/pgdata/postgresql.auto.conf"
sudo docker compose up -d postgres
# 5. Vérifier puis rouvrir
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2
```
> Si l'IP privée du nouveau serveur diffère de `10.10.1.20`, mettez à jour
> `DATABASE_HOST` et `REDIS_HOST` dans le ConfigMap, la NetworkPolicy
> `allow-egress-to-data-node`, `pg_hba.conf` et la cible Prometheus.
> Terraform réutilise l'IP fixe déclarée : cela ne devrait pas arriver.
### 5.4 Perte totale de app-01
Beaucoup plus simple : **aucune donnée n'y réside**.
```bash
cd infra/prod/terraform && terraform apply -replace=hcloud_server.app
# Puis : 03-durcissement-serveurs.md → 05-cluster-k3s.md → 06 → 09
# Mettez à jour les enregistrements DNS Cloudflare avec la nouvelle IP.
```
Comptez une heure. C'est le bénéfice direct d'avoir gardé le nœud applicatif
entièrement sans état.
---
## 6. Sauvegarde manuelle avant opération risquée
Avant une migration lourde ou une montée de version majeure :
```bash
ssh deploy@<db_ip> 'sudo systemctl start xpeditis-backup.service'
ssh deploy@<db_ip> 'sudo journalctl -u xpeditis-backup -f'
```
Attendez la fin **avant** de lancer l'opération. Notez l'horodatage : c'est
votre point de retour.
---
## 7. Ce qui n'est pas sauvegardé, et pourquoi
| Élément | Sauvegardé ? | Justification |
|---|---|---|
| PostgreSQL | oui, 3 fois | données métier |
| Documents Object Storage | par Hetzner (réplication) | pas de version antérieure : **une suppression est définitive**. Voir ci-dessous. |
| Redis | AOF sur disque local | cache + sessions. Une perte déconnecte les utilisateurs, sans plus. |
| Journaux Loki / métriques Prometheus | non | 31 j / 15 j de rétention, sans valeur au-delà. |
| Configuration k3s | non | entièrement reconstructible depuis `infra/prod/`. |
| État Terraform | manuellement | à chiffrer SOPS ou à stocker sur un backend S3. |
> **Les documents ne sont pas versionnés.** Hetzner Object Storage réplique
> mais ne conserve pas d'historique : un connaissement supprimé par erreur — ou
> par une faille applicative — est définitivement perdu. Si ces documents ont
> une valeur contractuelle (ils en ont), ajoutez une synchronisation
> hebdomadaire vers la Storage Box :
>
> ```bash
> # Sur db-01, tâche hebdomadaire
> rclone sync hetzner:xpeditis-prod-documents \
> storagebox:xpeditis-prod/documents --backup-dir storagebox:xpeditis-prod/documents-anciens/$(date +%F)
> ```
> À mettre en place dans le premier mois d'exploitation.
---
## 8. Contrôle
```
[ ] Timers xpeditis-backup et xpeditis-backup-verify actifs
[ ] Une sauvegarde complète réussie, visible sur Object Storage ET Storage Box
[ ] pg-restore.sh verify : SAUVEGARDE VALIDE
[ ] TEST PITR RÉEL EFFECTUÉ ET RÉUSSI
[ ] RTO réel chronométré et noté
[ ] Snapshots de la Storage Box activés
[ ] Sauvegardes Hetzner Cloud activées sur les deux serveurs
[ ] Battement de cœur externe vert
[ ] WALG_LIBSODIUM_KEY et backup-age.key sauvegardées HORS LIGNE
[ ] État Terraform sauvegardé
[ ] Synchronisation des documents planifiée (dans le premier mois)
```
→ **Suite : [13 — Sécurité](./13-securite-durcissement.md)**

View File

@ -0,0 +1,310 @@
# 13 — Sécurité
**Durée : environ 3 h.** C'est le document à relire avant chaque revue et après
chaque incident.
---
## Rotation obligatoire avant la mise en production
`infra/preprod/docker-stack.preprod.yml` est **versionné dans Git** et contient
en clair :
| Secret | Nature | Action |
|---|---|---|
| Mot de passe PostgreSQL preprod | interne | Ne jamais réutiliser en prod. Changer aussi en preprod. |
| Mot de passe Redis preprod | interne | Idem. |
| `JWT_SECRET` preprod | interne | Idem. |
| Identifiants MinIO preprod | interne | Idem. |
| **Clé SMTP Brevo** | **tiers, active** | **Révoquer immédiatement.** |
| Clés Stripe de test | tiers, test | Faire tourner par précaution. |
| Mot de passe admin Grafana | interne | Changer. |
Ils sont dans l'historique Git : les retirer du fichier ne les efface pas.
Toute personne ayant eu accès au dépôt — actuel ou ancien collaborateur, fork,
sauvegarde, outil d'analyse — les possède.
### La clé Brevo d'abord
C'est le seul identifiant qui donne un pouvoir **hors de votre infrastructure** :
envoyer des e-mails signés `noreply@xpeditis.com`. Un hameçonnage envoyé depuis
votre domaine à vos propres clients est un scénario nettement plus grave qu'un
accès à une base de preprod.
```
1. Brevo → SMTP & API → révoquer la clé publiée
2. Créer une clé PRODUCTION → Secret Kubernetes
3. Créer une clé PREPROD distincte → stack preprod
4. Vérifier les envois : Brevo → Statistiques → Transactionnel
```
### Puis les autres
```bash
# Preprod : nouveaux mots de passe partout, puis migration du stack vers SOPS
# Production : les secrets sont déjà générés à neuf en 06-secrets-sops.md
```
### Empêcher la récidive
```bash
# Détection de secrets dans le dépôt
brew install gitleaks
gitleaks detect --source . --verbose
# Crochet de pré-commit
cat > .git/hooks/pre-commit <<'EOF'
#!/bin/sh
if command -v gitleaks >/dev/null; then
gitleaks protect --staged --redact || {
echo "Un secret a ete detecte dans les fichiers indexes. Commit refuse."
exit 1
}
fi
EOF
chmod +x .git/hooks/pre-commit
```
Migrez également la preprod vers SOPS, sur le modèle de la production.
---
## 1. Modèle de menace
Ce à quoi une plateforme B2B de réservation maritime est réellement exposée :
| Menace | Vraisemblance | Impact | Ce qui la contre |
|---|---|---|---|
| Balayage automatisé / force brute SSH | permanente | faible | Clé uniquement, fail2ban, firewall par IP |
| Réutilisation d'identifiants clients | élevée | fort | Argon2, limitation de débit à 3 niveaux, jetons courts |
| Fuite de secret par le dépôt | **avérée** | **fort** | SOPS, gitleaks, rotation |
| Injection SQL | faible | critique | TypeORM paramétré, `class-validator` |
| Documents accessibles sans autorisation | moyenne | fort | Bucket privé, URLs pré-signées |
| Déni de service | moyenne | moyen | Cloudflare, limitation Traefik |
| Rançongiciel sur db-01 | faible | **critique** | Snapshots côté fournisseur (hors de portée du serveur) |
| Compromission de la chaîne CI | faible | critique | Environnement protégé, clé SSH restreinte, promotion d'images |
| Erreur humaine | **élevée** | fort | PITR, confirmations explicites, `preflight-check.sh` |
L'erreur humaine et la fuite de secret sont les deux plus probables. C'est là
que porte l'essentiel des mesures.
---
## 2. Défenses en place, par couche
### Réseau
- Firewall Hetzner : SSH et 6443 sur vos IP ; 80/443 sur les IP Cloudflare.
- db-01 : **aucun port applicatif public**. Réseau privé uniquement.
- UFW sur les deux nœuds (le firewall Hetzner ne filtre pas le réseau privé).
- `NetworkPolicy` k8s : refus par défaut, sortie Internet sans les plages
privées — un pod compromis ne peut pas balayer `10.10.0.0/16`.
- Firewall CI vide au repos, ouvert deux minutes pour une seule IP.
### Transport
- TLS 1.2 minimum, HSTS 1 an, Full (strict) chez Cloudflare.
- PostgreSQL : `hostssl` uniquement, une connexion en clair est refusée.
- Certificats ECDSA, rotation de clé à chaque renouvellement.
### Système
- SSH par clé, root interdit, 3 tentatives, algorithmes modernes.
- fail2ban, bannissement permanent des récidivistes.
- Mises à jour de sécurité automatiques, redémarrage nocturne si nécessaire.
- auditd sur les fichiers sensibles et les commandes root.
- Compte root verrouillé (la console Hetzner ne donne donc pas de session).
### Kubernetes
- `Secret` chiffrés au repos (`--secrets-encryption`).
- Journal d'audit de l'API, 30 jours, sans jamais journaliser le contenu des
`Secret`.
- Pod Security Admission `restricted` : pas de root, pas de privilèges, pas de
capability.
- Quotas et `LimitRange` : une fuite mémoire n'emporte pas Traefik.
- Tableau de bord Traefik désactivé.
### Application
- Argon2 pour les mots de passe.
- JWT 15 min / rafraîchissement 7 j, cookies `httpOnly`.
- Helmet, CORS en liste blanche stricte (pas de joker, `credentials: true`).
- Limitation de débit à trois niveaux indépendants : Cloudflare, Traefik, NestJS.
- Swagger désactivé en production.
- Journaux expurgés (`authorization`, `x-api-key`, mots de passe).
### Données
- Bucket privé, clés d'accès cloisonnées application / sauvegardes.
- Sauvegardes chiffrées côté client (libsodium et age) : un bucket qui fuite ne
livre rien.
- PITR à 5 minutes.
---
## 3. Revue avant ouverture
```bash
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
cd infra/prod
DB_PUBLIC_IP=<ip_db> bash scripts/preflight-check.sh
```
### Contrôles manuels complémentaires
```bash
# 1. Rien d'exposé sur db-01 (depuis un AUTRE réseau)
nmap -Pn -p- --min-rate 1000 <db_public_ipv4>
# 2. Rien d'inattendu sur app-01
nmap -Pn -p- --min-rate 1000 <app_public_ipv4>
# 3. Qualité TLS
# https://www.ssllabs.com/ssltest/analyze.html?d=app.xpeditis.com → A ou A+
# 4. En-têtes de sécurité
# https://securityheaders.com/?q=https%3A%2F%2Fapp.xpeditis.com → A ou A+
# 5. Comptes de démonstration morts
for c in admin manager user; do
echo -n "$c: "
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api.xpeditis.com/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$c@xpeditis.com\",\"password\":\"Password123!\"}"
done
# Attendu : 401 trois fois
# 6. Un seul administrateur
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres \
psql -d xpeditis_prod -c "SELECT email, role, is_active FROM users WHERE role = '"'"'ADMIN'"'"';"'
# 7. Documents inaccessibles sans autorisation
curl -sI https://fsn1.your-objectstorage.com/xpeditis-prod-documents/ | head -1 # 403
# 8. Aucun secret en clair dans le dépôt
gitleaks detect --source . --verbose
# 9. Une route protégée refuse l'anonyme
curl -s -o /dev/null -w '%{http_code}\n' https://api.xpeditis.com/api/v1/bookings # 401
```
### Analyse des images
```bash
brew install trivy
trivy image --severity HIGH,CRITICAL rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:latest
trivy image --severity HIGH,CRITICAL rg.fr-par.scw.cloud/weworkstudio/xpeditis-frontend:latest
```
Les images `node:20-alpine` remontent régulièrement des CVE de la bibliothèque
système. Traitez les CRITICAL avec vecteur réseau ; les autres peuvent attendre
la prochaine reconstruction. Reconstruisez les images **au moins une fois par
mois** pour absorber les correctifs de base.
---
## 4. Ce que cette configuration ne couvre pas
Soyez lucide sur les limites :
| Non couvert | Conséquence | Quand le traiter |
|---|---|---|
| **Panne d'un nœud** | app-01 en panne = site hors ligne (1 h de reconstruction). | Phase 2 : second nœud + Load Balancer. |
| **PostgreSQL non redondé** | Panne de db-01 = coupure jusqu'à restauration (< 2 h). | Phase 2 : standby en réplication. |
| **Panne du datacenter fsn1** | Coupure jusqu'à reconstruction ailleurs. | Phase 3 : bi-région. |
| **2FA sur les comptes utilisateurs** | Une réutilisation d'identifiants suffit. Le schéma a une colonne `totp_secret` inutilisée. | Dans les 3 mois. |
| **Analyse de vulnérabilité en continu** | Trivy est manuel. | Ajouter à la CI quand possible. |
| **SOC 2** | Bloquant pour les grands comptes. | Quand un client l'exige (Vanta, ~800 €/mois). |
| **Test d'intrusion externe** | Aucun regard extérieur. | Avant les premiers clients importants (3 à 8 k€). |
| **Documents non versionnés** | Une suppression est définitive. | [12 § 7](./12-sauvegardes-restauration.md#7--ce-qui-nest-pas-sauvegarde-et-pourquoi) |
---
## 5. Réponse à incident
### 5.1 Suspicion de compromission
**Ne redémarrez rien.** Un redémarrage détruit les preuves en mémoire et
l'attaquant a probablement déjà un moyen de revenir.
```bash
# 1. ISOLER — bloquer tout le trafic entrant sauf votre IP
cat > /tmp/isolement.json <<JSON
[{"direction":"in","protocol":"tcp","port":"22","source_ips":["<votre_ip>/32"]}]
JSON
hcloud firewall replace-rules xpeditis-prod-fw-app --rules-file /tmp/isolement.json
# Le site devient injoignable : c'est l'effet recherché. Pour ne couper que le
# trafic public sans perdre l'accès, préférez le mode « Under Attack » de
# Cloudflare, qui laisse vos IP passer.
# 2. CONSTATER
ssh deploy@<app_ip> '
last -20 # connexions récentes
sudo journalctl -t xpeditis-ssh-deploy -n 100
sudo ausearch -k rootcmd -ts today | tail -50
sudo grep -i "accepted" /var/log/auth.log | tail -30
'
kubectl -n xpeditis-prod get events --sort-by=.lastTimestamp
ssh deploy@<app_ip> 'sudo grep -E "\"verb\":\"(create|delete|patch)\"" /var/log/k3s/audit.log | tail -50'
# 3. PRÉSERVER
ssh deploy@<app_ip> 'sudo tar czf /tmp/preuves.tgz /var/log/auth.log* /var/log/k3s/audit.log* /var/log/audit/'
scp deploy@<app_ip>:/tmp/preuves.tgz ./incident-$(date +%F).tgz
# 4. ÉVALUER : la base a-t-elle été touchée ?
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && sudo docker compose logs postgres | grep -iE "connection authorized|FATAL" | tail -50'
```
### 5.2 Compromission avérée
```
1. Couper l'accès public (firewall, ou Cloudflare en mode « Under Attack »)
2. Faire tourner TOUS les secrets (06-secrets-sops.md)
3. Révoquer les jetons tiers : Scaleway, Hetzner, Cloudflare, Stripe, Brevo
4. Reconstruire app-01 à partir de zéro — ne jamais « nettoyer » un serveur
5. Restaurer la base à un instant ANTÉRIEUR à la compromission (PITR)
6. Forcer la déconnexion de tous les utilisateurs (rotation du JWT_SECRET)
7. Analyser les journaux : quelles données ont été consultées ?
8. Notifier la CNIL sous 72 h si des données personnelles sont concernées (16)
9. Informer les clients concernés
```
> **Point 4 : reconstruire, pas nettoyer.** Un serveur compromis ne se
> désinfecte pas — on ne peut jamais prouver l'absence de porte dérobée.
> L'architecture est faite pour cela : app-01 est sans état, sa reconstruction
> prend une heure.
### 5.3 Fuite de données personnelles
Le RGPD impose une notification à la CNIL **sous 72 heures** à compter de la
prise de connaissance. Voir [16 § Violation de données](./16-rgpd-conformite.md).
---
## 6. Entretien
| Fréquence | Action |
|---|---|
| **Hebdomadaire** | Revue Grafana (erreurs, latence, disque). Vérifier que le firewall CI est vide. Vérifier le résultat de la vérification de sauvegarde. |
| **Mensuelle** | `trivy` sur les images, reconstruire pour absorber les correctifs de base. Relire les nouveaux comptes ADMIN. `gitleaks detect`. |
| **Trimestrielle** | `refresh-cloudflare-ips.sh`. Test de restauration PITR complet. Revue des accès (Hetzner, Cloudflare, GitHub, Stripe). Mise à jour de k3s. |
| **Annuelle** | Rotation `JWT_SECRET`, mots de passe base et Redis, clé age. Revue du modèle de menace. Test d'intrusion externe si le chiffre d'affaires le permet. |
---
## 7. Contrôle
```
[ ] Clé SMTP Brevo publiée RÉVOQUÉE, nouvelle clé de production en place
[ ] Mots de passe base / Redis / JWT / MinIO de preprod changés
[ ] Mot de passe admin Grafana changé
[ ] gitleaks : aucun secret détecté
[ ] Crochet de pré-commit gitleaks installé
[ ] preflight-check.sh : aucun point bloquant
[ ] nmap depuis l'extérieur : db-01 entièrement filtré
[ ] SSL Labs ≥ A
[ ] securityheaders.com ≥ A
[ ] Comptes de démonstration : 401 sur les trois
[ ] Un seul ADMIN actif, le vôtre
[ ] Bucket documents : 403 en anonyme
[ ] trivy : aucune CRITICAL exploitable à distance
[ ] Procédure de réponse à incident lue et comprise
[ ] Entretien planifié dans l'agenda
```
→ **Suite : [14 — Runbook de mise en ligne](./14-runbook-go-live.md)**

View File

@ -0,0 +1,256 @@
# 14 — Runbook de mise en ligne
Le déroulé du dernier jour et de l'ouverture. **Imprimez-le ou gardez-le ouvert
à côté de vous** — ce n'est pas le moment de chercher une commande dans un
autre fichier.
---
## Choisir le moment
| Bon moment | Mauvais moment |
|---|---|
| **Mardi ou mercredi matin**, 9 h–10 h | Vendredi, quel que soit l'argument |
| Vous êtes disponible les 48 h suivantes | La veille de vos congés |
| Aucune migration lourde en attente | Pendant une maintenance Hetzner annoncée |
Mardi matin laisse trois jours ouvrés pour découvrir et corriger ce qui n'a pas
été anticipé.
---
## J-1 : répétition générale
### 1. Contrôle complet (30 min)
```bash
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
cd infra/prod
DB_PUBLIC_IP=<ip_db> make preflight
```
**Aucun point bloquant ne doit subsister.** Consignez les avertissements dans un
registre des risques daté, avec pour chacun une décision explicite : accepté,
ou corrigé avant l'ouverture.
### 2. Parcours fonctionnels de bout en bout (1 h)
À faire **dans un navigateur, comme un vrai client**, pas en `curl`.
```
[ ] Inscription d'un nouveau compte
[ ] Réception de l'e-mail de validation (vérifier : boîte principale, pas spam)
[ ] Validation, puis connexion
[ ] Recherche de tarifs → au moins un résultat s'affiche
[ ] Création d'une réservation
[ ] Réception de l'e-mail de confirmation
[ ] Génération et téléchargement d'un PDF
[ ] Lien magique transporteur : réception, ouverture, acceptation
[ ] Souscription à un abonnement en mode Live (petit montant, puis remboursement)
[ ] Réception du webhook Stripe (Dashboard Stripe → Webhooks → Tentatives)
[ ] Notification temps réel visible dans l'interface
[ ] Déconnexion, mot de passe oublié, réinitialisation
[ ] Espace d'administration accessible avec votre compte ADMIN
[ ] Import d'une grille tarifaire CSV
[ ] Navigation sur mobile
```
> **Le test Stripe en mode Live est indispensable.** Les clés de test et de
> production ont des comportements différents, et le secret de webhook n'est pas
> le même. Une souscription à 1 €, immédiatement remboursée, vaut mieux que la
> découverte du problème par le premier client.
> **La notification temps réel** peut ne pas arriver : c'est attendu avec deux
> replicas. Voir [15 § Points de vigilance](./15-exploitation-incidents.md#points-de-vigilance-connus).
### 3. Répétition du retour arrière (20 min)
Exercez-vous pendant que l'enjeu est nul.
```bash
# Déployer sciemment une version antérieure
kubectl -n xpeditis-prod set image deploy/xpeditis-backend \
backend=rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:prod-<sha_precedent>
kubectl -n xpeditis-prod rollout status deploy/xpeditis-backend
# Puis revenir
make rollback
bash scripts/smoke-test.sh
```
**Chronométrez.** C'est votre temps de retour réel.
### 4. Sauvegarde manuelle (10 min)
```bash
ssh deploy@<db_ip> 'sudo systemctl start xpeditis-backup.service'
ssh deploy@<db_ip> 'sudo journalctl -u xpeditis-backup -n 30 --no-pager'
```
Notez l'horodatage : c'est votre point de retour du jour J.
### 5. Décision go / no-go
| Critère | Bloquant ? |
|---|---|
| `preflight-check.sh` sans point bloquant | **oui** |
| Test PITR réussi ([12](./12-sauvegardes-restauration.md)) | **oui** |
| Clé SMTP Brevo compromise révoquée | **oui** |
| Comptes de démonstration neutralisés (401) | **oui** |
| Un seul ADMIN actif | **oui** |
| Parcours d'inscription et de réservation fonctionnels | **oui** |
| Paiement Stripe testé en Live | **oui** |
| Alerte de test reçue sur Discord | **oui** |
| Supervision externe active | **oui** |
| Retour arrière répété | **oui** |
| SSL Labs ≥ A | non — corriger sous 7 j |
| Notifications temps réel fiables | non — dégradation connue |
| Sentry connecté | non |
**Un seul « oui » en échec = report.** Reporter d'une semaine coûte infiniment
moins cher que d'ouvrir avec un administrateur au mot de passe public.
---
## J0 : ouverture
### T-30 min
```bash
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
cd infra/prod
make status # tous les pods Running, certificats Ready
make smoke # tout au vert
# Sauvegarde de dernière minute
ssh deploy@<db_ip> 'sudo systemctl start xpeditis-backup.service'
```
Ouvrez et gardez sous les yeux :
- Grafana : `https://grafana.xpeditis.com`
- Le canal Discord `#alertes`
- Le tableau de bord Stripe
- Un terminal avec `make -C infra/prod logs`
### T-0 : rendre public
Si vous aviez restreint l'accès pendant la préparation (règle Cloudflare,
liste d'IP), retirez-la maintenant.
```bash
# Vérification finale, depuis un réseau extérieur (partage de connexion mobile)
curl -sI https://xpeditis.com/ | head -1
curl -sI https://app.xpeditis.com/ | head -1
curl -s https://api.xpeditis.com/api/v1/health | jq
```
Publiez : site vitrine, réseaux sociaux, e-mail aux premiers clients.
### T+15 min
```bash
make smoke
kubectl -n xpeditis-prod get pods
kubectl -n xpeditis-prod top pods 2>/dev/null || true
```
Dans Grafana :
- taux d'erreur 5xx — doit rester à 0 ;
- latence P95 — sous 1 s ;
- mémoire des pods — stable, pas de croissance continue.
### T+1 h, T+4 h, puis fin de journée
Les mêmes contrôles. Ce que vous cherchez :
| Signal | Interprétation |
|---|---|
| Mémoire du backend en croissance continue | Fuite mémoire → surveiller, redémarrer si besoin |
| Erreurs 401 en rafale | Problème de cookie (`COOKIE_DOMAIN`) ou de CORS |
| Requêtes PostgreSQL lentes | Index manquant sur une table qui grossit |
| Connexions PostgreSQL en hausse | Pool non libéré |
| Espace disque en baisse rapide | Journaux ou WAL — vérifier que l'archivage fonctionne |
---
## Si ça tourne mal
### Le site est cassé pour tout le monde
```bash
make -C infra/prod rollback
make -C infra/prod smoke
```
Si le retour arrière ne suffit pas : voir
[15 § Le site est hors ligne](./15-exploitation-incidents.md#le-site-est-hors-ligne).
### Il faut refermer temporairement
Cloudflare → Security → Settings → **Under Attack Mode**.
Les visiteurs passent par une page d'interstitiel ; vos IP restent autorisées.
C'est réversible en un clic, contrairement à une modification de firewall.
### Perte ou corruption de données
Ne rien faire d'autre, et suivre
[12 § Restauration](./12-sauvegardes-restauration.md#5-restauration).
Chaque écriture supplémentaire complique la remise en état.
---
## J+1 à J+7
### Chaque jour
```bash
make -C infra/prod status
```
```
[ ] Aucune alerte non traitée sur Discord
[ ] Sauvegarde de la nuit réussie (battement de cœur vert)
[ ] Taux d'erreur stable
[ ] Aucun pod redémarré sans raison
[ ] Aucun paiement en échec inexpliqué côté Stripe
```
### J+7 : bilan
```
[ ] Vérification hebdomadaire de restauration passée (dimanche 04h00)
[ ] Consommation réelle vs dimensionnement — le CPX41 est-il bien calibré ?
[ ] Coût réel vs prévision (Xpeditis_Previsions_Couts.xlsx)
[ ] Registre des risques relu : les avertissements acceptés sont-ils traités ?
[ ] Retours utilisateurs : quelque chose casse-t-il de façon récurrente ?
[ ] HSTS : passer de 1 mois à 12 mois + preload si tout est stable
```
---
## Contacts et accès en urgence
À remplir et à garder **hors du dépôt** (gestionnaire de mots de passe, ou
papier dans un tiroir) :
```
Hetzner Cloud console.hetzner.cloud compte : ____________
Hetzner Robot robot.hetzner.com (Storage Box)
Cloudflare dash.cloudflare.com compte : ____________
Registrar ____________
Scaleway console.scaleway.com
Stripe dashboard.stripe.com
Brevo app.brevo.com
GitHub github.com/____________
SSH admin ssh -i ~/.ssh/xpeditis_prod deploy@<app_ip>
SSH base ssh -i ~/.ssh/xpeditis_prod deploy@<db_ip>
Kubeconfig ~/.kube/xpeditis-prod.yaml
Clé age ~/.config/sops/age/keys.txt (+ copie hors ligne : ________)
Clé de secours emplacement physique : ____________
```
---
→ **Suite : [15 — Exploitation et incidents](./15-exploitation-incidents.md)**

View File

@ -0,0 +1,405 @@
# 15 — Exploitation et incidents
Document de référence, à consulter au coup par coup.
---
## Points de vigilance connus
Trois limites identifiées dans le code. Aucune n'empêche l'ouverture, mais
toutes doivent être connues avant qu'un client ne les découvre.
### 1. Notifications temps réel — dégradées avec 2 replicas
**Constat.** `notifications.gateway.ts` conserve la correspondance
`userId → sockets` **en mémoire du processus** et n'utilise pas
`@socket.io/redis-adapter`.
**Conséquence.** Avec deux replicas backend, une notification émise par le
replica A n'atteint pas un utilisateur connecté au replica B. En pratique,
environ **une notification temps réel sur deux se perd**.
Les sessions collantes sont configurées (`k8s/base/04-backend.yaml`), ce qui
règle la négociation du handshake Socket.IO — mais **pas** la diffusion entre
instances.
**Atténuation actuelle.** Les notifications restent visibles au rechargement
(elles sont lues via l'API REST) et les e-mails partent normalement. Seul le
« temps réel » est affecté.
**Solution de contournement immédiate**, si le temps réel est critique :
```bash
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=1
kubectl -n xpeditis-prod patch hpa xpeditis-backend --type=merge \
-p '{"spec":{"minReplicas":1,"maxReplicas":1}}'
```
Prix à payer : plus de haute disponibilité, et une brève coupure à chaque
déploiement.
**Correctif de fond** (à faire dans le premier mois) :
```bash
cd apps/backend && npm i @socket.io/redis-adapter ioredis
```
```ts
// src/infrastructure/websocket/redis-io.adapter.ts
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { Redis } from 'ioredis';
import type { ServerOptions } from 'socket.io';
export class RedisIoAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createAdapter>;
async connectToRedis(): Promise<void> {
const opts = {
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT ?? 6379),
password: process.env.REDIS_PASSWORD,
};
const pubClient = new Redis(opts);
const subClient = pubClient.duplicate();
this.adapterConstructor = createAdapter(pubClient, subClient);
}
createIOServer(port: number, options?: ServerOptions): unknown {
const server = super.createIOServer(port, options);
(server as { adapter: (a: unknown) => void }).adapter(this.adapterConstructor);
return server;
}
}
```
```ts
// main.ts, avant app.listen()
const redisIoAdapter = new RedisIoAdapter(app);
await redisIoAdapter.connectToRedis();
app.useWebSocketAdapter(redisIoAdapter);
```
La carte `userSockets` en mémoire reste utile localement ; le diffuseur Redis
prend en charge la propagation entre instances.
### 2. La sonde de disponibilité ne teste rien
`health.controller.ts` renvoie `{status: 'ready', checks: {database: 'ok',
redis: 'ok'}}` **en dur**, sans interroger quoi que ce soit.
**Conséquence.** Un pod incapable de joindre PostgreSQL est déclaré prêt et
Traefik lui envoie du trafic. Les utilisateurs prennent des 500 au lieu d'être
redirigés vers un pod sain.
**Correctif** (`@nestjs/terminus`) :
```bash
cd apps/backend && npm i @nestjs/terminus
```
```ts
@Get('ready')
@HealthCheck()
check() {
return this.health.check([
() => this.db.pingCheck('database', { timeout: 3000 }),
() => this.redis.checkHealth('redis'),
]);
}
```
En attendant, la sonde de vivacité (`/health/live`) et les alertes Prometheus
couvrent l'essentiel : un pod réellement mort est bien détecté.
### 3. Retour arrière et migrations
`rollback` revient à l'image précédente, **pas au schéma précédent**. Voir
[§ Retour arrière avec migration](#retour-arriere-avec-migration).
---
## Le site est hors ligne
### Diagnostic, du plus extérieur au plus intérieur
```bash
# 1. Est-ce Cloudflare ou nous ?
curl -sI https://app.xpeditis.com/ | head -3
# 521/522 → Cloudflare n'atteint pas l'origine
# 523 → problème DNS
# 200 → ce n'est pas l'infrastructure : voir l'application
# 2. Le serveur répond-il ?
ping -c3 <app_public_ipv4>
ssh deploy@<app_public_ipv4> 'uptime; df -h /; free -m'
# 3. k3s tourne-t-il ?
ssh deploy@<app_public_ipv4> 'sudo systemctl status k3s --no-pager | head -20'
# 4. Les pods ?
kubectl -n xpeditis-prod get pods
kubectl -n xpeditis-prod get events --sort-by=.lastTimestamp | tail -20
# 5. La base ?
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && sudo docker compose ps'
```
### Causes fréquentes
| Symptôme | Cause | Correctif |
|---|---|---|
| 521/522, serveur joignable | Les rangs IP Cloudflare ont changé | `bash scripts/refresh-cloudflare-ips.sh --write && terraform apply` |
| Pods `Evicted`, `df` proche de 100 % | Disque plein | Voir § Disque plein |
| Pods `Pending` | Ressources insuffisantes | `kubectl describe pod` ; réduire les replicas ou agrandir le serveur |
| `CrashLoopBackOff` | Erreur applicative | `kubectl logs --previous` |
| `ImagePullBackOff` | `regcred` périmé | Recréer le secret ([05 § 4](./05-cluster-k3s.md)) |
| Erreurs de connexion base | db-01 en panne | `docker compose up -d` sur db-01 |
| k3s ne démarre pas | `--protect-kernel-defaults` | `sysctl --system` puis `systemctl restart k3s` |
### Disque plein
La panne la plus fréquente d'un serveur laissé seul.
```bash
ssh deploy@<ip> '
df -h
sudo du -sh /var/lib/rancher/k3s/agent/containerd 2>/dev/null
sudo du -sh /var/log/* 2>/dev/null | sort -h | tail -10
sudo du -sh /var/lib/xpeditis/* 2>/dev/null
'
# Sur app-01 : purger les images inutilisées
ssh deploy@<app_ip> 'sudo k3s crictl rmi --prune'
# Journaux systemd
ssh deploy@<ip> 'sudo journalctl --vacuum-size=500M'
# Sur db-01 : WAL qui s'accumulent = archivage cassé
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres \
psql -c "SELECT * FROM pg_stat_archiver;"'
# Si failed_count augmente : WAL-G ne peut plus écrire sur Object Storage.
# Vérifier les identifiants dans .env.data et la joignabilité du service.
```
> Des WAL qui s'accumulent finissent par remplir le disque et **arrêter
> PostgreSQL**. Un `failed_count` qui monte dans `pg_stat_archiver` est une
> urgence, pas un avertissement.
---
## Retour arrière
### Cas simple, sans migration
```bash
make -C infra/prod rollback
make -C infra/prod smoke
```
Ou pour une version précise :
```bash
kubectl -n xpeditis-prod rollout history deploy/xpeditis-backend
kubectl -n xpeditis-prod rollout undo deploy/xpeditis-backend --to-revision=3
```
### Retour arrière avec migration
**`rollout undo` ne défait pas les migrations.** Si la version retirée
contenait une migration destructrice, l'ancienne version applicative peut ne
plus fonctionner contre le schéma courant.
Trois situations :
| Migration | Peut-on revenir en arrière simplement ? |
|---|---|
| Ajout de table ou de colonne nullable | **Oui.** L'ancien code ignore ce qu'il ne connaît pas. |
| Ajout de colonne NOT NULL sans défaut | **Non.** L'ancien code fera échouer les insertions. |
| Suppression ou renommage de colonne | **Non.** L'ancien code interrogera une colonne absente. |
| Changement de type | **Non.** |
Pour les cas non réversibles :
```bash
# 1. Arrêter les écritures
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0
# 2. Annuler la dernière migration
kubectl -n xpeditis-prod run migration-revert --rm -it --restart=Never \
--image=rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:prod-<sha_actuel> \
--overrides='{"spec":{"imagePullSecrets":[{"name":"regcred"}],"containers":[{
"name":"migration-revert",
"image":"rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:prod-<sha_actuel>",
"command":["node","./node_modules/typeorm/cli.js","migration:revert","-d",
"dist/infrastructure/persistence/typeorm/data-source.js"],
"envFrom":[{"configMapRef":{"name":"xpeditis-backend-config"}},
{"secretRef":{"name":"xpeditis-backend-secrets"}}]}]}}'
# 3. Revenir à l'image précédente
kubectl -n xpeditis-prod set image deploy/xpeditis-backend backend=...:prod-<sha_precedent>
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2
```
Si `down()` n'est pas implémentée ou échoue, il ne reste que la restauration
PITR ([12](./12-sauvegardes-restauration.md)).
> **Écrivez des migrations réversibles.** Pour supprimer une colonne, procédez
> en deux temps : d'abord une version qui cesse de l'utiliser, puis, une fois
> celle-ci stabilisée en production, une seconde qui supprime la colonne. Le
> retour arrière reste possible à chaque étape.
---
## Montée en charge
### Signaux
| Signal | Seuil | Action |
|---|---|---|
| CPU backend soutenu | > 70 % | Le HPA monte à 4. Au-delà, agrandir le serveur. |
| Mémoire nœud | > 85 % | Passer en CPX51 (16 vCPU / 32 Go). |
| Connexions PostgreSQL | > 80 % | Augmenter `max_connections` ou ajouter PgBouncer. |
| Latence P95 | > 1 s | Chercher la cause avant d'ajouter des ressources. |
| Disque db-01 | > 70 % | Agrandir le volume (à chaud). |
### Agrandir un serveur
```bash
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=1 # app-01 seulement
ssh deploy@<ip> 'sudo shutdown -h now'
# Console Hetzner → Rescale → CPX51 → Power on
# Ou : $EDITOR terraform.tfvars puis terraform apply
```
Quelques minutes de coupure. Le disque ne peut qu'être **agrandi**, jamais
réduit : la manœuvre est irréversible.
### Agrandir le volume PostgreSQL
Sans coupure :
```bash
cd infra/prod/terraform
$EDITOR terraform.tfvars # db_volume_size = 100
terraform apply
ssh deploy@<db_ip> '
sudo resize2fs /dev/disk/by-id/scsi-0HC_Volume_*
df -h /var/lib/xpeditis/pgdata
'
```
### Passer à deux nœuds (phase 2)
Quand un seul nœud ne suffit plus, ou que la coupure lors d'une panne devient
inacceptable :
1. Ajouter un `hcloud_server.app2` dans Terraform (mêmes firewalls, même réseau).
2. L'installer en agent k3s :
```bash
curl -sfL https://get.k3s.io | K3S_URL=https://10.10.1.10:6443 \
K3S_TOKEN=$(ssh deploy@<app1> 'sudo cat /var/lib/rancher/k3s/server/node-token') sh -
```
3. Ajouter un Load Balancer Hetzner LB11 (6 €/mois) devant les deux nœuds.
4. Les `topologySpreadConstraints` déjà présents répartiront automatiquement les
pods — ils sont en `ScheduleAnyway`, ils deviennent effectifs sans
modification.
5. Ajouter un standby PostgreSQL en réplication sur un troisième serveur.
Budget correspondant : ligne « 1 000 utilisateurs » du fichier de prévisions.
---
## Mises à jour
### Application
Chaîne normale : `preprod` → validation → `main` → déploiement automatique.
### Système (automatique)
`unattended-upgrades` applique les correctifs de sécurité chaque nuit et
redémarre à 04h30 si le noyau l'exige.
```bash
ssh deploy@<ip> 'sudo journalctl -u unattended-upgrades -n 30 --no-pager'
```
> Le redémarrage automatique d'app-01 provoque **quelques minutes de coupure** :
> k3s redémarre, puis les pods. C'est un choix assumé — un noyau non corrigé est
> un risque plus grand que quelques minutes d'indisponibilité nocturne. Avec
> deux nœuds (phase 2), décalez les fenêtres pour supprimer la coupure.
### k3s (trimestriel)
```bash
# Sauvegarde de l'état du cluster
ssh deploy@<app_ip> 'sudo k3s etcd-snapshot save --name avant-maj'
# Mise à jour
ssh deploy@<app_ip> 'curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.32.x+k3s1 sh -'
kubectl get nodes
make -C infra/prod smoke
```
Restez sur une version mineure de retard par rapport à la dernière : les
correctifs de régression arrivent vite.
### PostgreSQL
Les correctifs (15.x → 15.y) sont sans risque :
```bash
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && \
sudo docker compose --env-file .env.data pull postgres && \
sudo docker compose --env-file .env.data up -d postgres'
```
Une montée de version **majeure** (15 → 16) impose un `pg_dump` / `pg_restore`
et une fenêtre de maintenance planifiée. Ne l'improvisez pas.
---
## Commandes de tous les jours
```bash
cd infra/prod
make status # vue d'ensemble
make logs # journaux backend en direct
make events # derniers événements Kubernetes
make smoke # l'extérieur voit-il un service sain ?
make restart # redémarrer les pods (après changement de secret)
make rollback # revenir à la version précédente
# Base de données
ssh deploy@<db_ip>
cd /opt/xpeditis/data-node
sudo docker compose exec -u postgres postgres psql -d xpeditis_prod
# Requêtes en cours
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c "
SELECT pid, now()-query_start AS duree, state, left(query, 80)
FROM pg_stat_activity WHERE state <> 'idle' ORDER BY duree DESC;"
# Tuer une requête bloquante
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c \
"SELECT pg_cancel_backend(<pid>);" # doux
# puis, si nécessaire : pg_terminate_backend(<pid>)
```
---
## Journal des incidents
Tenez-le. Après trois mois, il vous dira où porter vos efforts bien mieux que
n'importe quelle intuition.
```markdown
## AAAA-MM-JJ — Titre court
**Détecté** : par qui / quoi, à quelle heure
**Durée** : de HH:MM à HH:MM
**Impact** : combien d'utilisateurs, quelles fonctions
**Cause** : ce qui s'est réellement passé
**Correctif** : ce qui a été fait
**Prévention** : ce qui empêche la récidive — et si rien, pourquoi
```

View File

@ -0,0 +1,241 @@
# 16 — RGPD et conformité
Xpeditis traite des données personnelles de professionnels : noms, adresses
e-mail, numéros de téléphone, adresses, SIRET, documents de transport nominatifs.
Le RGPD s'applique intégralement.
> Ce document décrit ce que l'infrastructure met en place et ce qu'il reste à
> faire. Il ne remplace pas un conseil juridique. Faites relire vos mentions
> légales, votre politique de confidentialité et vos contrats de sous-traitance
> par un professionnel avant de démarcher des grands comptes.
---
## 1. Ce que l'infrastructure apporte déjà
| Exigence | Mise en œuvre |
|---|---|
| Hébergement dans l'UE | Hetzner, Falkenstein (Allemagne). Terraform refuse toute région hors UE. |
| Chiffrement en transit | TLS 1.2+ partout, y compris entre l'application et PostgreSQL. |
| Chiffrement au repos | Sauvegardes chiffrées côté client (libsodium, age). `Secret` k8s chiffrés dans l'état de k3s. |
| Contrôle d'accès | RBAC applicatif, Argon2, JWT courts, cookies `httpOnly`. |
| Traçabilité | `audit_logs` applicatif, journal d'audit Kubernetes, auditd système. |
| Minimisation dans les journaux | pino expurge `authorization`, `x-api-key` et les mots de passe. Traefik ne conserve que `User-Agent` et `Cf-Connecting-Ip`. |
| Durée de conservation des journaux | Loki 31 jours, Prometheus 15 jours, audit k8s 30 jours. |
| Sécurité des sauvegardes | Chiffrées, cloisonnées, testées. |
| Résilience | PITR 5 min, RTO < 2 h — l'article 32 exige de pouvoir « rétablir la disponibilité ». |
> **Le chiffrement du disque n'est pas activé.** Les volumes Hetzner ne sont pas
> chiffrés au repos par défaut. En pratique, la protection repose sur la sécurité
> physique du datacenter (certifié ISO 27001) et sur la destruction sécurisée des
> supports. Si un client l'exige contractuellement, il faut mettre en place LUKS
> sur le volume PostgreSQL — au prix d'une clé à saisir ou à stocker à chaque
> démarrage, ce qui déplace le problème plus qu'il ne le résout sur un serveur
> distant. À arbitrer, pas à improviser.
---
## 2. Registre des sous-traitants
À tenir à jour. C'est la première chose que demande un client grand compte, et
c'est obligatoire au titre de l'article 30.
| Sous-traitant | Rôle | Données | Localisation | Transfert hors UE | DPA |
|---|---|---|---|---|---|
| **Hetzner Online GmbH** | Hébergement, stockage, sauvegardes | Toutes | Allemagne | non | [DPA Hetzner](https://www.hetzner.com/AV/DPA_en.pdf) à signer |
| **Cloudflare Inc.** | DNS, WAF, CDN | IP, en-têtes, métadonnées de requête | Réseau mondial | **oui** (États-Unis) | DPA + CCT, incluses aux CGU |
| **Stripe Inc.** | Paiements | Nom, e-mail, données de facturation | UE + États-Unis | **oui** | DPA Stripe |
| **Brevo (Sendinblue SAS)** | E-mails transactionnels | Nom, e-mail, contenu des messages | France | non | DPA Brevo |
| **Functional Software Inc. (Sentry)** | Suivi des erreurs | Traces, potentiellement des identifiants utilisateur | États-Unis | **oui** | DPA + purge des données |
| **Pappers (SAS)** | Vérification SIRET | SIRET, raison sociale | France | non | DPA |
| **Scaleway SAS** | Registre d'images | Aucune donnée personnelle | France | non | — |
| **GitHub Inc.** | Code source, CI | Aucune donnée client | États-Unis | oui | — |
### Actions
```
[ ] Signer le DPA Hetzner (formulaire dans la console)
[ ] Récupérer et archiver les DPA Cloudflare, Stripe, Brevo, Sentry, Pappers
[ ] Activer la purge des données personnelles dans Sentry
Settings → Security & Privacy → Data Scrubbing → activer
+ champs supplémentaires : email, phone, siret, token
[ ] Documenter ce registre dans un document versionné
```
> **Sentry mérite une attention particulière.** Une trace d'exception peut
> contenir l'e-mail, l'identifiant et le corps de requête d'un utilisateur, et
> partir chez un sous-traitant américain. Activez la purge **avant** de connecter
> Sentry à la production, pas après.
---
## 3. Durées de conservation
À décider, puis à appliquer techniquement. Proposition de départ :
| Donnée | Durée proposée | Justification |
|---|---|---|
| Compte utilisateur actif | Durée de la relation contractuelle | Exécution du contrat |
| Compte inactif | 3 ans après la dernière connexion | Recommandation CNIL en prospection B2B |
| Réservations et documents de transport | **10 ans** | Obligation comptable et commerciale (art. L123-22 code de commerce) |
| Factures | 10 ans | Idem |
| `audit_logs` | 1 an | Sécurité, art. 32 |
| Journaux techniques (Loki) | 31 jours | Déjà appliqué |
| Sauvegardes | 7 j PITR, 30 j dumps | Déjà appliqué |
| Cookies analytiques | 13 mois | Recommandation CNIL |
> **La conservation légale prime sur le droit à l'effacement.** Un client peut
> demander la suppression de son compte : les réservations qui portent une
> obligation comptable doivent être conservées, mais peuvent être
> **pseudonymisées** (retirer nom, e-mail, téléphone en gardant les montants et
> références). C'est ce que doit faire la fonction d'effacement du module GDPR.
### À vérifier dans le code
Le backend expose un module `gdpr`. Vérifiez qu'il fait bien ce qu'il annonce :
```bash
# Que fait réellement l'export ? Que fait la suppression ?
grep -rn "class.*Gdpr\|anonymi\|pseudonym" apps/backend/src/application/gdpr/
```
```
[ ] L'export de données couvre : compte, organisation, réservations, documents
[ ] La suppression pseudonymise au lieu d'effacer les données à conservation légale
[ ] Une purge automatique des comptes inactifs > 3 ans existe (ou est planifiée)
```
---
## 4. Documents obligatoires
| Document | Où | Statut |
|---|---|---|
| Politique de confidentialité | `/privacy` (route publique existante) | à rédiger |
| Mentions légales | `/terms` | à rédiger |
| Politique de cookies + bandeau de consentement | `/cookies` | à rédiger |
| CGU / CGV | `/terms` | à rédiger |
| Registre des traitements (art. 30) | interne | à créer |
| Registre des sous-traitants | interne | §2 ci-dessus |
| Procédure de violation de données | interne | §6 ci-dessous |
Les routes `/privacy`, `/terms` et `/cookies` sont déjà publiques dans
`middleware.ts` : il ne manque que le contenu.
### Bandeau de cookies
Nécessaire **uniquement** si vous déposez des cookies non essentiels (mesure
d'audience, publicité). Les cookies d'authentification `httpOnly` sont
strictement nécessaires et n'exigent pas de consentement.
Si vous ajoutez Google Analytics (`NEXT_PUBLIC_GA_ID` est prévu dans le
Dockerfile frontend), un bandeau de consentement **préalable** devient
obligatoire — le script ne doit pas se charger avant l'acceptation.
**Alternative plus simple** : Plausible ou Matomo auto-hébergé, sans cookie ni
consentement requis. Moins de code, moins de risque juridique.
---
## 5. Droits des personnes
Vous devez répondre sous **un mois**.
| Droit | Mise en œuvre |
|---|---|
| Accès / portabilité | Module GDPR — export JSON ou CSV |
| Rectification | Interface de profil |
| Effacement | Module GDPR — pseudonymisation, cf. §3 |
| Limitation | Désactivation du compte (`is_active = false`) |
| Opposition | Désinscription des communications |
Publiez une adresse de contact dédiée (`privacy@xpeditis.com` ou
`dpo@xpeditis.com`) dans la politique de confidentialité, et **relevez-la**.
### DPO
Non obligatoire pour une PME dont le traitement de données personnelles n'est
ni massif ni sensible. Désignez néanmoins un **référent RGPD** nommément —
c'est ce que demandent les clients grands comptes.
---
## 6. Violation de données
**Notification à la CNIL sous 72 heures** à compter de la prise de connaissance
(art. 33). Si le risque pour les personnes est élevé, il faut **aussi** les
informer directement (art. 34).
### Procédure
```
1. CONSTATER Suivre 13 § Réponse à incident. Préserver les preuves.
2. QUALIFIER Quelles données ? Combien de personnes ? Quel risque réel ?
3. CONTENIR Isoler, faire tourner les secrets, restaurer.
4. NOTIFIER CNIL sous 72 h : notifications.cnil.fr
Même incomplète, une notification dans les délais vaut mieux
qu'une notification complète hors délai.
5. INFORMER Les personnes concernées si le risque est élevé.
6. DOCUMENTER Journal des violations : obligatoire, même sans notification.
```
### Ce dont vous aurez besoin
- Quelles données, pour combien de personnes ?
- Depuis quand, jusqu'à quand ?
- Comment cela a-t-il été découvert ?
- Quelles mesures ont été prises ?
- Quelles conséquences probables pour les personnes ?
Les sources : `audit_logs`, Loki (31 j), journal d'audit k8s (30 j), auditd,
journaux PostgreSQL. **Ces rétentions déterminent votre capacité à répondre.**
Une intrusion découverte 45 jours après coup ne sera pas reconstituable — c'est
un argument pour porter la rétention Loki à 90 jours dès que le volume le permet.
---
## 7. Ce qu'exigeront les grands comptes
Au-delà du RGPD, attendez-vous à :
| Demande | Statut | Coût |
|---|---|---|
| Questionnaire sécurité (50-200 questions) | ce dossier y répond en grande partie | temps |
| DPA signé de votre côté | à préparer | juridique |
| Preuve de sauvegardes testées | [12](./12-sauvegardes-restauration.md) | fait |
| Engagement de disponibilité (SLA) | pas d'engagement contractuel aujourd'hui | à arbitrer |
| Test d'intrusion récent | non fait | 3 à 8 k€ |
| SOC 2 Type II | non | ~800 €/mois (Vanta) + 15-25 k€ d'audit |
| ISO 27001 | non | 20-40 k€ |
| Assurance responsabilité civile professionnelle / cyber | **prévue au budget** (150 €/mois) | à souscrire |
**Souscrivez la RC Pro / cyber avant l'ouverture.** Elle figure dans le fichier
de prévisions et couvre précisément ce que la technique ne peut pas couvrir :
les conséquences financières d'un incident.
---
## 8. Contrôle
```
[ ] Hébergement UE confirmé (fsn1)
[ ] DPA Hetzner signé
[ ] DPA Cloudflare, Stripe, Brevo, Sentry, Pappers archivés
[ ] Purge des données personnelles activée dans Sentry
[ ] Registre des sous-traitants rédigé
[ ] Registre des traitements (art. 30) rédigé
[ ] Durées de conservation décidées et documentées
[ ] Module GDPR vérifié : export complet, suppression pseudonymisante
[ ] Politique de confidentialité publiée sur /privacy
[ ] Mentions légales et CGU publiées sur /terms
[ ] Politique de cookies publiée sur /cookies
[ ] Adresse privacy@ ou dpo@ publiée et relevée
[ ] Référent RGPD désigné nommément
[ ] Procédure de violation de données rédigée et accessible hors ligne
[ ] Assurance RC Pro / cyber souscrite
```
---
**Fin de la documentation de mise en production.**
Retour à l'[index](./README.md).

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

@ -0,0 +1,174 @@
# Mise en production — Xpeditis sur Hetzner
Procédure complète, pas à pas, pour ouvrir Xpeditis au public de manière sûre.
**Option retenue** : Hetzner auto-hébergé (feuille « Hetzner (auto-hébergé) » du
fichier `Xpeditis_Previsions_Couts.xlsx`), k3s sur 2 serveurs, ≈ 66 € HT/mois
d'infrastructure.
Tous les fichiers évoqués ici vivent dans [`infra/prod/`](../../infra/prod/README.md).
---
## Lisez ceci en premier
L'analyse du dépôt a mis au jour **quatre problèmes qui auraient compromis ou
cassé la production**. Ils sont traités dans les fichiers livrés, mais deux
exigent une action de votre part.
### 0. Une migration créait un ADMIN au mot de passe public — corrigé
`1730000000007-SeedTestUsers` insérait `admin@xpeditis.com` (rôle **ADMIN**),
`manager@` et `user@`, tous avec le mot de passe `Password123!` — écrit en clair
dans le dépôt. Sur une base de production neuve, appliquer les migrations créait
donc un administrateur dont les identifiants sont publics. C'était le point le
plus grave du parcours.
Trois mécanismes, tous automatiques :
| Migration | Rôle |
|---|---|
| `1730000000007-SeedTestUsers` (modifiée) | **Ne s'exécute plus** si `NODE_ENV=production`. Les comptes ne sont jamais créés. Dev et preprod gardent les leurs. |
| `1756000000000-NeutralizeSeedAccountsInProduction` | Filet de sécurité pour toute base où ils existeraient déjà : renommage, hash inauthentifiable, désactivation. Échoue si le nettoyage est incomplet. |
| `1756000000001-BootstrapAdminFromEnv` | Crée **votre** administrateur depuis `BOOTSTRAP_ADMIN_EMAIL`, **sans aucun mot de passe stocké** : vous définissez le vôtre via « mot de passe oublié ». |
Résultat : aucun secret n'existe nulle part — ni dans Git, ni dans le Secret
Kubernetes, ni dans l'historique du shell. `preflight-check.sh` vérifie ensuite
en conditions réelles que `Password123!` est bien refusé sur les trois adresses.
→ **[09 — Déploiement applicatif](./09-deploiement-application.md#le-premier-administrateur)**
### 1. Les secrets de preprod sont dans l'historique Git — action requise
`infra/preprod/docker-stack.preprod.yml` est versionné et contient **en clair** :
mot de passe PostgreSQL, mot de passe Redis, `JWT_SECRET`, identifiants MinIO,
**clé SMTP Brevo**, clés Stripe de test, mot de passe administrateur Grafana.
Ils sont dans l'historique Git : les réécrire ne suffirait pas, il faut les
considérer comme **compromis et les révoquer**. La clé Brevo en particulier est
un identifiant tiers actif : quiconque a lu le dépôt peut envoyer des e-mails
au nom de `noreply@xpeditis.com`.
→ **[13-securite-durcissement.md § Rotation obligatoire](./13-securite-durcissement.md#rotation-obligatoire-avant-la-mise-en-production)**
### 2. L'image frontend ne peut pas être promue depuis la preprod — corrigé
`next.config.js` fige `NEXT_PUBLIC_API_URL` **au moment du build**. L'ancien
`cd-main.yml` re-taguait l'image de preprod vers la production : l'application
en production aurait appelé `api.preprod.xpeditis.com`. Le workflow réécrit
reconstruit le frontend avec les URLs de production et **vérifie que l'URL de
preprod n'est pas présente dans le bundle** avant de déployer.
### 3. Les migrations partaient en concurrence — corrigé
L'image backend lance les migrations à chaque démarrage de pod
(`scripts/setup/startup.js`). Avec deux replicas, deux processus migrent en même
temps ; TypeORM ne sérialise pas entre processus, l'un des deux part en
`CrashLoopBackOff`. Un **Job Kubernetes** (parallélisme 1) applique désormais les
migrations *avant* la mise à jour des images ; `startup.js` ne fait plus que
constater qu'il n'y a rien à migrer. **Aucune modification du code applicatif.**
Un quatrième point ne bloque pas le lancement mais dégrade une fonctionnalité :
les notifications temps réel. Voir
[15-exploitation-incidents.md § Points de vigilance](./15-exploitation-incidents.md#points-de-vigilance-connus).
---
## Chronologie
| Quand | Étape | Durée | Document |
|---|---|---|---|
| **J-14** | Comptes, domaine, outils, décisions | 2-3 h | [01](./01-prerequis.md) |
| **J-10** | Serveurs, réseau, firewalls (Terraform) | 1 h | [02](./02-provisioning-hetzner.md) |
| **J-10** | Durcissement système des deux serveurs | 1 h | [03](./03-durcissement-serveurs.md) |
| **J-9** | PostgreSQL + Redis + sauvegardes | 2 h | [04](./04-noeud-donnees.md) |
| **J-8** | Cluster k3s | 1 h 30 | [05](./05-cluster-k3s.md) |
| **J-8** | Secrets SOPS | 1 h | [06](./06-secrets-sops.md) |
| **J-7** | Stockage objet | 45 min | [07](./07-stockage-objet-s3.md) |
| **J-7** | DNS, Cloudflare, TLS | 1 h 30 | [08](./08-dns-tls-cloudflare.md) |
| **J-6** | Premier déploiement applicatif | 2 h | [09](./09-deploiement-application.md) |
| **J-5** | CI/CD automatisée | 1 h 30 | [10](./10-cicd-github-actions.md) |
| **J-4** | Supervision et alertes | 2 h | [11](./11-observabilite.md) |
| **J-3** | **Test de restauration réel** | 2 h | [12](./12-sauvegardes-restauration.md) |
| **J-2** | Revue de sécurité, rotation des secrets | 3 h | [13](./13-securite-durcissement.md) |
| **J-1** | Répétition générale, go / no-go | 2 h | [14](./14-runbook-go-live.md) |
| **J0** | Ouverture | — | [14](./14-runbook-go-live.md) |
| **après** | Exploitation, incidents, montée en charge | — | [15](./15-exploitation-incidents.md) |
| **après** | RGPD et conformité | — | [16](./16-rgpd-conformite.md) |
**Temps de travail cumulé : environ 25 heures.** Étalez-les : plusieurs étapes
comportent des délais incompressibles (propagation DNS, émission de certificat,
première sauvegarde complète).
---
## Les documents
| # | Fichier | Contenu |
|---|---|---|
| 01 | [Prérequis](./01-prerequis.md) | Comptes, outils, domaine, clés SSH, décisions à trancher |
| 02 | [Provisioning Hetzner](./02-provisioning-hetzner.md) | Terraform, serveurs, réseau privé, firewalls, volume |
| 03 | [Durcissement des serveurs](./03-durcissement-serveurs.md) | SSH, UFW, fail2ban, auditd, mises à jour automatiques |
| 04 | [Nœud de données](./04-noeud-donnees.md) | PostgreSQL 15 + TLS, Redis 7, WAL-G, timers de sauvegarde |
| 05 | [Cluster k3s](./05-cluster-k3s.md) | Installation durcie, Traefik, cert-manager, kubeconfig |
| 06 | [Secrets SOPS](./06-secrets-sops.md) | Clé age, chiffrement, application, rotation, sauvegarde de la clé |
| 07 | [Stockage objet](./07-stockage-objet-s3.md) | Hetzner Object Storage, buckets, clés cloisonnées |
| 08 | [DNS, TLS, Cloudflare](./08-dns-tls-cloudflare.md) | Enregistrements, proxy, WAF, certificat wildcard |
| 09 | [Déploiement applicatif](./09-deploiement-application.md) | Manifests, migrations, premier administrateur, données de référence |
| 10 | [CI/CD](./10-cicd-github-actions.md) | Secrets GitHub, environnement protégé, déploiement automatique |
| 11 | [Observabilité](./11-observabilite.md) | Loki, Prometheus, Grafana, alertes Discord, supervision externe |
| 12 | [Sauvegardes et restauration](./12-sauvegardes-restauration.md) | Stratégie 3-2-1, PITR, tests de restauration, RTO/RPO |
| 13 | [Sécurité](./13-securite-durcissement.md) | Rotation des secrets compromis, checklist, revue externe |
| 14 | [Runbook de mise en ligne](./14-runbook-go-live.md) | J-1, J0, go/no-go, plan de repli |
| 15 | [Exploitation et incidents](./15-exploitation-incidents.md) | Runbooks, points de vigilance, montée en charge, mises à jour |
| 16 | [RGPD et conformité](./16-rgpd-conformite.md) | Hébergement, sous-traitants, conservation, droits des personnes |
---
## Architecture livrée
```
Internet → Cloudflare (WAF, anti-DDoS, cache)
│ le firewall Hetzner n'accepte 80/443 que depuis Cloudflare
▼
app-01 · CPX41 · fsn1 · k3s mono-nœud
Traefik ──► api.xpeditis.com → backend NestJS ×2 (HPA 2→4)
├► app / www / apex → frontend Next.js ×2
└► grafana.xpeditis.com (filtré par IP)
observabilité : Loki · Promtail · Prometheus · Alertmanager · Grafana
│
│ réseau privé 10.10.1.0/24, PostgreSQL en TLS obligatoire
▼
db-01 · CPX31 · fsn1 · Docker Compose
PostgreSQL 15 + WAL-G · Redis 7 · postgres-exporter
volume dédié 50 Go
│
├─► Hetzner Object Storage (documents applicatifs + WAL-G)
└─► Hetzner Storage Box (dumps logiques chiffrés age)
```
---
## Principes appliqués partout
1. **Tout secret est chiffré dans Git** (SOPS + age) ou n'y est pas du tout.
2. **La base n'est jamais joignable depuis Internet.** Réseau privé, `hostssl`
uniquement, UFW, aucun enregistrement DNS.
3. **Personne ne contourne Cloudflare.**
4. **SSH et l'API Kubernetes ne sont ouverts qu'à vos IP.** La CI ouvre une
fenêtre de deux minutes pour une seule IP, et la referme quoi qu'il arrive.
5. **Une sauvegarde non restaurée n'est pas une sauvegarde.**
6. **Ce qui n'est pas surveillé n'existe pas.** Chaque défaillance a une alerte,
et le silence des sauvegardes est lui-même surveillé — de l'extérieur.
---
## En cas de problème
| Situation | Aller directement à |
|---|---|
| Le site ne répond plus | [15 § Le site est hors ligne](./15-exploitation-incidents.md#le-site-est-hors-ligne) |
| Un déploiement a mal tourné | [15 § Retour arrière](./15-exploitation-incidents.md#retour-arriere) |
| Perte ou corruption de données | [12 § Restauration](./12-sauvegardes-restauration.md#restauration) |
| Suspicion de compromission | [13 § Réponse à incident](./13-securite-durcissement.md#reponse-a-incident) |
| Certificat expiré | [08 § Dépannage TLS](./08-dns-tls-cloudflare.md#depannage) |

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
}