Compare commits
4 commits
main
...
fix/compos
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e39fb257b | ||
| ef71101453 | |||
| 80b8272291 | |||
| 8c5d942de8 |
18 changed files with 1177 additions and 13 deletions
13
.env.example
13
.env.example
|
|
@ -131,3 +131,16 @@ CRON_TIMEZONE=Europe/Paris
|
|||
# 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>`).
|
||||
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
|
||||
|
|
|
|||
75
.env.prod.example
Normal file
75
.env.prod.example
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# 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
|
||||
45
.forgejo/workflows/deploy.yml
Normal file
45
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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,6 +56,9 @@ Thumbs.db
|
|||
*.log
|
||||
/logs/
|
||||
|
||||
# === Marqueur de version (ecrit par scripts/deploy.sh sur l'hote, propre au deploiement) ===
|
||||
/src/VERSION
|
||||
|
||||
# === Data / Uploads / Backups ===
|
||||
# /var/ : contient /var/backups/ (bind-mount des dumps BDD du conteneur cron)
|
||||
# et tout futur artefact run-time (caches persistes, logs).
|
||||
|
|
|
|||
184
docker-compose.prod.yml.example
Normal file
184
docker-compose.prod.yml.example
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
# 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,6 +89,12 @@ services:
|
|||
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
|
||||
|
|
|
|||
115
docs/architecture/deployment.md
Normal file
115
docs/architecture/deployment.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# 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,25 +52,40 @@ if ! command -v docker >/dev/null 2>&1; then
|
|||
fi
|
||||
|
||||
echo "Deploiement Wakdo : branche '$BRANCH' depuis '$REMOTE' via $COMPOSE_FILE"
|
||||
printf 'Confirmer le deploiement en production ? [oui/NON] '
|
||||
read -r answer
|
||||
if [ "$answer" != "oui" ]; then
|
||||
echo "deploy: annule."
|
||||
exit 1
|
||||
# Mode non-interactif pour le CD : DEPLOY_YES=1 saute la confirmation (la forced
|
||||
# command SSH le pose). On NE lit PAS $SSH_ORIGINAL_COMMAND : la cle CI ne peut
|
||||
# influencer ni la branche ni le compose, seulement declencher CE script.
|
||||
if [ "${DEPLOY_YES:-}" = "1" ] || [ "${DEPLOY_YES:-}" = "oui" ]; then
|
||||
echo "deploy: confirmation automatique (DEPLOY_YES)."
|
||||
else
|
||||
printf 'Confirmer le deploiement en production ? [oui/NON] '
|
||||
read -r answer
|
||||
if [ "$answer" != "oui" ]; then
|
||||
echo "deploy: annule."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[1/4] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
|
||||
echo "[1/5] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
|
||||
git fetch --prune "$REMOTE" "$BRANCH"
|
||||
git checkout "$BRANCH"
|
||||
git merge --ff-only "$REMOTE/$BRANCH"
|
||||
|
||||
echo "[2/4] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)"
|
||||
echo "[2/5] marqueur de version (preuve CD cote app)"
|
||||
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
|
||||
|
||||
echo "[3/4] demarrage de la stack (migrate + seed idempotents puis app)"
|
||||
echo "[4/5] demarrage de la stack (migrate + seed idempotents puis app)"
|
||||
docker compose -f "$COMPOSE_FILE" up -d
|
||||
|
||||
echo "[4/4] etat des services"
|
||||
echo "[5/5] etat des services"
|
||||
docker compose -f "$COMPOSE_FILE" ps
|
||||
|
||||
echo "Deploiement termine."
|
||||
echo "Deploiement termine ($SHA)."
|
||||
|
|
|
|||
98
src/app/Auth/SmtpClient.php
Normal file
98
src/app/Auth/SmtpClient.php
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/app/Auth/SmtpMailer.php
Normal file
101
src/app/Auth/SmtpMailer.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
28
src/app/Auth/SmtpTransport.php
Normal file
28
src/app/Auth/SmtpTransport.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?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;
|
||||
}
|
||||
95
src/app/Auth/StreamSmtpTransport.php
Normal file
95
src/app/Auth/StreamSmtpTransport.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?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,9 +12,13 @@ use App\Core\Response;
|
|||
* Sonde de sante. GET /api/health.
|
||||
*
|
||||
* Le comptage des categories prouve la chaine complete (autoloader -> routeur
|
||||
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond.
|
||||
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond. Expose aussi
|
||||
* 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).
|
||||
*/
|
||||
final class HealthController extends Controller
|
||||
class HealthController extends Controller
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $params
|
||||
|
|
@ -35,6 +39,8 @@ final class HealthController extends Controller
|
|||
$httpStatus = 503;
|
||||
}
|
||||
|
||||
$version = $this->readVersion();
|
||||
|
||||
return $this->json(
|
||||
[
|
||||
'status' => $dbStatus === 'ok' ? 'ok' : 'degraded',
|
||||
|
|
@ -42,8 +48,45 @@ final class HealthController extends Controller
|
|||
'php_version' => PHP_VERSION,
|
||||
'db' => $dbStatus,
|
||||
'categories' => $categories,
|
||||
'version' => $version['version'],
|
||||
'deployed_at' => $version['deployed_at'],
|
||||
],
|
||||
$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,9 +7,13 @@ namespace App\Controllers;
|
|||
use Throwable;
|
||||
use App\Auth\Csrf;
|
||||
use App\Auth\LogMailer;
|
||||
use App\Auth\Mailer;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\PasswordResetService;
|
||||
use App\Auth\SessionManager;
|
||||
use App\Auth\SmtpClient;
|
||||
use App\Auth\SmtpMailer;
|
||||
use App\Auth\StreamSmtpTransport;
|
||||
use App\Core\Controller;
|
||||
use App\Core\Response;
|
||||
|
||||
|
|
@ -124,7 +128,33 @@ class PasswordResetController extends Controller
|
|||
$this->database,
|
||||
$this->config,
|
||||
new PasswordHasher($this->config),
|
||||
new LogMailer(),
|
||||
$this->mailer(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
66
tests/Support/FakeSmtpTransport.php
Normal file
66
tests/Support/FakeSmtpTransport.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
107
tests/Unit/Auth/SmtpClientTest.php
Normal file
107
tests/Unit/Auth/SmtpClientTest.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
68
tests/Unit/Auth/SmtpMailerTest.php
Normal file
68
tests/Unit/Auth/SmtpMailerTest.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?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());
|
||||
}
|
||||
}
|
||||
72
tests/Unit/Controllers/HealthControllerTest.php
Normal file
72
tests/Unit/Controllers/HealthControllerTest.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?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