diff --git a/.github/scripts/build-site.sh b/.github/scripts/build-site.sh new file mode 100755 index 0000000..f95e715 --- /dev/null +++ b/.github/scripts/build-site.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Construit le site statique complet dans _site/ : émet les credentials signés, +# la Bitstring Status List, le flux annuaire, puis assemble tout (clés, preuves, +# pages de vérif, code navigateur, photos). Partagé par les workflows Pages et VPS. +# +# Requiert : SIGNING_PRIVATE_KEY (env), un dépôt checkout avec historique + LFS, +# et les deps des scripts installées (.github/scripts). Ne fabrique PAS le CNAME +# (spécifique à Pages) : le workflow appelant l'ajoute si besoin. +set -euo pipefail +shopt -s nullglob + +echo "== Émission des credentials (déterministe) ==" +count=0 +for d in data/members/*/; do + f="${d}record.yml" + [ -f "$f" ] || continue + certified_on="$(git log --diff-filter=A --format=%aI -- "$f" | tail -1)" + if [ -z "$certified_on" ]; then + echo "::warning::pas de commit d'ajout pour $f, ignoré"; continue + fi + CERTIFIED_ON="$certified_on" node .github/scripts/sign-credential.mjs "$f" + handle="$(basename "$d")" + cp site/badge-page.html "u/$handle/index.html" + count=$((count + 1)) +done +echo "émis: $count credential(s)" + +echo "== Bitstring Status List ==" +STATUS_ISSUED_ON="$(git show -s --format=%aI HEAD)" node .github/scripts/sign-status-list.mjs + +echo "== Flux annuaire ==" +DIRECTORY_GENERATED_AT="$(git show -s --format=%aI HEAD)" node .github/scripts/build-directory.mjs + +echo "== Assemblage de _site ==" +mkdir -p _site/keys _site/u _site/status _site/photos +cp public/issuer.json _site/issuer.json +cp site/verify.mjs site/verify-page.mjs site/bitstring.mjs _site/ +cp site/verify.html _site/index.html +cp directory.json _site/directory.json +if [ -d keys ]; then cp keys/*.json _site/keys/ 2>/dev/null || true; fi +if [ -d u ]; then cp -r u/* _site/u/ 2>/dev/null || true; fi +for p in data/members/*/photo.webp; do + [ -f "$p" ] || continue + h="$(basename "$(dirname "$p")")"; cp "$p" "_site/photos/$h.webp" +done +if [ -f status/1 ]; then cp status/1 _site/status/1; fi +echo "== _site prêt ==" diff --git a/.github/workflows/deploy-vps.yml b/.github/workflows/deploy-vps.yml new file mode 100644 index 0000000..e7cb075 --- /dev/null +++ b/.github/workflows/deploy-vps.yml @@ -0,0 +1,101 @@ +# Déploiement sur le VPS (alternative à GitHub Pages). Calqué sur ai-driven-dev.fr : +# build+signe en cloud GitHub (la clé privée n'y touche jamais le VPS), image nginx +# statique poussée sur GHCR, puis le runner self-hosted fait `docker compose pull && up`. +# +# GATÉ (workflow_dispatch) tant que le VPS n'est pas câblé (voir docs/DEPLOY-VPS.md). +# Quand tout est prêt : ajouter le trigger `push` et retirer emit-and-deploy.yml (Pages). +name: Déployer sur le VPS + +on: + workflow_dispatch: + +permissions: read-all + +concurrency: + group: deploy-vps + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + name: Build + signe + push GHCR + runs-on: ubuntu-latest # GitHub-hosted : la clé privée reste dans le cloud GitHub + environment: signing # main uniquement, expose SIGNING_PRIVATE_KEY + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.meta.outputs.tags }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + lfs: true + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "20" + + - run: cd .github/scripts && npm ci + + - name: Construire le site (signe + assemble _site) + env: + SIGNING_PRIVATE_KEY: ${{ secrets.SIGNING_PRIVATE_KEY }} + run: bash .github/scripts/build-site.sh + + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,format=long + type=raw,value=latest,enable={{is_default_branch}} + + - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + deploy: + name: Déployer sur le VPS + needs: build-and-push + runs-on: [self-hosted, aidd-central] + permissions: + packages: read + environment: + name: production-vps + steps: + - name: Pull + restart le conteneur + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GHCR_USER: ${{ github.actor }} + DEPLOY_DIR: ${{ vars.DEPLOY_PATH || '/opt/infrastructure/services/aidd-badges' }} + CONTAINER: aidd-badges-verify + run: | + set -euo pipefail + cd "$DEPLOY_DIR" + echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin + docker compose pull + docker compose up -d --remove-orphans + for i in $(seq 1 20); do + S=$(docker inspect --format='{{.State.Health.Status}}' "$CONTAINER" 2>/dev/null || echo starting) + [ "$S" = "healthy" ] && { echo "healthy après ${i}x5s"; break; } + [ "$i" -eq 20 ] && { echo "PAS healthy"; docker logs --tail 50 "$CONTAINER"; exit 1; } + sleep 5 + done + IP=$(docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CONTAINER") + CODE=$(curl -sf -o /dev/null -w '%{http_code}' "http://${IP}:8080/issuer.json" || echo 000) + [ "$CODE" = "200" ] || { echo "smoke test HTTP $CODE"; exit 1; } + echo "Déployé, smoke test HTTP $CODE" + docker logout ghcr.io diff --git a/.github/workflows/emit-and-deploy.yml b/.github/workflows/emit-and-deploy.yml index 1d3e338..81cf859 100644 --- a/.github/workflows/emit-and-deploy.yml +++ b/.github/workflows/emit-and-deploy.yml @@ -19,6 +19,7 @@ on: - ".github/scripts/sign-credential.mjs" - ".github/scripts/sign-status-list.mjs" - ".github/scripts/build-directory.mjs" + - ".github/scripts/build-site.sh" - ".github/scripts/lib/**" - ".github/workflows/emit-and-deploy.yml" workflow_dispatch: @@ -47,53 +48,13 @@ jobs: - name: Installer les dépendances des scripts run: cd .github/scripts && npm ci - - name: Émettre les credentials (déterministe) + - name: Construire le site (signe + assemble _site) env: SIGNING_PRIVATE_KEY: ${{ secrets.SIGNING_PRIVATE_KEY }} - run: | - shopt -s nullglob - count=0 - for d in data/members/*/; do - f="${d}record.yml" - [ -f "$f" ] || continue - certified_on="$(git log --diff-filter=A --format=%aI -- "$f" | tail -1)" - if [ -z "$certified_on" ]; then - echo "::warning::pas de commit d'ajout pour $f, ignoré"; continue - fi - CERTIFIED_ON="$certified_on" node .github/scripts/sign-credential.mjs "$f" - handle="$(basename "$d")" - cp site/badge-page.html "u/$handle/index.html" # page de vérif du membre - count=$((count+1)) - done - echo "émis: $count credential(s)" + run: bash .github/scripts/build-site.sh - - name: Émettre la Bitstring Status List (révocation) - env: - SIGNING_PRIVATE_KEY: ${{ secrets.SIGNING_PRIVATE_KEY }} - run: | - STATUS_ISSUED_ON="$(git show -s --format=%aI HEAD)" \ - node .github/scripts/sign-status-list.mjs - - - name: Construire le flux annuaire (directory.json) - run: | - DIRECTORY_GENERATED_AT="$(git show -s --format=%aI HEAD)" \ - node .github/scripts/build-directory.mjs - - - name: Assembler le site statique - run: | - mkdir -p _site/keys _site/u _site/status _site/photos - cp public/issuer.json _site/issuer.json - cp site/verify.mjs site/verify-page.mjs site/bitstring.mjs _site/ # code de vérif (navigateur) - cp site/verify.html _site/index.html # page de vérif générique - cp directory.json _site/directory.json # flux annuaire (#33/#49) - if [ -d keys ]; then cp keys/*.json _site/keys/ 2>/dev/null || true; fi - if [ -d u ]; then cp -r u/* _site/u/ 2>/dev/null || true; fi - for p in data/members/*/photo.webp; do # publier la photo sous /photos/.webp - [ -f "$p" ] || continue - h="$(basename "$(dirname "$p")")"; cp "$p" "_site/photos/$h.webp" - done - if [ -f status/1 ]; then cp status/1 _site/status/1; fi - echo "verify.ai-driven-dev.fr" > _site/CNAME + - name: Fichier CNAME (spécifique à Pages) + run: echo "verify.ai-driven-dev.fr" > _site/CNAME - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8698539 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +# Sert le site de vérification statique (_site) via nginx. L'image est construite +# APRÈS la signature (en CI GitHub-hosted) : _site contient déjà les preuves signées, +# les clés publiques, la status list, l'annuaire et le code de vérif. Aucun secret ici. +FROM nginx:1.27-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY _site/ /usr/share/nginx/html/ + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/issuer.json || exit 1 + +EXPOSE 8080 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9cb8f68 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +# Sert le site de vérification sur le VPS. L'image est construite et poussée sur +# GHCR par la CI (après signature) ; le VPS ne fait que pull + up. Le reverse proxy +# de l'infra route verify.ai-driven-dev.fr vers le port publié ci-dessous. +services: + verify: + image: ${IMAGE:-ghcr.io/ai-driven-dev/badges:latest} + container_name: aidd-badges-verify + ports: + - "${PORT:-8092}:8080" + restart: unless-stopped + init: true + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8080/issuer.json"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 5s diff --git a/docs/DEPLOY-VPS.md b/docs/DEPLOY-VPS.md new file mode 100644 index 0000000..3c09d01 --- /dev/null +++ b/docs/DEPLOY-VPS.md @@ -0,0 +1,57 @@ +# Déploiement sur le VPS (au lieu de GitHub Pages) + +Aujourd'hui la vérif est servie par **GitHub Pages** (`emit-and-deploy.yml`). Cette +note décrit comment basculer sur le **VPS**, en calquant `ai-driven-dev.fr`. + +## Principe (sécurité) + +La **signature reste en cloud GitHub** : le job `build-and-push` tourne sur un runner +GitHub-hosted, dans l'environnement `signing`. La clé privée **ne touche jamais le VPS**. +Seule l'**image nginx** (sortie publique : `_site` signé) est poussée sur GHCR, puis le +runner self-hosted fait `docker compose pull && up`. GitHub n'est jamais sollicité par un +visiteur. + +``` +GitHub (build + signe) → image GHCR → VPS (docker compose) → verify.ai-driven-dev.fr +``` + +## Ce que le workflow fait déjà + +`deploy-vps.yml` (gaté `workflow_dispatch`) : +1. build + signe `_site`, construit l'image (Dockerfile nginx), pousse sur `ghcr.io/ai-driven-dev/badges` ; +2. sur le runner `[self-hosted, aidd-central]` : `docker compose pull && up`, healthcheck, smoke test. + +L'image sert sur le port **8080** ; `docker-compose.yml` le publie sur `${PORT:-8092}`. + +## Étapes côté VPS (à faire une fois) + +1. **Placer `docker-compose.yml`** du repo dans un dossier de déploiement, ex. + `/opt/infrastructure/services/aidd-badges/` (= la variable `DEPLOY_PATH`). +2. **Reverse proxy** : router `verify.ai-driven-dev.fr` → `http://127.0.0.1:` + (le port publié par le compose), avec TLS. Même mécanisme que pour le site principal. +3. **DNS** : `verify.ai-driven-dev.fr` → l'IP du VPS. +4. **Variables du repo** (Settings → Actions → Variables) si les défauts ne conviennent + pas : `DEPLOY_PATH`, `PORT`. +5. **Environnement `production-vps`** : créer l'environnement (protections au choix). + +## Basculer Pages → VPS + +Une fois le VPS servi et le DNS en place : +1. Dans `deploy-vps.yml`, ajouter le trigger `push` (branche `main`, mêmes `paths` que + `emit-and-deploy.yml`) en plus de `workflow_dispatch`. +2. Désactiver / supprimer `emit-and-deploy.yml` (Pages) et retirer le fichier `CNAME`. +3. Repointer le DNS de `verify.*` de Pages vers le VPS. + +Réversible : les badges référencent le **domaine**, pas l'hébergeur. Repasser sur Pages = +réactiver `emit-and-deploy.yml` et repointer le DNS. + +## Tester l'image en local + +```bash +# produire un _site de démo puis builder/lancer l'image +node .github/scripts/demo.mjs # écrit .demo-site (Ctrl+C après "servie") +cp -r .demo-site _site +docker build -t badges-verify . +docker run --rm -p 8092:8080 badges-verify +# -> http://localhost:8092/issuer.json, /u/demo, etc. +``` diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..307301b --- /dev/null +++ b/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Types corrects pour le code de vérif et les preuves. + types { + text/html html; + text/javascript mjs; + application/json json; + image/svg+xml svg; + image/webp webp; + } + # credential.jwt et status/1 : pas d'extension -> texte brut. + location ~ /credential\.jwt$ { default_type text/plain; } + location = /status/1 { default_type text/plain; } + + # CORS en lecture : la vérif indépendante et le site principal lisent ces fichiers. + add_header Access-Control-Allow-Origin "*" always; + + # Fiche de membre : /u/ -> /u//index.html + location / { + try_files $uri $uri/ $uri/index.html =404; + } +}