Compare commits
2 commits
fix/compos
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dee190a8c | |||
| 510404013c |
18 changed files with 13 additions and 1177 deletions
13
.env.example
13
.env.example
|
|
@ -131,16 +131,3 @@ CRON_TIMEZONE=Europe/Paris
|
||||||
# Nom du reseau Docker externe partage avec le Traefik de l'hote (doit exister
|
# Nom du reseau Docker externe partage avec le Traefik de l'hote (doit exister
|
||||||
# AVANT le up : cree par la stack Traefik, ou `docker network create <nom>`).
|
# AVANT le up : cree par la stack Traefik, ou `docker network create <nom>`).
|
||||||
REVERSE_PROXY_NETWORK=admin_proxy
|
REVERSE_PROXY_NETWORK=admin_proxy
|
||||||
|
|
||||||
# ===================================================================
|
|
||||||
# Envoi d'email (reinitialisation mot de passe) - OPTIONNEL
|
|
||||||
# ===================================================================
|
|
||||||
# Absentes en local : l'app journalise le lien de reset (LogMailer), aucun envoi.
|
|
||||||
# Renseigner SMTP_HOST + SMTP_USER + SMTP_PASSWORD active l'envoi via relais SMTP.
|
|
||||||
# Mettre les vraies valeurs uniquement dans le .env de l'hote (jamais versionnees).
|
|
||||||
# SMTP_HOST=smtp-relay.brevo.com
|
|
||||||
# SMTP_PORT=587
|
|
||||||
# SMTP_USER=
|
|
||||||
# SMTP_PASSWORD=
|
|
||||||
# MAIL_FROM_EMAIL=noreply@example.com
|
|
||||||
# MAIL_FROM_NAME=Wakdo
|
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
# Modele de configuration de PRODUCTION (derriere Traefik).
|
|
||||||
#
|
|
||||||
# cp .env.prod.example .env
|
|
||||||
# puis renseigner les lignes <REMPLIR> (domaines, mots de passe, reseau Traefik).
|
|
||||||
#
|
|
||||||
# Difference avec .env.example (dev) : APP_ENV=prod, APP_DEBUG=false, URLs en HTTPS,
|
|
||||||
# mots de passe forts, REVERSE_PROXY_NETWORK renseigne.
|
|
||||||
|
|
||||||
APP_ENV=prod
|
|
||||||
APP_DEBUG=false
|
|
||||||
APP_TIMEZONE=Europe/Paris
|
|
||||||
|
|
||||||
# Domaines publics (doivent resoudre en DNS vers l'hote de prod).
|
|
||||||
APP_HOST_KIOSK=<REMPLIR-domaine-borne>
|
|
||||||
APP_HOST_ADMIN=<REMPLIR-domaine-admin>
|
|
||||||
APP_URL_KIOSK=https://<REMPLIR-domaine-borne>
|
|
||||||
APP_URL_ADMIN=https://<REMPLIR-domaine-admin>
|
|
||||||
|
|
||||||
# Base de donnees : mots de passe FORTS en prod (openssl rand -base64 24).
|
|
||||||
DB_HOST=wakdo-db
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_NAME=wakdo
|
|
||||||
DB_USER=wakdo
|
|
||||||
DB_PASSWORD=<REMPLIR-mot-de-passe-fort>
|
|
||||||
DB_ROOT_PASSWORD=<REMPLIR-autre-mot-de-passe-fort>
|
|
||||||
|
|
||||||
SESSION_LIFETIME_IDLE=14400
|
|
||||||
SESSION_LIFETIME_ABSOLUTE=36000
|
|
||||||
SESSION_NAME=WAKDO_SID
|
|
||||||
|
|
||||||
# Doit correspondre EXACTEMENT a APP_URL_KIOSK (pas de wildcard).
|
|
||||||
CORS_ALLOWED_ORIGIN=https://<REMPLIR-domaine-borne>
|
|
||||||
|
|
||||||
ARGON2_MEMORY_COST=65536
|
|
||||||
ARGON2_TIME_COST=4
|
|
||||||
ARGON2_THREADS=1
|
|
||||||
|
|
||||||
ACCOUNT_LOCKOUT_THRESHOLD=5
|
|
||||||
ACCOUNT_LOCKOUT_BASE_SECONDS=60
|
|
||||||
ACCOUNT_LOCKOUT_MAX_SECONDS=900
|
|
||||||
IP_THROTTLE_WINDOW_SECONDS=900
|
|
||||||
IP_THROTTLE_MAX_ATTEMPTS=20
|
|
||||||
|
|
||||||
STAFF_PIN_MIN_LENGTH=4
|
|
||||||
STAFF_PIN_MAX_LENGTH=12
|
|
||||||
PIN_THROTTLE_THRESHOLD=5
|
|
||||||
PIN_THROTTLE_BASE_SECONDS=30
|
|
||||||
PIN_THROTTLE_MAX_SECONDS=300
|
|
||||||
PIN_THROTTLE_WINDOW_SECONDS=900
|
|
||||||
PASSWORD_RESET_TTL=3600
|
|
||||||
|
|
||||||
AUDIT_LOG_RETENTION_DAYS=365
|
|
||||||
THROTTLE_PURGE_AFTER_HOURS=24
|
|
||||||
ORDER_RETENTION_DAYS=1095
|
|
||||||
|
|
||||||
UPLOAD_MAX_SIZE_MB=5
|
|
||||||
UPLOAD_ALLOWED_MIME=image/jpeg,image/png,image/webp
|
|
||||||
|
|
||||||
CRON_TIMEZONE=Europe/Paris
|
|
||||||
|
|
||||||
# Nom du reseau Docker externe du Traefik de l'hote (doit exister avant le up).
|
|
||||||
REVERSE_PROXY_NETWORK=<REMPLIR-reseau-traefik>
|
|
||||||
|
|
||||||
# ===================================================================
|
|
||||||
# Envoi d'email (reinitialisation mot de passe) - relais SMTP
|
|
||||||
# ===================================================================
|
|
||||||
# Si SMTP_HOST + SMTP_USER + SMTP_PASSWORD sont presents, l'app envoie via le
|
|
||||||
# relais ; sinon elle se rabat sur le journal (LogMailer). Renseigner ces 3
|
|
||||||
# valeurs UNIQUEMENT ici (jamais dans le depot). Exemple : relais Brevo.
|
|
||||||
SMTP_HOST=smtp-relay.brevo.com
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USER=<REMPLIR-login-smtp>
|
|
||||||
SMTP_PASSWORD=<REMPLIR-cle-smtp-secrete>
|
|
||||||
MAIL_FROM_EMAIL=noreply@a3n.fr
|
|
||||||
MAIL_FROM_NAME=Wakdo
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
name: Deploy
|
|
||||||
# Deploiement continu (CD) vers Vision (prod) a chaque release sur main.
|
|
||||||
#
|
|
||||||
# Topologie : le runner tourne sur Stark (dev) et n'a pas le socket Docker. Il ne
|
|
||||||
# pilote donc PAS Docker lui-meme : il OUVRE une session SSH vers Vision (prod, hote
|
|
||||||
# distinct) ou une forced command (cote Vision) lance scripts/deploy.sh. La cle CI ne
|
|
||||||
# peut ainsi declencher QUE le deploiement, rien d'autre.
|
|
||||||
#
|
|
||||||
# main n'est alimentee que par des PR dev->main deja passees par la CI : le code
|
|
||||||
# deploye a donc deja ete teste. Voir docs/architecture/deployment.md pour la mise en
|
|
||||||
# place cote Vision (utilisateur deploy, forced command) et les secrets a creer.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- name: Install SSH client
|
|
||||||
run: |
|
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y -qq openssh-client >/dev/null
|
|
||||||
- name: Deploy to Vision over SSH
|
|
||||||
env:
|
|
||||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
|
||||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
|
||||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
|
||||||
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
install -d -m 700 ~/.ssh
|
|
||||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy
|
|
||||||
chmod 600 ~/.ssh/id_deploy
|
|
||||||
# Cle d'hote epinglee (pas de TOFU) : la connexion echoue si Vision ne
|
|
||||||
# presente pas la cle attendue.
|
|
||||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
|
||||||
# Aucune commande passee : la forced command cote Vision lance deploy.sh.
|
|
||||||
# BatchMode : pas de prompt interactif (un echec d'auth echoue vite au lieu
|
|
||||||
# de pendre le job) ; ConnectTimeout borne l'attente si Vision est injoignable.
|
|
||||||
ssh -i ~/.ssh/id_deploy -o IdentitiesOnly=yes \
|
|
||||||
-o StrictHostKeyChecking=yes \
|
|
||||||
-o BatchMode=yes -o ConnectTimeout=15 \
|
|
||||||
"$DEPLOY_USER@$DEPLOY_HOST"
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -56,9 +56,6 @@ Thumbs.db
|
||||||
*.log
|
*.log
|
||||||
/logs/
|
/logs/
|
||||||
|
|
||||||
# === Marqueur de version (ecrit par scripts/deploy.sh sur l'hote, propre au deploiement) ===
|
|
||||||
/src/VERSION
|
|
||||||
|
|
||||||
# === Data / Uploads / Backups ===
|
# === Data / Uploads / Backups ===
|
||||||
# /var/ : contient /var/backups/ (bind-mount des dumps BDD du conteneur cron)
|
# /var/ : contient /var/backups/ (bind-mount des dumps BDD du conteneur cron)
|
||||||
# et tout futur artefact run-time (caches persistes, logs).
|
# et tout futur artefact run-time (caches persistes, logs).
|
||||||
|
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
# Modele de compose de production (derriere un reverse proxy Traefik).
|
|
||||||
#
|
|
||||||
# Entierement pilote par le .env : le meme fichier marche sur n'importe quel hote,
|
|
||||||
# seules les valeurs du .env changent. Sur l'hote de prod :
|
|
||||||
# cp docker-compose.prod.yml.example docker-compose.prod.yml
|
|
||||||
# cp .env.prod.example .env # puis renseigner domaines + mots de passe
|
|
||||||
# docker compose -f docker-compose.prod.yml up -d --build
|
|
||||||
#
|
|
||||||
# Prerequis : le reseau externe ${REVERSE_PROXY_NETWORK} existe (cree par la stack
|
|
||||||
# Traefik de l'hote). Les entrypoints (websecure) et le certresolver (letsencrypt)
|
|
||||||
# doivent correspondre a la config Traefik de l'hote.
|
|
||||||
|
|
||||||
name: wakdo
|
|
||||||
|
|
||||||
networks:
|
|
||||||
wakdo_internal:
|
|
||||||
driver: bridge
|
|
||||||
reverse_proxy:
|
|
||||||
name: ${REVERSE_PROXY_NETWORK}
|
|
||||||
external: true
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
wakdo_db_data:
|
|
||||||
wakdo_uploads:
|
|
||||||
|
|
||||||
services:
|
|
||||||
|
|
||||||
wakdo-db:
|
|
||||||
image: mariadb:11.4
|
|
||||||
container_name: wakdo-db
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
|
||||||
MARIADB_DATABASE: ${DB_NAME}
|
|
||||||
MARIADB_USER: ${DB_USER}
|
|
||||||
MARIADB_PASSWORD: ${DB_PASSWORD}
|
|
||||||
MARIADB_AUTO_UPGRADE: "1"
|
|
||||||
TZ: ${APP_TIMEZONE:-Europe/Paris}
|
|
||||||
volumes:
|
|
||||||
- wakdo_db_data:/var/lib/mysql
|
|
||||||
- ./db/init:/docker-entrypoint-initdb.d:ro
|
|
||||||
networks:
|
|
||||||
- wakdo_internal
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 6
|
|
||||||
start_period: 30s
|
|
||||||
|
|
||||||
wakdo-migrate:
|
|
||||||
image: mariadb:11.4
|
|
||||||
container_name: wakdo-migrate
|
|
||||||
restart: "no"
|
|
||||||
environment:
|
|
||||||
DB_HOST: ${DB_HOST}
|
|
||||||
DB_PORT: ${DB_PORT}
|
|
||||||
DB_NAME: ${DB_NAME}
|
|
||||||
DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- ./db:/db:ro
|
|
||||||
networks:
|
|
||||||
- wakdo_internal
|
|
||||||
depends_on:
|
|
||||||
wakdo-db:
|
|
||||||
condition: service_healthy
|
|
||||||
entrypoint: ["bash", "/db/migrate-container.sh"]
|
|
||||||
|
|
||||||
wakdo-app:
|
|
||||||
build:
|
|
||||||
context: ./docker/php-fpm
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: wakdo-app
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
APP_ENV: ${APP_ENV}
|
|
||||||
APP_DEBUG: ${APP_DEBUG}
|
|
||||||
APP_TIMEZONE: ${APP_TIMEZONE}
|
|
||||||
APP_URL_KIOSK: ${APP_URL_KIOSK}
|
|
||||||
APP_URL_ADMIN: ${APP_URL_ADMIN}
|
|
||||||
DB_HOST: ${DB_HOST}
|
|
||||||
DB_PORT: ${DB_PORT}
|
|
||||||
DB_NAME: ${DB_NAME}
|
|
||||||
DB_USER: ${DB_USER}
|
|
||||||
DB_PASSWORD: ${DB_PASSWORD}
|
|
||||||
SESSION_LIFETIME_IDLE: ${SESSION_LIFETIME_IDLE}
|
|
||||||
SESSION_LIFETIME_ABSOLUTE: ${SESSION_LIFETIME_ABSOLUTE}
|
|
||||||
SESSION_NAME: ${SESSION_NAME}
|
|
||||||
CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN}
|
|
||||||
ARGON2_MEMORY_COST: ${ARGON2_MEMORY_COST}
|
|
||||||
ARGON2_TIME_COST: ${ARGON2_TIME_COST}
|
|
||||||
ARGON2_THREADS: ${ARGON2_THREADS}
|
|
||||||
ACCOUNT_LOCKOUT_THRESHOLD: ${ACCOUNT_LOCKOUT_THRESHOLD}
|
|
||||||
ACCOUNT_LOCKOUT_BASE_SECONDS: ${ACCOUNT_LOCKOUT_BASE_SECONDS}
|
|
||||||
ACCOUNT_LOCKOUT_MAX_SECONDS: ${ACCOUNT_LOCKOUT_MAX_SECONDS}
|
|
||||||
IP_THROTTLE_WINDOW_SECONDS: ${IP_THROTTLE_WINDOW_SECONDS}
|
|
||||||
IP_THROTTLE_MAX_ATTEMPTS: ${IP_THROTTLE_MAX_ATTEMPTS}
|
|
||||||
STAFF_PIN_MIN_LENGTH: ${STAFF_PIN_MIN_LENGTH}
|
|
||||||
STAFF_PIN_MAX_LENGTH: ${STAFF_PIN_MAX_LENGTH}
|
|
||||||
PIN_THROTTLE_THRESHOLD: ${PIN_THROTTLE_THRESHOLD}
|
|
||||||
PIN_THROTTLE_BASE_SECONDS: ${PIN_THROTTLE_BASE_SECONDS}
|
|
||||||
PIN_THROTTLE_MAX_SECONDS: ${PIN_THROTTLE_MAX_SECONDS}
|
|
||||||
PIN_THROTTLE_WINDOW_SECONDS: ${PIN_THROTTLE_WINDOW_SECONDS}
|
|
||||||
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
|
|
||||||
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
|
|
||||||
UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME}
|
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
|
||||||
SMTP_USER: ${SMTP_USER:-}
|
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
|
||||||
MAIL_FROM_EMAIL: ${MAIL_FROM_EMAIL:-}
|
|
||||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Wakdo}
|
|
||||||
volumes:
|
|
||||||
- ./src:/var/www/html
|
|
||||||
- wakdo_uploads:/var/www/html/public/uploads
|
|
||||||
networks:
|
|
||||||
- wakdo_internal
|
|
||||||
depends_on:
|
|
||||||
wakdo-migrate:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
wakdo-db:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
wakdo-web:
|
|
||||||
build:
|
|
||||||
context: ./docker/apache
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: wakdo-web
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
APP_HOST_KIOSK: ${APP_HOST_KIOSK}
|
|
||||||
APP_HOST_ADMIN: ${APP_HOST_ADMIN}
|
|
||||||
volumes:
|
|
||||||
- ./src:/var/www/html
|
|
||||||
- wakdo_uploads:/var/www/html/public/uploads
|
|
||||||
networks:
|
|
||||||
- wakdo_internal
|
|
||||||
- reverse_proxy
|
|
||||||
depends_on:
|
|
||||||
wakdo-migrate:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
wakdo-app:
|
|
||||||
condition: service_started
|
|
||||||
wakdo-db:
|
|
||||||
condition: service_healthy
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.docker.network=${REVERSE_PROXY_NETWORK}"
|
|
||||||
- "traefik.http.routers.wakdo-kiosk.rule=Host(`${APP_HOST_KIOSK}`)"
|
|
||||||
- "traefik.http.routers.wakdo-kiosk.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.wakdo-kiosk.tls=true"
|
|
||||||
- "traefik.http.routers.wakdo-kiosk.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.routers.wakdo-kiosk.service=wakdo-kiosk-svc"
|
|
||||||
- "traefik.http.services.wakdo-kiosk-svc.loadbalancer.server.port=80"
|
|
||||||
- "traefik.http.routers.wakdo-admin.rule=Host(`${APP_HOST_ADMIN}`)"
|
|
||||||
- "traefik.http.routers.wakdo-admin.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.wakdo-admin.tls=true"
|
|
||||||
- "traefik.http.routers.wakdo-admin.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.routers.wakdo-admin.service=wakdo-admin-svc"
|
|
||||||
- "traefik.http.services.wakdo-admin-svc.loadbalancer.server.port=80"
|
|
||||||
|
|
||||||
wakdo-cron:
|
|
||||||
build:
|
|
||||||
context: ./docker/cron
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: wakdo-cron
|
|
||||||
restart: unless-stopped
|
|
||||||
init: true
|
|
||||||
environment:
|
|
||||||
DB_HOST: ${DB_HOST}
|
|
||||||
DB_PORT: ${DB_PORT}
|
|
||||||
DB_NAME: ${DB_NAME}
|
|
||||||
DB_USER: ${DB_USER}
|
|
||||||
DB_PASSWORD: ${DB_PASSWORD}
|
|
||||||
AUDIT_LOG_RETENTION_DAYS: ${AUDIT_LOG_RETENTION_DAYS:-365}
|
|
||||||
THROTTLE_PURGE_AFTER_HOURS: ${THROTTLE_PURGE_AFTER_HOURS:-24}
|
|
||||||
TZ: ${CRON_TIMEZONE:-Europe/Paris}
|
|
||||||
volumes:
|
|
||||||
- ./var/backups:/backups
|
|
||||||
networks:
|
|
||||||
- wakdo_internal
|
|
||||||
depends_on:
|
|
||||||
wakdo-db:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
@ -89,12 +89,6 @@ services:
|
||||||
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
|
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
|
||||||
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
|
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
|
||||||
UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME}
|
UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
|
||||||
SMTP_USER: ${SMTP_USER:-}
|
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
|
||||||
MAIL_FROM_EMAIL: ${MAIL_FROM_EMAIL:-}
|
|
||||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Wakdo}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./src:/var/www/html
|
- ./src:/var/www/html
|
||||||
- wakdo_uploads:/var/www/html/public/uploads
|
- wakdo_uploads:/var/www/html/public/uploads
|
||||||
|
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
# Deploiement continu (CD) — Wakdo
|
|
||||||
|
|
||||||
Ce document decrit le deploiement automatique vers la production et la mise en place
|
|
||||||
a faire une seule fois cote serveur. Il complete `scripts/deploy.sh` et
|
|
||||||
`.forgejo/workflows/deploy.yml`.
|
|
||||||
|
|
||||||
## Topologie
|
|
||||||
|
|
||||||
| Hote | Role |
|
|
||||||
|---|---|
|
|
||||||
| **Thanos** (`git.acadenice.com`) | Forge : depot Git + Forgejo Actions |
|
|
||||||
| **Stark** | Environnement de dev ; heberge le runner Forgejo |
|
|
||||||
| **Vision** | Production : la stack Wakdo y tourne, cible du deploiement |
|
|
||||||
|
|
||||||
Le runner (sur Stark) n'a pas acces au socket Docker, par choix de securite : un job
|
|
||||||
CI ne peut pas piloter Docker sur son hote. Le deploiement vers Vision se fait donc
|
|
||||||
par SSH — ce qui correspond au schema normal d'un deploiement vers un hote distant.
|
|
||||||
|
|
||||||
## Flux
|
|
||||||
|
|
||||||
```
|
|
||||||
merge dev -> main (release, deja passee par la CI sur la PR)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Forgejo Actions: workflow Deploy (.forgejo/workflows/deploy.yml)
|
|
||||||
│ ssh deploy@vision (sans commande : forced command cote Vision)
|
|
||||||
▼
|
|
||||||
Vision: scripts/deploy.sh (git ff-only -> VERSION + deploy.log -> compose build/up)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
GET /api/health renvoie le nouveau SHA ← preuve du deploiement
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ce qui est automatise (dans le depot)
|
|
||||||
|
|
||||||
- `.forgejo/workflows/deploy.yml` : sur push `main`, ouvre la session SSH vers Vision.
|
|
||||||
- `scripts/deploy.sh` : recupere `main` (fast-forward), ecrit le marqueur de version
|
|
||||||
(`src/VERSION`) et une ligne dans `deploy.log`, reconstruit et recree la stack.
|
|
||||||
Mode non-interactif via `DEPLOY_YES=1`.
|
|
||||||
- `GET /api/health` expose `version` (SHA) et `deployed_at` (date), lus depuis
|
|
||||||
`src/VERSION`.
|
|
||||||
|
|
||||||
## Mise en place cote Vision (une fois)
|
|
||||||
|
|
||||||
Prerequis : Docker + docker compose, le depot clone (ex. `/srv/wakdo`).
|
|
||||||
|
|
||||||
Le compose et le `.env` de prod ne sont pas versionnes (propres a l'hote) ; ils se
|
|
||||||
derivent des modeles fournis dans le depot :
|
|
||||||
```bash
|
|
||||||
cp docker-compose.prod.yml.example docker-compose.prod.yml
|
|
||||||
cp .env.prod.example .env # puis renseigner domaines + mots de passe + reseau Traefik
|
|
||||||
docker compose -f docker-compose.prod.yml up -d --build
|
|
||||||
```
|
|
||||||
Le compose est entierement pilote par le `.env` : le meme fichier marche sur tout hote.
|
|
||||||
|
|
||||||
1. Creer un utilisateur dedie au deploiement, membre du groupe `docker` :
|
|
||||||
```bash
|
|
||||||
sudo useradd -m -G docker deploy
|
|
||||||
```
|
|
||||||
2. Lui donner le depot (ou ajuster les droits du clone existant) :
|
|
||||||
```bash
|
|
||||||
sudo chown -R deploy:deploy /srv/wakdo
|
|
||||||
```
|
|
||||||
3. Autoriser la cle CI avec une **forced command** : la cle ne peut lancer que le
|
|
||||||
deploiement, aucune autre commande. Dans `~deploy/.ssh/authorized_keys` :
|
|
||||||
```
|
|
||||||
command="cd /srv/wakdo && DEPLOY_YES=1 scripts/deploy.sh main",no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA...CLE_PUBLIQUE... deploy@wakdo-ci
|
|
||||||
```
|
|
||||||
`deploy.sh` ne lit pas `$SSH_ORIGINAL_COMMAND` : meme si un appel SSH tentait de
|
|
||||||
passer une autre commande, elle serait ignoree.
|
|
||||||
|
|
||||||
## Generer la cle et la connaitre cote forge
|
|
||||||
|
|
||||||
Sur un poste de confiance :
|
|
||||||
```bash
|
|
||||||
ssh-keygen -t ed25519 -f wakdo-deploy -C "deploy@wakdo-ci" -N ""
|
|
||||||
# wakdo-deploy -> cle PRIVEE (secret de la forge, ci-dessous)
|
|
||||||
# wakdo-deploy.pub -> cle PUBLIQUE (authorized_keys de Vision, etape 3)
|
|
||||||
|
|
||||||
ssh-keyscan -t ed25519 <hote-vision> # -> contenu du secret DEPLOY_KNOWN_HOSTS
|
|
||||||
```
|
|
||||||
|
|
||||||
## Secrets et variables a creer sur la forge
|
|
||||||
|
|
||||||
Depot -> Settings -> Actions -> Secrets / Variables :
|
|
||||||
|
|
||||||
| Type | Nom | Valeur |
|
|
||||||
|---|---|---|
|
|
||||||
| Secret | `DEPLOY_SSH_KEY` | contenu de la cle privee `wakdo-deploy` |
|
|
||||||
| Secret | `DEPLOY_KNOWN_HOSTS` | sortie de `ssh-keyscan` (cle d'hote de Vision) |
|
|
||||||
| Secret | `DEPLOY_HOST` | nom/IP de Vision |
|
|
||||||
| Variable | `DEPLOY_USER` | `deploy` |
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
1. Faire une release (`dev -> main`).
|
|
||||||
2. Suivre le workflow **Deploy** dans l'interface de la forge (il se declenche au push
|
|
||||||
sur `main`).
|
|
||||||
3. Interroger la sonde et lire la version deployee :
|
|
||||||
```bash
|
|
||||||
curl -s https://<fqdn-admin-prod>/api/health
|
|
||||||
# { ... "version": "<sha>", "deployed_at": "<date>" }
|
|
||||||
```
|
|
||||||
Le `version` correspond au HEAD de `main` apres la release — preuve que Vision a ete
|
|
||||||
mise a jour sans intervention manuelle.
|
|
||||||
|
|
||||||
## Notes de securite
|
|
||||||
|
|
||||||
- Cle SSH dediee au seul deploiement, **forced command** + options `no-*` qui retirent
|
|
||||||
shell, tunnels et forwarding.
|
|
||||||
- Cle d'hote **epinglee** (`DEPLOY_KNOWN_HOSTS`, `StrictHostKeyChecking=yes`) : pas de
|
|
||||||
confiance a la premiere connexion.
|
|
||||||
- Secrets stockes cote forge, hors du depot. `.env` et `docker-compose.prod.yml`
|
|
||||||
restent gitignores.
|
|
||||||
- Le runner n'a pas le socket Docker : un job ne peut pas agir sur Docker localement.
|
|
||||||
|
|
@ -52,40 +52,25 @@ if ! command -v docker >/dev/null 2>&1; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Deploiement Wakdo : branche '$BRANCH' depuis '$REMOTE' via $COMPOSE_FILE"
|
echo "Deploiement Wakdo : branche '$BRANCH' depuis '$REMOTE' via $COMPOSE_FILE"
|
||||||
# Mode non-interactif pour le CD : DEPLOY_YES=1 saute la confirmation (la forced
|
printf 'Confirmer le deploiement en production ? [oui/NON] '
|
||||||
# command SSH le pose). On NE lit PAS $SSH_ORIGINAL_COMMAND : la cle CI ne peut
|
read -r answer
|
||||||
# influencer ni la branche ni le compose, seulement declencher CE script.
|
if [ "$answer" != "oui" ]; then
|
||||||
if [ "${DEPLOY_YES:-}" = "1" ] || [ "${DEPLOY_YES:-}" = "oui" ]; then
|
echo "deploy: annule."
|
||||||
echo "deploy: confirmation automatique (DEPLOY_YES)."
|
exit 1
|
||||||
else
|
|
||||||
printf 'Confirmer le deploiement en production ? [oui/NON] '
|
|
||||||
read -r answer
|
|
||||||
if [ "$answer" != "oui" ]; then
|
|
||||||
echo "deploy: annule."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[1/5] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
|
echo "[1/4] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
|
||||||
git fetch --prune "$REMOTE" "$BRANCH"
|
git fetch --prune "$REMOTE" "$BRANCH"
|
||||||
git checkout "$BRANCH"
|
git checkout "$BRANCH"
|
||||||
git merge --ff-only "$REMOTE/$BRANCH"
|
git merge --ff-only "$REMOTE/$BRANCH"
|
||||||
|
|
||||||
echo "[2/5] marqueur de version (preuve CD cote app)"
|
echo "[2/4] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)"
|
||||||
SHA="$(git rev-parse --short HEAD)"
|
|
||||||
NOW="$(date --iso-8601=seconds)"
|
|
||||||
# Sous src/ pour etre visible dans le conteneur (mount ./src -> /var/www/html),
|
|
||||||
# lu a chaud par GET /api/health. Journal d'historique a la racine du depot.
|
|
||||||
printf '%s %s\n' "$SHA" "$NOW" > src/VERSION
|
|
||||||
printf '[%s] deploy %s (branche %s)\n' "$NOW" "$SHA" "$BRANCH" >> deploy.log
|
|
||||||
|
|
||||||
echo "[3/5] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)"
|
|
||||||
docker compose -f "$COMPOSE_FILE" build --pull
|
docker compose -f "$COMPOSE_FILE" build --pull
|
||||||
|
|
||||||
echo "[4/5] demarrage de la stack (migrate + seed idempotents puis app)"
|
echo "[3/4] demarrage de la stack (migrate + seed idempotents puis app)"
|
||||||
docker compose -f "$COMPOSE_FILE" up -d
|
docker compose -f "$COMPOSE_FILE" up -d
|
||||||
|
|
||||||
echo "[5/5] etat des services"
|
echo "[4/4] etat des services"
|
||||||
docker compose -f "$COMPOSE_FILE" ps
|
docker compose -f "$COMPOSE_FILE" ps
|
||||||
|
|
||||||
echo "Deploiement termine ($SHA)."
|
echo "Deploiement termine."
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client SMTP minimal (sans dependance) : ESMTP + STARTTLS + AUTH LOGIN, suffisant
|
|
||||||
* pour un relais authentifie type Brevo. Conduit la conversation contre un
|
|
||||||
* SmtpTransport injecte ; chaque etape verifie le code de reponse attendu et leve
|
|
||||||
* en cas d'ecart. La construction du message est laissee a l'appelant (SmtpMailer).
|
|
||||||
*/
|
|
||||||
final class SmtpClient
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly SmtpTransport $transport,
|
|
||||||
private readonly string $heloName = 'wakdo',
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ouvre la session, s'authentifie, transmet un message deja assemble
|
|
||||||
* (en-tetes + corps, lignes en CRLF, dot-stuffing applique) puis ferme.
|
|
||||||
*/
|
|
||||||
public function send(
|
|
||||||
string $host,
|
|
||||||
int $port,
|
|
||||||
string $user,
|
|
||||||
string $password,
|
|
||||||
string $from,
|
|
||||||
string $to,
|
|
||||||
string $message,
|
|
||||||
): void {
|
|
||||||
// Defense en profondeur : un CRLF dans une adresse injecterait une commande
|
|
||||||
// SMTP (RCPT supplementaire) ou un en-tete. On refuse avant toute connexion.
|
|
||||||
$this->assertNoInjection($from, 'expediteur');
|
|
||||||
$this->assertNoInjection($to, 'destinataire');
|
|
||||||
|
|
||||||
$t = $this->transport;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$t->open($host, $port, 15);
|
|
||||||
$this->expect($t->readReply(), 220, 'greeting');
|
|
||||||
|
|
||||||
$this->command('EHLO ' . $this->heloName, 250, 'EHLO');
|
|
||||||
$this->command('STARTTLS', 220, 'STARTTLS');
|
|
||||||
$t->enableCrypto();
|
|
||||||
// Re-EHLO obligatoire apres bascule TLS (la session repart de zero).
|
|
||||||
$this->command('EHLO ' . $this->heloName, 250, 'EHLO TLS');
|
|
||||||
|
|
||||||
$this->command('AUTH LOGIN', 334, 'AUTH LOGIN');
|
|
||||||
$this->command(base64_encode($user), 334, 'AUTH user');
|
|
||||||
$this->command(base64_encode($password), 235, 'AUTH password');
|
|
||||||
|
|
||||||
$this->command('MAIL FROM:<' . $from . '>', 250, 'MAIL FROM');
|
|
||||||
$this->command('RCPT TO:<' . $to . '>', 250, 'RCPT TO');
|
|
||||||
$this->command('DATA', 354, 'DATA');
|
|
||||||
|
|
||||||
// Corps + terminateur "<CRLF>.<CRLF>".
|
|
||||||
$t->write($message . "\r\n.\r\n");
|
|
||||||
$this->expect($t->readReply(), 250, 'corps du message');
|
|
||||||
|
|
||||||
$t->write("QUIT\r\n");
|
|
||||||
// La fermeture (221) n'est pas bloquante : le message est deja accepte.
|
|
||||||
$t->readReply();
|
|
||||||
} finally {
|
|
||||||
$t->close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function command(string $line, int $expected, string $stage): void
|
|
||||||
{
|
|
||||||
$this->transport->write($line . "\r\n");
|
|
||||||
$this->expect($this->transport->readReply(), $expected, $stage);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function assertNoInjection(string $address, string $label): void
|
|
||||||
{
|
|
||||||
if (preg_match('/[\r\n]/', $address) === 1) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
sprintf('SMTP : adresse %s invalide (saut de ligne interdit)', $label),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function expect(string $reply, int $code, string $stage): void
|
|
||||||
{
|
|
||||||
$got = (int) substr(ltrim($reply), 0, 3);
|
|
||||||
if ($got !== $code) {
|
|
||||||
// On ne journalise pas le corps : il peut contenir le lien de reset.
|
|
||||||
throw new RuntimeException(
|
|
||||||
sprintf('SMTP %s : attendu %d, recu "%s"', $stage, $code, trim($reply)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mailer SMTP reel (relais authentifie type Brevo). Implemente l'interface Mailer
|
|
||||||
* a la place de LogMailer quand le SMTP est configure (voir PasswordResetController).
|
|
||||||
* Assemble un message texte/plain UTF-8 conforme puis delegue l'envoi a SmtpClient.
|
|
||||||
*/
|
|
||||||
final class SmtpMailer implements Mailer
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly SmtpClient $client,
|
|
||||||
private readonly string $host,
|
|
||||||
private readonly int $port,
|
|
||||||
private readonly string $user,
|
|
||||||
private readonly string $password,
|
|
||||||
private readonly string $fromEmail,
|
|
||||||
private readonly string $fromName,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function sendPasswordReset(string $email, string $resetUrl): void
|
|
||||||
{
|
|
||||||
// Garde destinataire : une adresse valide ne contient ni CRLF ni structure
|
|
||||||
// d'injection (verrou en plus de la garde transport de SmtpClient).
|
|
||||||
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
|
||||||
throw new RuntimeException('SmtpMailer : adresse destinataire invalide');
|
|
||||||
}
|
|
||||||
|
|
||||||
$subject = 'Reinitialisation de votre mot de passe Wakdo';
|
|
||||||
$body = "Bonjour,\r\n\r\n"
|
|
||||||
. "Une reinitialisation de mot de passe a ete demandee pour ce compte.\r\n"
|
|
||||||
. "Pour definir un nouveau mot de passe, ouvrez ce lien :\r\n\r\n"
|
|
||||||
. $resetUrl . "\r\n\r\n"
|
|
||||||
. "Ce lien expire rapidement. Si vous n'etes pas a l'origine de la demande, "
|
|
||||||
. "ignorez cet email.\r\n";
|
|
||||||
|
|
||||||
$message = $this->buildMessage($email, $subject, $body);
|
|
||||||
|
|
||||||
$this->client->send(
|
|
||||||
$this->host,
|
|
||||||
$this->port,
|
|
||||||
$this->user,
|
|
||||||
$this->password,
|
|
||||||
$this->fromEmail,
|
|
||||||
$email,
|
|
||||||
$message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assemble en-tetes + corps en CRLF, avec dot-stuffing pour la phase DATA. */
|
|
||||||
private function buildMessage(string $to, string $subject, string $body): string
|
|
||||||
{
|
|
||||||
$headers = [
|
|
||||||
'From: ' . $this->encodeHeader($this->fromName) . ' <' . $this->fromEmail . '>',
|
|
||||||
'To: <' . $to . '>',
|
|
||||||
'Subject: ' . $this->encodeHeader($subject),
|
|
||||||
'MIME-Version: 1.0',
|
|
||||||
'Content-Type: text/plain; charset=UTF-8',
|
|
||||||
'Content-Transfer-Encoding: 8bit',
|
|
||||||
];
|
|
||||||
|
|
||||||
$raw = implode("\r\n", $headers) . "\r\n\r\n" . $this->normalizeEol($body);
|
|
||||||
|
|
||||||
return $this->dotStuff($raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** RFC 2047 (encoded-word base64) si la valeur sort de l'ASCII imprimable. */
|
|
||||||
private function encodeHeader(string $value): string
|
|
||||||
{
|
|
||||||
if (preg_match('/^[\x20-\x7E]*$/', $value) === 1) {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '=?UTF-8?B?' . base64_encode($value) . '?=';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalise toutes les fins de ligne en CRLF (LF ou CR isoles -> CRLF). */
|
|
||||||
private function normalizeEol(string $text): string
|
|
||||||
{
|
|
||||||
return (string) preg_replace('/\r\n|\r|\n/', "\r\n", $text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Double un point en debut de ligne (RFC 5321 transparency). */
|
|
||||||
private function dotStuff(string $message): string
|
|
||||||
{
|
|
||||||
$lines = explode("\r\n", $message);
|
|
||||||
foreach ($lines as $i => $line) {
|
|
||||||
if (isset($line[0]) && $line[0] === '.') {
|
|
||||||
$lines[$i] = '.' . $line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode("\r\n", $lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Couche transport d'une session SMTP : abstrait le socket reel pour que la
|
|
||||||
* logique du protocole (SmtpClient) soit testable sans reseau (double en test).
|
|
||||||
*/
|
|
||||||
interface SmtpTransport
|
|
||||||
{
|
|
||||||
public function open(string $host, int $port, int $timeoutSeconds): void;
|
|
||||||
|
|
||||||
/** Ecrit exactement $raw sur la connexion (CRLF inclus par l'appelant). */
|
|
||||||
public function write(string $raw): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lit une reponse SMTP complete. Gere le multiligne (RFC 5321 : les lignes
|
|
||||||
* de continuation ont un '-' en 4e position, la derniere un espace).
|
|
||||||
*/
|
|
||||||
public function readReply(): string;
|
|
||||||
|
|
||||||
/** Bascule la connexion en TLS (apres STARTTLS). */
|
|
||||||
public function enableCrypto(): void;
|
|
||||||
|
|
||||||
public function close(): void;
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transport SMTP reel sur socket TCP (stream_socket_client + STARTTLS). Aucune
|
|
||||||
* dependance externe. Non teste unitairement (effet de bord reseau) : la logique
|
|
||||||
* du protocole est couverte via SmtpClient + un transport double.
|
|
||||||
*/
|
|
||||||
final class StreamSmtpTransport implements SmtpTransport
|
|
||||||
{
|
|
||||||
/** @var resource|null */
|
|
||||||
private $stream = null;
|
|
||||||
|
|
||||||
public function open(string $host, int $port, int $timeoutSeconds): void
|
|
||||||
{
|
|
||||||
$errno = 0;
|
|
||||||
$errstr = '';
|
|
||||||
$stream = @stream_socket_client(
|
|
||||||
sprintf('tcp://%s:%d', $host, $port),
|
|
||||||
$errno,
|
|
||||||
$errstr,
|
|
||||||
$timeoutSeconds,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($stream === false) {
|
|
||||||
throw new RuntimeException(sprintf('SMTP : connexion echouee (%s)', $errstr));
|
|
||||||
}
|
|
||||||
|
|
||||||
stream_set_timeout($stream, $timeoutSeconds);
|
|
||||||
$this->stream = $stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function write(string $raw): void
|
|
||||||
{
|
|
||||||
fwrite($this->requireStream(), $raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function readReply(): string
|
|
||||||
{
|
|
||||||
$stream = $this->requireStream();
|
|
||||||
$data = '';
|
|
||||||
$lines = 0;
|
|
||||||
|
|
||||||
while (($line = fgets($stream, 515)) !== false) {
|
|
||||||
$data .= $line;
|
|
||||||
|
|
||||||
// Bornes anti-boucle sur reponse malformee (ni ligne finale, ni EOF).
|
|
||||||
if (++$lines > 100 || strlen($data) > 65536) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continuation UNIQUEMENT si '-' en 4e position ; toute autre ligne
|
|
||||||
// (y compris trop courte) termine la reponse.
|
|
||||||
if (!(strlen($line) >= 4 && $line[3] === '-')) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($data === '') {
|
|
||||||
throw new RuntimeException('SMTP : aucune reponse du serveur');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function enableCrypto(): void
|
|
||||||
{
|
|
||||||
if (!stream_socket_enable_crypto($this->requireStream(), true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
|
||||||
throw new RuntimeException('SMTP : echec de la negociation TLS (STARTTLS)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function close(): void
|
|
||||||
{
|
|
||||||
if (is_resource($this->stream)) {
|
|
||||||
fclose($this->stream);
|
|
||||||
}
|
|
||||||
$this->stream = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return resource */
|
|
||||||
private function requireStream()
|
|
||||||
{
|
|
||||||
if (!is_resource($this->stream)) {
|
|
||||||
throw new RuntimeException('SMTP : transport non ouvert');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->stream;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -12,13 +12,9 @@ use App\Core\Response;
|
||||||
* Sonde de sante. GET /api/health.
|
* Sonde de sante. GET /api/health.
|
||||||
*
|
*
|
||||||
* Le comptage des categories prouve la chaine complete (autoloader -> routeur
|
* Le comptage des categories prouve la chaine complete (autoloader -> routeur
|
||||||
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond. Expose aussi
|
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond.
|
||||||
* la version deployee (SHA + date), ecrite par scripts/deploy.sh : c'est la preuve
|
|
||||||
* cote app du CD (apres un deploiement, ce champ reflete le nouveau commit).
|
|
||||||
*
|
|
||||||
* Non-final : seam de test (la sous-classe redirige versionFilePath sur une fixture).
|
|
||||||
*/
|
*/
|
||||||
class HealthController extends Controller
|
final class HealthController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $params
|
* @param array<string, string> $params
|
||||||
|
|
@ -39,8 +35,6 @@ class HealthController extends Controller
|
||||||
$httpStatus = 503;
|
$httpStatus = 503;
|
||||||
}
|
}
|
||||||
|
|
||||||
$version = $this->readVersion();
|
|
||||||
|
|
||||||
return $this->json(
|
return $this->json(
|
||||||
[
|
[
|
||||||
'status' => $dbStatus === 'ok' ? 'ok' : 'degraded',
|
'status' => $dbStatus === 'ok' ? 'ok' : 'degraded',
|
||||||
|
|
@ -48,45 +42,8 @@ class HealthController extends Controller
|
||||||
'php_version' => PHP_VERSION,
|
'php_version' => PHP_VERSION,
|
||||||
'db' => $dbStatus,
|
'db' => $dbStatus,
|
||||||
'categories' => $categories,
|
'categories' => $categories,
|
||||||
'version' => $version['version'],
|
|
||||||
'deployed_at' => $version['deployed_at'],
|
|
||||||
],
|
],
|
||||||
$httpStatus,
|
$httpStatus,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Chemin du marqueur de version. Sous le mount du code (./src -> /var/www/html),
|
|
||||||
* donc lisible a chaud par l'app sans rebuild.
|
|
||||||
*/
|
|
||||||
protected function versionFilePath(): string
|
|
||||||
{
|
|
||||||
return dirname(__DIR__, 2) . '/VERSION';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lit "SHA<espace>date" ecrit par deploy.sh. Absence toleree (dev / avant 1er
|
|
||||||
* deploiement) : les deux champs retombent a null.
|
|
||||||
*
|
|
||||||
* @return array{version: ?string, deployed_at: ?string}
|
|
||||||
*/
|
|
||||||
private function readVersion(): array
|
|
||||||
{
|
|
||||||
$path = $this->versionFilePath();
|
|
||||||
if (!is_file($path) || !is_readable($path)) {
|
|
||||||
return ['version' => null, 'deployed_at' => null];
|
|
||||||
}
|
|
||||||
|
|
||||||
$line = trim((string) @file_get_contents($path));
|
|
||||||
if ($line === '') {
|
|
||||||
return ['version' => null, 'deployed_at' => null];
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = explode(' ', $line, 2);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'version' => $parts[0] !== '' ? $parts[0] : null,
|
|
||||||
'deployed_at' => isset($parts[1]) && $parts[1] !== '' ? $parts[1] : null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,9 @@ namespace App\Controllers;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
use App\Auth\Csrf;
|
use App\Auth\Csrf;
|
||||||
use App\Auth\LogMailer;
|
use App\Auth\LogMailer;
|
||||||
use App\Auth\Mailer;
|
|
||||||
use App\Auth\PasswordHasher;
|
use App\Auth\PasswordHasher;
|
||||||
use App\Auth\PasswordResetService;
|
use App\Auth\PasswordResetService;
|
||||||
use App\Auth\SessionManager;
|
use App\Auth\SessionManager;
|
||||||
use App\Auth\SmtpClient;
|
|
||||||
use App\Auth\SmtpMailer;
|
|
||||||
use App\Auth\StreamSmtpTransport;
|
|
||||||
use App\Core\Controller;
|
use App\Core\Controller;
|
||||||
use App\Core\Response;
|
use App\Core\Response;
|
||||||
|
|
||||||
|
|
@ -128,33 +124,7 @@ class PasswordResetController extends Controller
|
||||||
$this->database,
|
$this->database,
|
||||||
$this->config,
|
$this->config,
|
||||||
new PasswordHasher($this->config),
|
new PasswordHasher($this->config),
|
||||||
$this->mailer(),
|
new LogMailer(),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SMTP reel si configure (SMTP_HOST + SMTP_USER + SMTP_PASSWORD presents),
|
|
||||||
* sinon repli sur LogMailer (le lien est journalise, pas d'envoi) : le dev
|
|
||||||
* reste sans infra mail, la prod envoie via le relais.
|
|
||||||
*/
|
|
||||||
protected function mailer(): Mailer
|
|
||||||
{
|
|
||||||
$host = $this->config->get('SMTP_HOST');
|
|
||||||
$user = $this->config->get('SMTP_USER');
|
|
||||||
$password = $this->config->get('SMTP_PASSWORD');
|
|
||||||
|
|
||||||
if ($host === null || $user === null || $password === null) {
|
|
||||||
return new LogMailer();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new SmtpMailer(
|
|
||||||
new SmtpClient(new StreamSmtpTransport()),
|
|
||||||
$host,
|
|
||||||
(int) ($this->config->get('SMTP_PORT', '587') ?? '587'),
|
|
||||||
$user,
|
|
||||||
$password,
|
|
||||||
$this->config->get('MAIL_FROM_EMAIL', 'noreply@localhost') ?? 'noreply@localhost',
|
|
||||||
$this->config->get('MAIL_FROM_NAME', 'Wakdo') ?? 'Wakdo',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Support;
|
|
||||||
|
|
||||||
use App\Auth\SmtpTransport;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transport SMTP double : rejoue des reponses serveur scriptees et enregistre les
|
|
||||||
* ecritures du client, pour tester la logique du protocole sans reseau.
|
|
||||||
*/
|
|
||||||
final class FakeSmtpTransport implements SmtpTransport
|
|
||||||
{
|
|
||||||
/** @var list<string> ce que le client a ecrit, dans l'ordre */
|
|
||||||
public array $writes = [];
|
|
||||||
|
|
||||||
public bool $cryptoEnabled = false;
|
|
||||||
public bool $closed = false;
|
|
||||||
public bool $opened = false;
|
|
||||||
|
|
||||||
/** @var list<string> reponses a rendre, dans l'ordre des readReply() */
|
|
||||||
private array $replies;
|
|
||||||
|
|
||||||
/** @param list<string> $replies */
|
|
||||||
public function __construct(array $replies)
|
|
||||||
{
|
|
||||||
$this->replies = $replies;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function open(string $host, int $port, int $timeoutSeconds): void
|
|
||||||
{
|
|
||||||
$this->opened = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function write(string $raw): void
|
|
||||||
{
|
|
||||||
$this->writes[] = $raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function readReply(): string
|
|
||||||
{
|
|
||||||
if ($this->replies === []) {
|
|
||||||
throw new RuntimeException('FakeSmtpTransport : plus de reponse scriptee');
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_shift($this->replies);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function enableCrypto(): void
|
|
||||||
{
|
|
||||||
$this->cryptoEnabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function close(): void
|
|
||||||
{
|
|
||||||
$this->closed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Concatene toutes les ecritures (pratique pour assertions sur le message). */
|
|
||||||
public function written(): string
|
|
||||||
{
|
|
||||||
return implode('', $this->writes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Unit\Auth;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use App\Auth\SmtpClient;
|
|
||||||
use App\Tests\Support\FakeSmtpTransport;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
final class SmtpClientTest extends TestCase
|
|
||||||
{
|
|
||||||
/** @return list<string> sequence nominale de reponses serveur */
|
|
||||||
private function happyReplies(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
"220 smtp.brevo ready\r\n", // greeting
|
|
||||||
"250-smtp\r\n250 AUTH LOGIN\r\n", // EHLO (multiligne)
|
|
||||||
"220 go ahead\r\n", // STARTTLS
|
|
||||||
"250 ok\r\n", // EHLO post-TLS
|
|
||||||
"334 VXNlcm5hbWU6\r\n", // AUTH LOGIN
|
|
||||||
"334 UGFzc3dvcmQ6\r\n", // user
|
|
||||||
"235 authenticated\r\n", // password
|
|
||||||
"250 ok\r\n", // MAIL FROM
|
|
||||||
"250 ok\r\n", // RCPT TO
|
|
||||||
"354 end data with <CRLF>.<CRLF>\r\n", // DATA
|
|
||||||
"250 queued\r\n", // body
|
|
||||||
"221 bye\r\n", // QUIT
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testNominalConversationAuthenticatesAndSends(): void
|
|
||||||
{
|
|
||||||
$t = new FakeSmtpTransport($this->happyReplies());
|
|
||||||
$client = new SmtpClient($t);
|
|
||||||
|
|
||||||
$client->send('smtp-relay.brevo.com', 587, 'user@x', 'secret', 'from@a.fr', 'to@b.fr', "Subject: hi\r\n\r\ncorps");
|
|
||||||
|
|
||||||
self::assertTrue($t->opened);
|
|
||||||
self::assertTrue($t->cryptoEnabled, 'STARTTLS doit basculer le transport en TLS');
|
|
||||||
self::assertTrue($t->closed, 'le transport doit etre ferme');
|
|
||||||
|
|
||||||
$sent = $t->written();
|
|
||||||
self::assertStringContainsString("STARTTLS\r\n", $sent);
|
|
||||||
self::assertStringContainsString("AUTH LOGIN\r\n", $sent);
|
|
||||||
self::assertStringContainsString(base64_encode('user@x') . "\r\n", $sent);
|
|
||||||
self::assertStringContainsString(base64_encode('secret') . "\r\n", $sent);
|
|
||||||
self::assertStringContainsString("MAIL FROM:<from@a.fr>\r\n", $sent);
|
|
||||||
self::assertStringContainsString("RCPT TO:<to@b.fr>\r\n", $sent);
|
|
||||||
self::assertStringContainsString("DATA\r\n", $sent);
|
|
||||||
self::assertStringContainsString("\r\n.\r\n", $sent, 'le corps doit finir par le terminateur DATA');
|
|
||||||
self::assertStringContainsString("QUIT\r\n", $sent);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testReEhloHappensAfterStarttls(): void
|
|
||||||
{
|
|
||||||
$t = new FakeSmtpTransport($this->happyReplies());
|
|
||||||
(new SmtpClient($t))->send('h', 587, 'u', 'p', 'f@a.fr', 't@b.fr', "x");
|
|
||||||
|
|
||||||
// Deux EHLO : un avant STARTTLS, un apres (session repart a zero apres TLS).
|
|
||||||
$ehloCount = substr_count($t->written(), 'EHLO ');
|
|
||||||
self::assertSame(2, $ehloCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRejectedAuthThrowsAndCloses(): void
|
|
||||||
{
|
|
||||||
$replies = $this->happyReplies();
|
|
||||||
$replies[6] = "535 authentication failed\r\n"; // reponse au mot de passe
|
|
||||||
|
|
||||||
$t = new FakeSmtpTransport($replies);
|
|
||||||
$client = new SmtpClient($t);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$client->send('h', 587, 'u', 'bad', 'f@a.fr', 't@b.fr', 'x');
|
|
||||||
self::fail('une auth refusee doit lever');
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
self::assertStringContainsString('AUTH password', $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
self::assertTrue($t->closed, 'le transport doit etre ferme meme en cas d echec (finally)');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUnexpectedGreetingThrows(): void
|
|
||||||
{
|
|
||||||
$t = new FakeSmtpTransport(["554 service unavailable\r\n"]);
|
|
||||||
$this->expectException(RuntimeException::class);
|
|
||||||
(new SmtpClient($t))->send('h', 587, 'u', 'p', 'f@a.fr', 't@b.fr', 'x');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRejectsCrlfInRecipientBeforeConnecting(): void
|
|
||||||
{
|
|
||||||
// Tentative d'injection d'une commande RCPT via le destinataire.
|
|
||||||
$t = new FakeSmtpTransport($this->happyReplies());
|
|
||||||
$client = new SmtpClient($t);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$client->send('h', 587, 'u', 'p', 'f@a.fr', "t@b.fr>\r\nRCPT TO:<evil@x.com", 'x');
|
|
||||||
self::fail('un CRLF dans l adresse doit lever');
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
self::assertStringContainsString('destinataire', $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
self::assertFalse($t->opened, 'aucune connexion ne doit s ouvrir si l adresse est invalide');
|
|
||||||
self::assertSame([], $t->writes, 'rien ne doit etre emis');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Unit\Auth;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use App\Auth\SmtpClient;
|
|
||||||
use App\Auth\SmtpMailer;
|
|
||||||
use App\Tests\Support\FakeSmtpTransport;
|
|
||||||
|
|
||||||
final class SmtpMailerTest extends TestCase
|
|
||||||
{
|
|
||||||
/** @return list<string> sequence nominale de reponses serveur */
|
|
||||||
private function happyReplies(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
"220 ready\r\n", "250 ok\r\n", "220 go\r\n", "250 ok\r\n",
|
|
||||||
"334 u\r\n", "334 p\r\n", "235 ok\r\n", "250 ok\r\n", "250 ok\r\n",
|
|
||||||
"354 data\r\n", "250 queued\r\n", "221 bye\r\n",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function mailer(FakeSmtpTransport $t): SmtpMailer
|
|
||||||
{
|
|
||||||
return new SmtpMailer(
|
|
||||||
new SmtpClient($t),
|
|
||||||
'smtp-relay.brevo.com',
|
|
||||||
587,
|
|
||||||
'login@smtp-brevo.com',
|
|
||||||
'secret',
|
|
||||||
'noreply@a3n.fr',
|
|
||||||
'Wakdo',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testBuildsAndSendsResetMessage(): void
|
|
||||||
{
|
|
||||||
$t = new FakeSmtpTransport($this->happyReplies());
|
|
||||||
$this->mailer($t)->sendPasswordReset('client@example.fr', 'https://corentin-wakdo-admin.stark.a3n.fr/reset_password?token=abc');
|
|
||||||
|
|
||||||
$sent = $t->written();
|
|
||||||
self::assertStringContainsString('From: Wakdo <noreply@a3n.fr>', $sent);
|
|
||||||
self::assertStringContainsString('To: <client@example.fr>', $sent);
|
|
||||||
self::assertStringContainsString('Subject: Reinitialisation de votre mot de passe Wakdo', $sent);
|
|
||||||
self::assertStringContainsString('Content-Type: text/plain; charset=UTF-8', $sent);
|
|
||||||
self::assertStringContainsString('https://corentin-wakdo-admin.stark.a3n.fr/reset_password?token=abc', $sent);
|
|
||||||
// L'enveloppe SMTP doit porter l'expediteur et le destinataire reels.
|
|
||||||
self::assertStringContainsString('MAIL FROM:<noreply@a3n.fr>', $sent);
|
|
||||||
self::assertStringContainsString('RCPT TO:<client@example.fr>', $sent);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRejectsInvalidRecipient(): void
|
|
||||||
{
|
|
||||||
$t = new FakeSmtpTransport($this->happyReplies());
|
|
||||||
$this->expectException(\RuntimeException::class);
|
|
||||||
$this->mailer($t)->sendPasswordReset("victim@x.fr\r\nBcc: evil@x.com", 'https://x/reset?token=t');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testHeaderAndBodySeparatedByBlankLine(): void
|
|
||||||
{
|
|
||||||
$t = new FakeSmtpTransport($this->happyReplies());
|
|
||||||
$this->mailer($t)->sendPasswordReset('c@e.fr', 'https://x/reset?token=t');
|
|
||||||
|
|
||||||
// En-tetes et corps separes par une ligne vide (CRLF CRLF).
|
|
||||||
self::assertStringContainsString("Content-Transfer-Encoding: 8bit\r\n\r\nBonjour,", $t->written());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Unit\Controllers;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use App\Controllers\HealthController;
|
|
||||||
use App\Core\Config;
|
|
||||||
use App\Core\Database;
|
|
||||||
use App\Core\Request;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sous-classe de test : pointe le fichier VERSION sur une fixture temporaire,
|
|
||||||
* pour couvrir l'exposition de la version deployee sans dependre d'un deploiement
|
|
||||||
* reel (le fichier est ecrit par scripts/deploy.sh sur l'hote, jamais en test).
|
|
||||||
*/
|
|
||||||
final class TestHealthController extends HealthController
|
|
||||||
{
|
|
||||||
public string $versionPath = '';
|
|
||||||
|
|
||||||
protected function versionFilePath(): string
|
|
||||||
{
|
|
||||||
return $this->versionPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* La sonde expose la version deployee (SHA + date) pour prouver le CD : apres un
|
|
||||||
* deploiement, GET /api/health doit refleter le nouveau commit. Le test n'a pas de
|
|
||||||
* base : l'appel DB echoue et degrade le statut, mais les champs version restent
|
|
||||||
* presents (ils sont independants de la BDD).
|
|
||||||
*/
|
|
||||||
final class HealthControllerTest extends TestCase
|
|
||||||
{
|
|
||||||
private function controller(string $versionPath): TestHealthController
|
|
||||||
{
|
|
||||||
$request = new Request('GET', '/api/health', [], [], '', '203.0.113.5');
|
|
||||||
$c = new TestHealthController($request, new Config(), new Database(new Config()));
|
|
||||||
$c->versionPath = $versionPath;
|
|
||||||
|
|
||||||
return $c;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testExposesDeployedVersionWhenFilePresent(): void
|
|
||||||
{
|
|
||||||
$fixture = tempnam(sys_get_temp_dir(), 'wakdo_version_');
|
|
||||||
file_put_contents($fixture, "3dee190 2026-06-23T14:02:11+02:00\n");
|
|
||||||
|
|
||||||
try {
|
|
||||||
$body = $this->controller($fixture)->index()->body();
|
|
||||||
$payload = json_decode($body, true);
|
|
||||||
|
|
||||||
self::assertSame('3dee190', $payload['version']);
|
|
||||||
self::assertSame('2026-06-23T14:02:11+02:00', $payload['deployed_at']);
|
|
||||||
} finally {
|
|
||||||
@unlink($fixture);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testVersionNullWhenFileAbsent(): void
|
|
||||||
{
|
|
||||||
$missing = sys_get_temp_dir() . '/wakdo_version_does_not_exist_' . getmypid();
|
|
||||||
@unlink($missing);
|
|
||||||
|
|
||||||
$body = $this->controller($missing)->index()->body();
|
|
||||||
$payload = json_decode($body, true);
|
|
||||||
|
|
||||||
self::assertNull($payload['version']);
|
|
||||||
self::assertNull($payload['deployed_at']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue