Files
la-voix-du-peuple/DEPLOIEMENT.md
billisdead defc07648c Security: close the 9 items from the June review + one found along the way
Findings from the 2026-06-24 security review, now mandatory before this
platform leaves the LAN for a public VPS (see budget/externalization note):

- Timing attack on admin password: hmac.compare_digest instead of !=
- CORS wildcard removed, restricted to ALLOWED_ORIGINS (needed for credentials)
- hCaptcha now fails closed on network error instead of open
- HMAC signatures use the full SHA-256 digest instead of a 64-bit truncation
- Consultation-scoped submissions were missing the consent/cooldown checks
  the global submission route already had
- Admin search capped at 200 chars (ILIKE cost)
- Webhook URLs validated against SSRF (private/loopback/link-local/metadata
  IPs rejected, re-checked at trigger time too, redirects not followed)
- Flood tracker backed by Redis when REDIS_URL is set (shared across Gunicorn
  workers, survives restarts), falls back to in-memory otherwise
- Admin session moved from sessionStorage/Bearer token to an httpOnly+Secure+
  SameSite=Strict cookie; new /api/admin/logout and /api/admin/session routes

Also found while wiring ProxyFix for the cookie's Secure flag: the app never
told Flask about the Nginx reverse proxy, so request.remote_addr saw Nginx's
own IP for every request in production — silently breaking rate limiting,
flood detection, and IP blacklist (everyone would collapse onto one shared
key). Added ProxyFix (1 hop).

CSV exports now stream instead of loading everything into memory. AI prompts
delimit citizen-submitted content explicitly and instruct the model to treat
it as inert data, never instructions, to reduce prompt injection surface.

Docs (DEX.md, SECURITE_ANTI_ABUS.md, README.md, DEPLOIEMENT.md) and
dev-local.sh updated to match.
2026-09-10 09:27:50 +02:00

13 KiB

Guide d'auto-hébergement — La Voix du Peuple

Rocky Linux 9 (ou RHEL 9, AlmaLinux 9)


Architecture

Internet
   │
   ▼
[Nginx]  ─── /api/* ──► [Gunicorn + Flask]  ──► [PostgreSQL]
   │                           │
   └─── /* ──► [React SPA]     └──► [Mistral AI API]
               (fichiers statiques)
  • Frontend : React + Vite, servi comme fichiers statiques par Nginx
  • Backend : Flask + Gunicorn (4 workers), accessible uniquement via Nginx
  • Base de données : PostgreSQL 15+
  • IA : Mistral AI (MISTRAL_API_KEY requis) ou tout fournisseur compatible OpenAI

Note production : En déploiement exposé à Internet, placer HAProxy devant Nginx pour la terminaison TLS. Voir ARCHITECTURE.md pour le schéma complet. Dans cette configuration, Nginx écoute sur le port 8080 (fichier deploy/nginx.conf) et HAProxy forward sur ce port. Pour un déploiement standalone sans HAProxy, changez listen 8080; en listen 80; dans deploy/nginx.conf et activez HTTPS avec certbot (section 12).


Prérequis

sudo dnf update -y
sudo dnf install -y git curl wget epel-release

1. Installer les dépendances système

Python 3.11

sudo dnf install -y python3.11 python3.11-pip python3.11-devel
python3.11 --version   # doit afficher Python 3.11.x

Node.js 20 (LTS)

curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -
sudo dnf install -y nodejs
node --version   # doit afficher v20.x.x

pnpm

npm install -g pnpm
pnpm --version

PostgreSQL 15

sudo dnf install -y postgresql15-server postgresql15
sudo /usr/pgsql-15/bin/postgresql-15-setup initdb
sudo systemctl enable --now postgresql-15

Nginx

sudo dnf install -y nginx
sudo systemctl enable nginx

2. Configurer PostgreSQL

sudo -u postgres psql

Dans psql :

CREATE USER voixdupeuple WITH PASSWORD 'CHANGEME_MOT_DE_PASSE_FORT';
CREATE DATABASE voixdupeuple OWNER voixdupeuple;
GRANT ALL PRIVILEGES ON DATABASE voixdupeuple TO voixdupeuple;
\q

Éditez /var/lib/pgsql/15/data/pg_hba.conf — remplacez ident par scram-sha-256 sur la ligne 127.0.0.1/32 :

host    all             all             127.0.0.1/32            scram-sha-256
sudo systemctl restart postgresql-15

# Test de connexion
psql -U voixdupeuple -h 127.0.0.1 -d voixdupeuple -c "\dt"

3. Créer l'utilisateur système

sudo useradd -r -s /bin/bash -d /opt/voix-du-peuple voixdupeuple
sudo mkdir -p /opt/voix-du-peuple
sudo chown voixdupeuple:voixdupeuple /opt/voix-du-peuple

4. Cloner le dépôt

sudo -u voixdupeuple git clone \
  https://votre-depot.example.com/voix-du-peuple.git \
  /opt/voix-du-peuple

5. Configurer les variables d'environnement

sudo -u voixdupeuple cp /opt/voix-du-peuple/.env.example /opt/voix-du-peuple/.env
sudo nano /opt/voix-du-peuple/.env

Valeurs obligatoires à renseigner :

# Base de données
DATABASE_URL=postgresql://voixdupeuple:CHANGEME_MOT_DE_PASSE_FORT@127.0.0.1:5432/voixdupeuple

# IA — Mistral AI (recommandé)
MISTRAL_API_KEY=votre-cle-mistral

# Sécurité — générez avec : python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=VALEUR_ALEATOIRE_LONGUE

# Panel d'administration
ADMIN_SECRET=MOT_DE_PASSE_ADMIN_FORT

Sécurisez le fichier :

sudo chmod 600 /opt/voix-du-peuple/.env
sudo chown voixdupeuple:voixdupeuple /opt/voix-du-peuple/.env

6. Installer les dépendances Python

sudo -u voixdupeuple bash -c "
  cd /opt/voix-du-peuple
  python3.11 -m venv .venv
  .venv/bin/pip install --upgrade pip
  .venv/bin/pip install -r artifacts/flask-api/requirements.txt
"

7. Initialiser la base de données

La base de données est initialisée automatiquement au premier démarrage du service (création des tables et migrations incrémentales idempotentes). Aucune action manuelle n'est requise. Passez directement à l'étape suivante.


8. Construire le frontend React

sudo -u voixdupeuple bash -c "
  cd /opt/voix-du-peuple
  pnpm install --frozen-lockfile
  cd artifacts/voix-du-peuple
  pnpm build
"

Les fichiers statiques sont générés dans artifacts/voix-du-peuple/dist/public/.

Si vous avez configuré VITE_APP_URL (URL publique du site, pour le QR code du flyer) ou VITE_HCAPTCHA_SITE_KEY, ajoutez ces variables dans le .env avant le build — elles sont intégrées statiquement dans le bundle.


9. Configurer le service systemd (Gunicorn)

# Créer le répertoire de logs
sudo mkdir -p /var/log/voix-du-peuple
sudo chown voixdupeuple:voixdupeuple /var/log/voix-du-peuple

# Copier le fichier de service
sudo cp /opt/voix-du-peuple/deploy/voix-du-peuple-api.service \
        /etc/systemd/system/voix-du-peuple-api.service

# Activer et démarrer
sudo systemctl daemon-reload
sudo systemctl enable --now voix-du-peuple-api

# Vérifier
sudo systemctl status voix-du-peuple-api

Test de l'API :

curl http://127.0.0.1:8000/api/healthz
# {"status": "ok"}

10. Configurer Nginx

Le fichier deploy/nginx.conf configure Nginx en écoute sur le port 8080 (mode derrière HAProxy). Pour un déploiement standalone sans HAProxy, changez listen 8080; en listen 80; avant de copier.

sudo cp /opt/voix-du-peuple/deploy/nginx.conf \
        /etc/nginx/conf.d/voix-du-peuple.conf

# Si déploiement standalone : changer le port d'écoute
sudo sed -i 's/listen 8080;/listen 80;/' /etc/nginx/conf.d/voix-du-peuple.conf

# Tester la configuration
sudo nginx -t

# Recharger Nginx
sudo systemctl reload nginx

11. Ouvrir le pare-feu

# Standalone (Nginx expose directement HTTP/HTTPS)
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

SELinux — autoriser Nginx à proxyfier vers Gunicorn :

sudo setsebool -P httpd_can_network_connect 1

12. (Recommandé) HTTPS avec Let's Encrypt — déploiement standalone

Ne s'applique qu'en déploiement standalone (Nginx exposé directement). Si HAProxy gère le TLS, configurez Let's Encrypt sur HAProxy — voir ARCHITECTURE.md.

sudo dnf install -y certbot python3-certbot-nginx

# Remplacez voix-du-peuple.example.com par votre domaine
sudo certbot --nginx -d voix-du-peuple.example.com \
  --email contact@example.com --agree-tos --no-eff-email

# Vérifier le renouvellement automatique
sudo systemctl status certbot-renew.timer

Structure du projet

voix-du-peuple/
├── artifacts/
│   ├── flask-api/              # Backend Python Flask
│   │   ├── app.py              # Application principale + routes API
│   │   ├── ai_agent.py         # Agent IA (filtrage, synthèse, check-law)
│   │   ├── legal_framework.py  # Prompts ancrés dans le droit (16 sources)
│   │   ├── database.py         # Accès PostgreSQL (psycopg2, sans ORM)
│   │   ├── requirements.txt    # Dépendances Python
│   │   └── start.sh            # Démarrage dev direct (python app.py)
│   └── voix-du-peuple/         # Frontend React + Vite 7
│       ├── src/
│       │   ├── pages/          # home, about, transparence, contributions-brutes,
│       │   │                   # consultations, legal-notice, privacy-policy, admin…
│       │   ├── components/     # UI (shadcn/ui + Radix), consent-dialog, navbar…
│       │   ├── lib/            # prompts.ts (verbatim IA), hooks, utils
│       │   └── App.tsx         # Routing principal (Wouter)
│       └── vite.config.ts      # Config Vite (dev + production)
├── lib/
│   ├── api-spec/               # Spécification OpenAPI + config Orval
│   ├── api-client-react/       # Hooks React Query générés
│   └── api-zod/                # Schémas Zod générés
├── deploy/
│   ├── voix-du-peuple-api.service  # Service systemd Gunicorn
│   └── nginx.conf                  # Configuration Nginx (port 8080, derrière HAProxy)
├── docs/                       # Documentation technique (RGPD, sécurité, prompts…)
├── .env.example                # Variables d'environnement (modèle commenté)
└── DEPLOIEMENT.md              # Ce fichier

Variables d'environnement complètes

Variable Obligatoire Description
DATABASE_URL Oui URL PostgreSQL — postgresql://user:pass@host:5432/db
MISTRAL_API_KEY Oui* Clé API Mistral AI (fournisseur par défaut)
OPENAI_API_KEY Oui* Alternative si Mistral non utilisé (compatible OpenAI)
OPENAI_BASE_URL Non Base URL pour fournisseur tiers (Ollama, proxy…)
FILTER_MODEL Non Modèle de filtrage (défaut : mistral-small-latest)
SYNTHESIS_MODEL Non Modèle de synthèse (défaut : mistral-large-latest)
SECRET_KEY Recommandé Active le cooldown cookie et le consentement RGPD signé HMAC-SHA256
ADMIN_SECRET Recommandé Mot de passe du panel /admin (session via cookie httpOnly)
ALLOWED_ORIGINS Recommandé Domaine(s) public(s) autorisés en CORS — requis pour le cookie de session
REDIS_URL Non Rate limiting persistant entre workers — redis://localhost:6379/0
RATE_LIMIT_CONTRIBUTIONS Non Seuil de soumissions (défaut : 5 per minute;3 per hour)
CONTRIBUTION_COOLDOWN_SECONDS Non Délai inter-soumissions par session (défaut : 240)
FLOOD_THRESHOLD Non Seuil d'alerte flood en 5 min (défaut : 10)
IP_BLACKLIST_DAYS Non Durée du blacklist IP (défaut : 30)
HCAPTCHA_SECRET_KEY Non Active la vérification hCaptcha côté serveur
VITE_HCAPTCHA_SITE_KEY Non Clé publique hCaptcha côté frontend (rebuild requis)
VITE_APP_URL Non URL publique du site — utilisée par le QR code du flyer (rebuild requis)

*MISTRAL_API_KEY est prioritaire. Si absent, OPENAI_API_KEY est utilisé. L'un des deux est obligatoire.


Modèles IA

Rôle Modèle par défaut Variable de contrôle
Filtrage des contributions mistral-small-latest FILTER_MODEL
Synthèse collective mistral-large-latest SYNTHESIS_MODEL
Vérification cadre légal même que FILTER_MODEL FILTER_MODEL

Pour changer de modèle, définissez la variable dans .env — aucune modification du code n'est nécessaire.


Mise à jour

sudo -u voixdupeuple bash -c "
  cd /opt/voix-du-peuple
  git pull origin main

  # Dépendances Python
  .venv/bin/pip install -r artifacts/flask-api/requirements.txt

  # Rebuild frontend
  pnpm install --frozen-lockfile
  cd artifacts/voix-du-peuple && pnpm build
"

# Redémarrer le service (les migrations DB s'appliquent automatiquement)
sudo systemctl restart voix-du-peuple-api
sudo systemctl reload nginx

Logs et supervision

# Logs API Flask (journald + fichiers)
sudo journalctl -u voix-du-peuple-api -f
sudo tail -f /var/log/voix-du-peuple/api-error.log
sudo tail -f /var/log/voix-du-peuple/api-access.log

# Logs Nginx
sudo tail -f /var/log/nginx/error.log
sudo tail -f /var/log/nginx/access.log

# Statut des services
sudo systemctl status voix-du-peuple-api
sudo systemctl status nginx
sudo systemctl status postgresql-15

Dépannage fréquent

L'API répond 502 Bad Gateway

# Vérifier que Gunicorn tourne
sudo systemctl status voix-du-peuple-api

# Vérifier SELinux
sudo setsebool -P httpd_can_network_connect 1

# Tester Gunicorn directement
curl http://127.0.0.1:8000/api/healthz

Erreur "could not connect to server" (PostgreSQL)

sudo systemctl status postgresql-15
psql -U voixdupeuple -h 127.0.0.1 -d voixdupeuple -c "\dt"
# Vérifier DATABASE_URL dans .env

Erreur Mistral AI API

# Vérifier la clé dans .env
grep MISTRAL_API_KEY /opt/voix-du-peuple/.env

# Tester l'accès à l'API Mistral
curl https://api.mistral.ai/v1/models \
  -H "Authorization: Bearer $(grep MISTRAL_API_KEY /opt/voix-du-peuple/.env | cut -d= -f2)"

Le frontend affiche une page blanche

# Vérifier que le build existe
ls /opt/voix-du-peuple/artifacts/voix-du-peuple/dist/public/

# Vérifier les droits
sudo chown -R voixdupeuple:voixdupeuple \
  /opt/voix-du-peuple/artifacts/voix-du-peuple/dist/

Les variables VITE_* ne sont pas prises en compte

Les variables préfixées VITE_ sont intégrées au moment du build, pas à l'exécution. Après toute modification de VITE_APP_URL ou VITE_HCAPTCHA_SITE_KEY, relancez le build :

cd /opt/voix-du-peuple/artifacts/voix-du-peuple && pnpm build