From 0ae2db3d8cd38fbbfd8a2d7f99847209482568bd Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Wed, 1 Jul 2026 12:34:39 +0300 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20webhook=20auto-deploy=20Forgejo?= =?UTF-8?q?=20=E2=86=92=20VPS=20Paris=20(TD-04)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/deploy.sh | 98 ++++++++++++++++++++++++ deploy/expria-deploy.service | 34 +++++++++ deploy/webhook-listener.mjs | 140 +++++++++++++++++++++++++++++++++++ docs/ARCHITECTURE-backend.md | 40 +++++----- docs/CHANGELOG-backend.md | 16 ++++ docs/TECH_DEBT-backend.md | 42 +++++++---- 6 files changed, 333 insertions(+), 37 deletions(-) create mode 100644 deploy/deploy.sh create mode 100644 deploy/expria-deploy.service create mode 100644 deploy/webhook-listener.mjs diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..ce10991 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Expria auto-deploy script (VPS Paris). +# +# Runs as the non-root `deploy` user, invoked by deploy/webhook-listener.mjs on +# a verified push to origin/main. Fast-forwards the checkout, installs, builds, +# and restarts expria-backend.service via ONE restricted sudo rule: +# deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart expria-backend.service +# +# On a build or health-check failure it auto-rolls back to the previous commit. +# All output goes to stdout -> journald (journalctl -u expria-deploy). +set -euo pipefail + +REPO_DIR="/opt/expria/expria-backend" +BRANCH="main" +SERVICE="expria-backend.service" +HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:4000/}" +LOCK_FILE="/tmp/expria-deploy.lock" + +log() { echo "$(date --iso-8601=seconds) [deploy] $*"; } + +# Serialize concurrent deploys: a second webhook waits here, or bails out. +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + log "another deploy is already in progress — aborting this run" + exit 0 +fi + +cd "$REPO_DIR" + +PREV="$(git rev-parse HEAD)" +log "start (current=${PREV:0:8})" + +restart_backend() { + sudo /usr/bin/systemctl restart "$SERVICE" +} + +# Poll the liveness endpoint (GET / always returns 200 when the process is up). +health_check() { + for _ in $(seq 1 10); do + if curl -fsS --max-time 3 "$HEALTH_URL" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + return 1 +} + +rollback() { + log "ROLLBACK to ${PREV:0:8}" + if git reset --hard "$PREV" && npm ci && npm run build; then + if restart_backend && health_check; then + log "ROLLBACK ok — service healthy on ${PREV:0:8}" + return 0 + fi + fi + log "ROLLBACK FAILED — manual intervention required (was on ${PREV:0:8})" + return 1 +} + +# 1. Fetch + fast-forward only (refuses a diverged history, no destructive pull). +git fetch --prune origin "$BRANCH" +if ! git merge --ff-only "origin/$BRANCH"; then + log "fast-forward failed (diverged history) — aborting, no changes applied" + exit 1 +fi +NEW="$(git rev-parse HEAD)" +log "pulled ${PREV:0:8} -> ${NEW:0:8}" + +if [ "$PREV" = "$NEW" ]; then + log "already up to date — nothing to deploy" + exit 0 +fi + +# 2. Install + build (rollback on failure). +if ! npm ci; then + log "npm ci FAILED" + rollback + exit 1 +fi +if ! npm run build; then + log "npm run build FAILED" + rollback + exit 1 +fi + +# 3. Restart + health check (rollback on failure). +if ! restart_backend; then + log "systemctl restart FAILED" + rollback + exit 1 +fi +if health_check; then + log "SUCCESS — deployed ${NEW:0:8}" +else + log "health check FAILED after restart" + rollback + exit 1 +fi diff --git a/deploy/expria-deploy.service b/deploy/expria-deploy.service new file mode 100644 index 0000000..b903810 --- /dev/null +++ b/deploy/expria-deploy.service @@ -0,0 +1,34 @@ +# Expria auto-deploy webhook listener — systemd unit (VPS Paris). +# +# DISTINCT from expria-backend.service: this runs the webhook listener, not the +# API. Keeping them separate is required — a deploy restarts expria-backend, so +# the listener must NOT be a child of it or the deploy would kill itself. +# +# Install (ops step, after CP1 validation): +# sudo cp deploy/expria-deploy.service /etc/systemd/system/expria-deploy.service +# sudo systemctl daemon-reload +# sudo systemctl enable --now expria-deploy +# Logs: +# journalctl -u expria-deploy -f + +[Unit] +Description=Expria auto-deploy webhook listener (Forgejo push -> deploy) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=deploy +Group=deploy +WorkingDirectory=/opt/expria/expria-backend/deploy +EnvironmentFile=/etc/expria/webhook.env +ExecStart=/usr/bin/node /opt/expria/expria-backend/deploy/webhook-listener.mjs +Restart=always +RestartSec=5 +# NOTE: NoNewPrivileges MUST stay false (default) — deploy.sh relies on sudo for +# the single restricted rule: systemctl restart expria-backend.service. +# ProtectSystem=strict is intentionally NOT set: deploy.sh writes the checkout +# under /opt/expria/expria-backend (git pull, npm ci, build). + +[Install] +WantedBy=multi-user.target diff --git a/deploy/webhook-listener.mjs b/deploy/webhook-listener.mjs new file mode 100644 index 0000000..762ad3e --- /dev/null +++ b/deploy/webhook-listener.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +// Expria auto-deploy webhook listener (VPS Paris). +// +// Zero external dependencies: Node stdlib only. Runs as the non-root `deploy` +// user under systemd (deploy/expria-deploy.service), bound to 127.0.0.1 only. +// Caddy (deploy.expria.app) terminates TLS + filters source IP, then proxies +// here. Forgejo "push" events are authenticated by HMAC-SHA256 over the raw +// body (header X-Gitea-Signature) using a DEDICATED secret (never the git token). +// +// On a verified push to refs/heads/main, it spawns deploy/deploy.sh. Concurrent +// deploys are serialized by flock inside deploy.sh. + +import http from "node:http"; +import crypto from "node:crypto"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const HOST = "127.0.0.1"; +const PORT = Number(process.env.WEBHOOK_PORT) || 9000; +const HOOK_PATH = process.env.WEBHOOK_PATH || "/hooks/deploy"; +const TARGET_REF = "refs/heads/main"; +const MAX_BODY = 5 * 1024 * 1024; // 5 MB — generous for a push payload + +const SECRET = process.env.WEBHOOK_SECRET; +if (!SECRET || SECRET.length < 16) { + console.error( + "[webhook] WEBHOOK_SECRET missing or too short (<16 chars) — refusing to start", + ); + process.exit(1); +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DEPLOY_SCRIPT = path.join(__dirname, "deploy.sh"); + +let deployRunning = false; + +function log(...args) { + console.log(new Date().toISOString(), "[webhook]", ...args); +} + +// Constant-time HMAC comparison. Never logs the secret or the signature. +function verifySignature(rawBody, headerSig) { + if (!headerSig || typeof headerSig !== "string") return false; + const expected = crypto + .createHmac("sha256", SECRET) + .update(rawBody) + .digest("hex"); + let a; + let b; + try { + a = Buffer.from(expected, "hex"); + b = Buffer.from(headerSig, "hex"); + } catch { + return false; + } + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} + +function runDeploy(triggerSha) { + deployRunning = true; + log("deploy launch", triggerSha ? `after=${triggerSha.slice(0, 8)}` : ""); + const child = spawn("/bin/bash", [DEPLOY_SCRIPT], { + cwd: __dirname, + env: { ...process.env }, + stdio: ["ignore", "inherit", "inherit"], // deploy.sh logs go to journald + }); + child.on("exit", (code) => { + deployRunning = false; + log("deploy finished exit=" + code); + }); + child.on("error", (err) => { + deployRunning = false; + log("deploy spawn error:", err.message); + }); +} + +const server = http.createServer((req, res) => { + if (req.method !== "POST" || req.url !== HOOK_PATH) { + res.writeHead(404).end("not found\n"); + return; + } + + const chunks = []; + let size = 0; + let aborted = false; + + req.on("data", (chunk) => { + size += chunk.length; + if (size > MAX_BODY) { + aborted = true; + res.writeHead(413).end("payload too large\n"); + req.destroy(); + return; + } + chunks.push(chunk); + }); + + req.on("end", () => { + if (aborted) return; + const rawBody = Buffer.concat(chunks); + const sig = + req.headers["x-gitea-signature"] || req.headers["x-forgejo-signature"]; + + if (!verifySignature(rawBody, sig)) { + log("rejected: invalid signature from", req.socket.remoteAddress); + res.writeHead(401).end("unauthorized\n"); + return; + } + + let payload; + try { + payload = JSON.parse(rawBody.toString("utf8")); + } catch { + res.writeHead(400).end("bad json\n"); + return; + } + + if (payload.ref !== TARGET_REF) { + log("ignored ref", payload.ref); + res.writeHead(200).end("ignored (not main)\n"); + return; + } + + if (deployRunning) { + log("deploy already running — new run will queue on flock"); + } + res.writeHead(202).end("accepted\n"); + runDeploy(payload.after); + }); + + req.on("error", () => { + if (!res.headersSent) res.writeHead(400).end(); + }); +}); + +server.listen(PORT, HOST, () => { + log(`listening on ${HOST}:${PORT}${HOOK_PATH}`); +}); diff --git a/docs/ARCHITECTURE-backend.md b/docs/ARCHITECTURE-backend.md index 2bcfdd5..251eba1 100644 --- a/docs/ARCHITECTURE-backend.md +++ b/docs/ARCHITECTURE-backend.md @@ -24,9 +24,9 @@ Utilisateur (navigateur) ┌─────────────▼───────────────┐ │ BACKEND │ │ Hono.js (Node.js) │ -│ Render (Frankfurt) │ +│ VPS Paris (systemd + Caddy)│ │ — toutes les routes API │ -│ — WebSocket proxy T2 EO │ +│ — WebSocket proxy T1/T2 EO │ └──────┬──────────────┬───────┘ │ │ ┌──────▼──────┐ ┌────▼────────────────┐ @@ -433,17 +433,17 @@ NODE_ENV=production ## 9. Déploiement -### Hébergement Git — GitHub +### Hébergement Git — Forgejo (auto-hébergé) -- Plateforme : github.com -- Dépôt frontend : `https://github.com/germannoff/expria-frontend` -- Dépôt backend : `https://github.com/germannoff/expria-backend` -- Note : compte GitHub réactivé le 17 avril 2026 après restriction OFAC levée -- Auto-deploy : disponible via Render (connecté à GitHub) +- Plateforme : Forgejo (fork Gitea) auto-hébergé sur le VPS Moscou (`157.22.190.91`), exposé via `git.expria.app` (proxifié par un Caddy Moscou). +- Dépôt frontend : `https://git.expria.app/hermann/expria-frontend` +- Dépôt backend : `https://git.expria.app/hermann/expria-backend` +- Push : HTTPS + token Forgejo (branche `main`). Un miroir SSH historique (`ssh://157.22.190.91:2222`) peut exister mais n'est pas la voie de référence. +- Note : migration depuis GitHub actée pour s'affranchir des restrictions OFAC et reprendre la main sur l'hébergement Git. ### Frontend — Cloudflare Pages -- Source : dépôt GitHub `expria-frontend` +- Source : dépôt Forgejo `expria-frontend` - Build command : `npm run build` - Output directory : `dist` - Domaine : `expria.app` (DNS pointé depuis Vercel vers Cloudflare Pages) @@ -455,17 +455,14 @@ npm run build npx wrangler pages deploy dist --project-name=expria ``` -### Backend — Render +### Backend — VPS Paris (HostVDS) -- Source : dépôt GitHub `expria-backend` -- Type : Web Service (Node.js) -- Région : Frankfurt (EU) — proximité utilisateurs Afrique du Nord -- Build command : `npm run build` -- Start command : `npm start` -- Domaine : `api.expria.app` (certificat SSL actif) -- URL Render : `https://expria-backend.onrender.com` (alias) -- WebSocket : activé nativement sur Render -- Déploiement : **automatique à chaque push sur main (GitHub → Render)** +- Hébergement : VPS **HostVDS Paris**, IP `95.182.84.3`. +- Source : dépôt Forgejo `expria-backend`, checkout dans `/opt/expria/expria-backend`. +- Runtime : Node.js (v20.x) géré par **systemd** — unité `expria-backend.service` (`ExecStart=/usr/bin/node dist/index.js`, `Restart=always`). Le process écoute sur `localhost:4000`. +- Reverse proxy : **Caddy** (v2) sert `api.expria.app` en TLS (Let's Encrypt forcé, `acme_ca` production) et proxifie vers `localhost:4000`. WebSocket (T1/T2 Live) supporté nativement par Caddy. +- Proxy Gemini : **Cloudflare WARP** tourne en mode SOCKS5 (`socks5://127.0.0.1:40000`) sur le VPS ; **seul** le trafic WebSocket Gemini Live y est routé (via `GEMINI_PROXY_URL`, cf. §8) car l'IP du datacenter est bloquée par Google. Supabase, DeepSeek, Stripe et les clients restent en connexion directe. +- **Auto-deploy (webhook Forgejo)** : un push sur `main` déclenche un webhook Forgejo vers `deploy.expria.app` (sous-domaine dédié, proxifié par Caddy vers un listener local `127.0.0.1:9000`). Le listener (`deploy/webhook-listener.mjs`, Node stdlib) vérifie la signature **HMAC-SHA256** (`X-Gitea-Signature`) et l'IP source (allowlist `157.22.190.91`), puis lance `deploy/deploy.sh` (git fast-forward → `npm ci` → `npm run build` → `systemctl restart expria-backend`, avec **rollback automatique** si le build ou le health-check échoue). Listener isolé sous l'utilisateur non-root `deploy` (unité `expria-deploy.service`). ### Base de données — Supabase @@ -478,8 +475,9 @@ npx wrangler pages deploy dist --project-name=expria ``` 1. Tester localement (npm run test — tous les tests verts) 2. Rejouer le Golden Dataset -3. Commit + push sur GitHub (branche main) -4. Backend : auto-deploy Render déclenché automatiquement +3. Commit + push sur Forgejo (branche main) +4. Backend : auto-deploy déclenché par le webhook Forgejo → VPS Paris + (git pull → npm ci → build → restart systemd, rollback auto si échec) 5. Déployer le frontend : npm run build && npx wrangler pages deploy dist 6. Vérifier les URLs de production (expria.app + api.expria.app) 7. Rejouer le Smoke Test (Groupe Z du Golden Dataset) diff --git a/docs/CHANGELOG-backend.md b/docs/CHANGELOG-backend.md index d1546d7..d39cc53 100644 --- a/docs/CHANGELOG-backend.md +++ b/docs/CHANGELOG-backend.md @@ -6,6 +6,22 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/). --- +## [Unreleased] — 2026-07-01 — Auto-deploy par webhook Forgejo (VPS Paris) + +### Added + +- `deploy/webhook-listener.mjs` — listener HTTP **Node stdlib pur** (aucune dépendance externe), bindé sur `127.0.0.1:9000`, path `POST /hooks/deploy`. Vérifie la signature **HMAC-SHA256** du corps brut (header `X-Gitea-Signature`, fallback `X-Forgejo-Signature`) via `crypto.timingSafeEqual`, refuse de démarrer sans `WEBHOOK_SECRET`, ne traite que `ref === refs/heads/main`, cape le body à 5 Mo, ne logge jamais le secret ni la signature. Lance `deploy.sh` sans bloquer la réponse. +- `deploy/deploy.sh` — script de déploiement (`set -euo pipefail`) : verrou `flock` (deploys sérialisés), `git fetch` + `git merge --ff-only origin/main` (refuse un historique divergé), `npm ci`, `npm run build`, `sudo systemctl restart expria-backend.service` (unique commande privilégiée), health-check `GET http://127.0.0.1:4000/`. **Rollback automatique** (`git reset --hard` vers le commit précédent + rebuild + restart) si le build ou le health-check échoue. +- `deploy/expria-deploy.service` — unité systemd du listener, **distincte** de `expria-backend.service`, sous l'utilisateur non-root `deploy`, secret injecté via `EnvironmentFile=/etc/expria/webhook.env`. + +### Notes + +- Exposition : sous-domaine dédié `deploy.expria.app` proxifié par Caddy vers `127.0.0.1:9000`, avec **allowlist IP** sur l'egress Forgejo Moscou (`157.22.190.91`) + signature HMAC = double garde. +- Sécurité : le listener tourne sous `deploy` (non-root) avec une règle sudoers restreinte à la seule ligne `systemctl restart expria-backend.service`. Secret dédié (≠ token git), hors dépôt, jamais loggé. +- Résout **TD-04** (déploiement manuel). Étapes ops VPS (création user `deploy`, sudoers, DNS `deploy.expria.app`, bloc Caddy, secret, `systemctl enable --now expria-deploy`, config webhook Forgejo) documentées et exécutées hors code. + +--- + ## [Unreleased] — 2026-06-30 — Proxy SOCKS5 (Cloudflare WARP) pour les WS Gemini Live ### Added diff --git a/docs/TECH_DEBT-backend.md b/docs/TECH_DEBT-backend.md index 29746fb..8661a1c 100644 --- a/docs/TECH_DEBT-backend.md +++ b/docs/TECH_DEBT-backend.md @@ -44,13 +44,22 @@ ## 2. Décisions pragmatiques — à revisiter -### TD-04 — Déploiement manuel (frontend + backend) +### TD-04 — Déploiement manuel (backend) **Priorité :** 🟢 Mineur -**Statut :** Ouvert — accepté jusqu'aux premiers revenus -**Description :** Cloudflare Pages et Render ne supportent pas l'auto-deploy depuis Codeberg. Le déploiement est manuel (CLI + dashboard). -**À faire :** Migrer vers VPS Hetzner + Coolify pour restaurer l'auto-deploy. Voir ARCHITECTURE.md §9 Phase 2. -**Condition de résolution :** Quand Expria génère ses premiers revenus réguliers. +**Statut :** Résolu — Auto-deploy webhook Forgejo → VPS Paris (2026-07-01) +**Description :** Le déploiement backend était manuel (git pull + build + restart à la main sur le VPS). Résolu par un **webhook auto-deploy custom** : un push sur `main` du dépôt Forgejo (`git.expria.app`) déclenche un webhook vers `deploy.expria.app`, reçu par un listener **Node stdlib** (`deploy/webhook-listener.mjs`) qui vérifie la signature HMAC-SHA256 + l'IP source, puis exécute `deploy/deploy.sh` (fast-forward → `npm ci` → `npm run build` → `systemctl restart expria-backend`, avec rollback automatique sur échec). Le listener tourne sous l'utilisateur non-root `deploy` via `expria-deploy.service`. Solution retenue plutôt que la cible historique « VPS Hetzner + Coolify » (jamais implémentée) : le backend est déjà sur VPS Paris (HostVDS) avec systemd + Caddy, un webhook léger suffit. +**Note frontend :** le déploiement frontend (Cloudflare Pages CLI) reste manuel — hors périmètre de cette résolution. + +--- + +### TD-26 — `expria-backend.service` tourne en root + +**Priorité :** 🟢 Mineur +**Statut :** Ouvert — introduit/constaté au Sprint auto-deploy (2026-07-01) +**Description :** L'unité systemd `expria-backend.service` sur le VPS Paris définit `User=root`. Le process API (Hono, WebSocket) s'exécute donc avec les pleins privilèges, ce qui n'est pas nécessaire et augmente la surface d'impact en cas de compromission du runtime. +**À faire :** Migrer le service vers un utilisateur dédié non-root (ex. `expria`), ajuster les permissions du checkout `/opt/expria/expria-backend`, du port (< 1024 non requis, le service écoute sur 4000) et du `EnvironmentFile`. Le listener auto-deploy tourne déjà sous `deploy` (non-root) — le backend applicatif doit suivre. +**Hors scope :** ce durcissement est indépendant de la mise en place du webhook et sera planifié ultérieurement. --- @@ -323,14 +332,15 @@ live** apparaîtra (ex. T3 Live). ## 5. Historique des résolutions -| ID | Description | Résolu le | Comment | -| ----- | ------------------------------------------- | ---------- | --------------------------- | -| TD-02 | planController.ts complété | 2026-04-16 | Session Stripe | -| TD-03 | stripe.ts complété | 2026-04-16 | Session Stripe | -| TD-14 | Erreurs TS2835 + TS18046 + TS7053 corrigées | 2026-04-17 | Session build Render | -| TD-10 | Analyse des patterns (Premium) livrée | 2026-04-25 | Sprint 3.6c | -| TD-11 | Indice de préparation livré | 2026-04-25 | Sprint 3.6c | -| TD-16 | Bucket Storage abandonné | 2026-04-25 | Sprint 4b — Deepgram direct | -| TD-17 | Limite audio in-memory caduque | 2026-04-25 | Sprint 4b | -| TD-18 | RLS Storage caduque | 2026-04-25 | Sprint 4b | -| TD-13 | Webhook Stripe idempotent | 2026-04-26 | Sprint 5a | +| ID | Description | Résolu le | Comment | +| ----- | ------------------------------------------- | ---------- | ----------------------------- | +| TD-02 | planController.ts complété | 2026-04-16 | Session Stripe | +| TD-03 | stripe.ts complété | 2026-04-16 | Session Stripe | +| TD-14 | Erreurs TS2835 + TS18046 + TS7053 corrigées | 2026-04-17 | Session build Render | +| TD-10 | Analyse des patterns (Premium) livrée | 2026-04-25 | Sprint 3.6c | +| TD-11 | Indice de préparation livré | 2026-04-25 | Sprint 3.6c | +| TD-16 | Bucket Storage abandonné | 2026-04-25 | Sprint 4b — Deepgram direct | +| TD-17 | Limite audio in-memory caduque | 2026-04-25 | Sprint 4b | +| TD-18 | RLS Storage caduque | 2026-04-25 | Sprint 4b | +| TD-13 | Webhook Stripe idempotent | 2026-04-26 | Sprint 5a | +| TD-04 | Auto-deploy webhook Forgejo → VPS Paris | 2026-07-01 | Node stdlib + systemd + Caddy |