diff --git a/.env.example b/.env.example index 84a4309..bd13698 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,8 @@ # L'app REFUSE de démarrer si elle est vide, trop courte (< 32 caractères) ou # égale à un placeholder connu. Générez une valeur forte : # openssl rand -hex 32 +# À SAUVEGARDER avec la base : un dump restauré avec un autre SESSION_SECRET rend +# tous les secrets stockés indéchiffrables (voir install/backup.sh). SESSION_SECRET= # Identifiants MySQL. Choisissez des mots de passe FORTS (ne laissez pas de @@ -27,13 +29,71 @@ DB_PASSWORD= DB_ROOT_PASSWORD= DB_NAME=goacloud +# ---------------------------------------------------------------------------- +# RECOMMANDÉ — secrets par fichier (convention Docker « _FILE ») +# ---------------------------------------------------------------------------- +# Tout secret listé ci-dessous accepte une variante _FILE qui pointe vers un +# fichier : GoaCore lit alors le contenu du fichier (retour à la ligne final +# ignoré) au lieu de la variable d'environnement. C'est ce qu'il faut faire dès +# que d'autres personnes ont accès au démon Docker : une variable d'environnement +# est lisible par `docker inspect` et dans /proc//environ, un fichier monté +# depuis un secret Docker/Swarm ou un tmpfs ne l'est pas. +# +# Secrets concernés : SESSION_SECRET, DB_PASS (hôte : DB_PASSWORD), +# PROXMOX_TOKEN_SECRET, WAZUH_PASSWORD, WAZUH_INDEXER_PASSWORD, AI_API_KEY, +# DISCORD_BOT_TOKEN. +# +# Le _FILE l'emporte sur la variable simple, et un fichier absent ou vide +# REFUSE le démarrage (pas de retour silencieux à une valeur par défaut). +# +# Exemple : décommentez le montage « ./secrets:/run/secrets:ro » dans +# docker-compose.yml, puis : +# mkdir -p secrets && openssl rand -hex 32 > secrets/session_secret +# chmod 600 secrets/* +# SESSION_SECRET_FILE=/run/secrets/session_secret +# DB_PASSWORD_FILE=/run/secrets/db_password +# +# SESSION_SECRET_FILE= +# DB_PASSWORD_FILE= +# PROXMOX_TOKEN_SECRET_FILE= +# WAZUH_PASSWORD_FILE= +# WAZUH_INDEXER_PASSWORD_FILE= +# AI_API_KEY_FILE= +# DISCORD_BOT_TOKEN_FILE= +# DB_ROOT_PASSWORD_FILE= # conteneur MySQL : EXCLUSIF de DB_ROOT_PASSWORD + # ---------------------------------------------------------------------------- # OPTIONNEL — serveur / TLS (defaults sains, à ne modifier qu'au besoin) # ---------------------------------------------------------------------------- +# Noms DNS et IP couverts par le certificat auto-signé (séparés par des virgules). +# À RENSEIGNER : sans eux, le certificat ne vaut que pour localhost/127.0.0.1 et +# le navigateur affiche une erreur de certificat à chaque accès. Mettez ici le nom +# (et/ou l'IP) par lequel vous ouvrez GoaCore. +# TLS_HOSTS=core.example.com,192.0.2.10 +TLS_HOSTS= + +# Le certificat auto-signé est PERSISTÉ (volume goacore_tls monté sur /app/certs) +# et n'est régénéré que s'il est absent, expiré, ou s'il ne couvre plus TLS_HOSTS : +# son empreinte ne change donc plus à chaque mise à jour. Emplacement modifiable : +# TLS_DIR=certs + +# Fournir SON PROPRE certificat (Let's Encrypt, PKI interne…) : renseignez les +# DEUX variables et montez les fichiers en lecture seule dans le conteneur. +# GoaCore ne les écrase jamais et refuse de démarrer s'ils sont illisibles. +# TLS_CERT_FILE=/run/tls/fullchain.pem +# TLS_KEY_FILE=/run/tls/privkey.pem + # Cookies de session marqués Secure (HTTPS). Laisser "true" en production. # COOKIE_SECURE=true +# Reverse proxy de confiance (CIDR séparés par des virgules). Laisser VIDE si +# GoaCore est joint directement : X-Forwarded-For n'est alors pas cru, ce qui +# empêche un attaquant de forger son IP pour contourner le blocage anti-force +# brute. À renseigner uniquement derrière un proxy, avec SON adresse et rien de +# plus large — sinon n'importe qui peut de nouveau forger l'en-tête. +# TRUSTED_PROXIES=192.0.2.1/32 + # Désactive la vérification du certificat TLS pour les appels SORTANTS vers # Proxmox / Wazuh (utile si ces services ont un certificat auto-signé). # À utiliser en connaissance de cause. diff --git a/.github/workflows/ci-deploy.yml b/.github/workflows/ci-deploy.yml index 3cad780..9212261 100644 --- a/.github/workflows/ci-deploy.yml +++ b/.github/workflows/ci-deploy.yml @@ -1,37 +1,160 @@ name: CI & Deploy +# SÉCURITÉ — le dépôt est PUBLIC et le runner `self-hosted` vit sur le LAN de +# l'auteur (socket Docker, coffre de secrets, accès aux services internes). Le code +# d'une Pull Request — potentiellement issue d'un fork inconnu, cf. CONTRIBUTING.md — +# ne doit JAMAIS s'y exécuter. Tous les jobs déclenchés par une PR tournent donc sur +# un runner GitHub hébergé et jetable ; seuls les `push` sur dev/main (du code déjà +# mergé, donc relu) atteignent le self-hosted. +# +# Corollaire : aucune valeur contrôlée par un tiers (titre/corps de PR, nom de +# branche, acteur, message de commit) n'est interpolée directement dans un `run:` — +# l'expansion `${{ }}` est textuelle et précède l'analyse par bash, donc un titre de +# PR bien choisi refermerait la chaîne et exécuterait sa charge. Ces valeurs passent +# exclusivement par des variables d'environnement, citées et assainies. + on: push: branches: [dev, main] pull_request: branches: [main] +# Moindre privilège par défaut ; les jobs qui écrivent (release, publish) le +# redemandent explicitement. +permissions: + contents: read + jobs: build: name: Build & Test - runs-on: self-hosted + # PR (code non relu) -> runner jetable GitHub ; push interne -> self-hosted. + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'self-hosted' }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-go@v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - # Suit go.mod plutot qu'une version figee : le pin a 1.22 faisait - # echouer toute montee de dependance exigeant un Go plus recent - # (GOTOOLCHAIN=local interdit de telecharger un toolchain). + # Suit la directive `go` de go.mod plutôt qu'une version figée ici : une + # version épinglée en dur diverge silencieusement du module (le runner + # télécharge alors le toolchain manquant et la CI ne teste plus ce que + # l'image construit, qui est en GOTOOLCHAIN=local). go-version-file: go.mod - name: Download dependencies run: go mod download + # CONTRIBUTING.md annonce `gofmt -l .` comme faisant partie de la CI : cette + # étape rend l'affirmation vraie. gofmt n'ayant pas de code de sortie d'échec, + # on transforme sa sortie en échec explicite. + - name: Format (gofmt) + run: | + UNFORMATTED=$(gofmt -l .) + if [ -n "$UNFORMATTED" ]; then + echo "::error::Fichiers non formatés (lancer 'gofmt -w') :" + echo "$UNFORMATTED" + exit 1 + fi + - name: Vet run: go vet ./... - name: Test run: go test ./... + # Étape BLOQUANTE. La dette est résorbée (x/crypto v0.52.0, chi v5.2.2, + # toolchain go1.25.12) : `govulncheck ./...` ne remonte plus aucune + # vulnérabilité effectivement appelée. La garder non bloquante reviendrait à + # laisser la prochaine repasser inaperçue. + - name: Vulnérabilités (govulncheck) + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + "$(go env GOPATH)/bin/govulncheck" ./... + - name: Build run: CGO_ENABLED=0 go build -o bin/goacloud ./cmd/server + # Construit réellement l'image (sans la pousser). `go build` seul ne dit rien + # du Dockerfile : c'est cette étape qui attrape une incohérence entre le + # builder épinglé et la directive `go` de go.mod, ou une étape de build CSS + # cassée — deux pannes qui, sinon, ne se voient qu'au déploiement. + - name: Build de l'image conteneur + run: docker build -t goacore:ci . + + # Détecteur de course, dans un job SÉPARÉ et sur un runner GitHub. `-race` exige + # cgo, donc un compilateur C : le runner self-hosted n'en a pas (CGO_ENABLED + # retombe à 0 et `go test -race` refuse de démarrer). Plutôt que d'installer une + # chaîne C sur le CT du runner pour un job qui n'a besoin d'aucun accès au LAN, + # on l'exécute là où gcc est fourni. Les déploiements en dépendent : une data + # race doit bloquer la mise en production, pas seulement teinter un log. + race: + name: Détecteur de course + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Test (race detector) + env: + CGO_ENABLED: "1" + run: go test ./... -race + + # Migrations de schéma sur une VRAIE base. Le job `build` ci-dessus n'exerce que + # la logique pure (classification d'erreurs, plan d'index) : les tests qui + # rejouent la montée de version d'une base cliente existante — schéma d'avant le + # versionnement, ancienne clé unique, doublons de VMID — étaient `t.Skip` faute de + # DSN, donc jamais exécutés. C'est pourtant le chemin le plus risqué du produit : + # il tourne chez le client, sans surveillance, au premier démarrage après une mise + # à jour, sur des données qu'on ne peut pas recréer. + # + # TOUJOURS sur un runner GitHub jetable, PR comme push (contrairement à `build` + # qui bascule sur le self-hosted) : `services:` publie 3306 sur l'hôte du runner, + # et le self-hosted fait tourner de vraies bases. On ne lui pose pas un MySQL de + # test sur un port qu'il utilise peut-être déjà, pour un job qui n'a besoin + # d'aucun accès au LAN. Le code d'une PR de fork n'y est donc jamais un problème + # non plus (cf. bandeau sécurité en tête de fichier). + db-tests: + name: Migrations (MySQL réel) + runs-on: ubuntu-latest + services: + mysql: + # Tag et non digest, à la différence de docker-compose.yml : ici on veut + # justement tester contre le MySQL 8.0 courant, pas contre une version + # figée. La prod, elle, reste épinglée. + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: goacore-ci + MYSQL_DATABASE: goacore_test + ports: + - 3306:3306 + # Sans health-check, les étapes démarrent avant que MySQL n'accepte les + # connexions et le job échoue au premier Ping. + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -u root -pgoacore-ci --silent" + --health-interval=5s + --health-timeout=5s + --health-retries=20 + --health-start-period=20s + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + # -count=1 : jamais de résultat mis en cache pour un test dont tout l'intérêt + # est de toucher un serveur externe. + # GOACORE_REQUIRE_DB_TESTS : transforme un DSN manquant en ÉCHEC au lieu d'un + # skip. Sans ce verrou, retirer le service MySQL ci-dessus repasserait la CI + # au vert avec zéro test exécuté — exactement l'angle mort qu'on répare ici. + - name: Migrations sur une base MySQL réelle + env: + GOACORE_TEST_DSN: root:goacore-ci@tcp(127.0.0.1:3306)/goacore_test?parseTime=true + GOACORE_REQUIRE_DB_TESTS: "1" + run: go test ./internal/database/ -race -count=1 -v + # The goabackup SSH helper is the AUTHORITATIVE security boundary on the Proxmox # host (forced-command, closed op set, the readonly 9500-9599 sandbox VMID guard). # The Go anti-drift test (TestSandboxVMIDRangeMatchesHelper, run in the build job) @@ -43,9 +166,11 @@ jobs: # NEVER touches a real host. helper: name: Validate goabackup helper - runs-on: self-hosted + # `test-runner.sh` vient de la PR : il ne s'exécute jamais sur le self-hosted + # pour une PR (cf. bandeau sécurité en tête de fichier). + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'self-hosted' }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Bash syntax (bash -n) run: bash -n deploy/goabackup/goabackup-runner.sh @@ -63,7 +188,7 @@ jobs: deploy-dev: name: Deploy DEV - needs: [build, helper] + needs: [build, race, helper, db-tests] runs-on: self-hosted if: github.ref == 'refs/heads/dev' && github.event_name == 'push' steps: @@ -75,22 +200,51 @@ jobs: docker rmi goacloud-dev-app 2>/dev/null || true docker builder prune -af 2>/dev/null || true + # Un webhook révoqué ou un hôte à court de disque répond 4xx/5xx : sans + # vérification du code HTTP la CI resterait verte alors que rien n'est déployé. - name: Trigger Portainer (DEV) + env: + WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK_URL_DEV }} run: | - curl -sk -X POST "${{ secrets.PORTAINER_WEBHOOK_URL_DEV }}" \ - -H "Content-Type: application/json" -d '{}' + BODY=$(mktemp) + CODE=$(curl -sk -o "$BODY" -w '%{http_code}' --max-time 300 \ + -X POST "$WEBHOOK" -H "Content-Type: application/json" -d '{}') + if [ "$CODE" -lt 200 ] || [ "$CODE" -ge 300 ]; then + echo "::error::Webhook Portainer DEV : HTTP $CODE — déploiement NON effectué" + head -c 500 "$BODY" + exit 1 + fi + echo "Webhook Portainer DEV accepté (HTTP $CODE)" + + - name: Vérifier que DEV répond + env: + PROBE_URL: ${{ vars.DEV_PROBE_URL || 'https://devcore.goacloud.fr/' }} + run: | + for i in $(seq 1 30); do + CODE=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 5 "$PROBE_URL" || echo 000) + if [ "$CODE" -ge 200 ] && [ "$CODE" -lt 400 ]; then + echo "DEV répond (HTTP $CODE) après $i tentative(s)" + exit 0 + fi + sleep 5 + done + echo "::error::DEV ne répond pas après 150s (dernier code : $CODE)" + exit 1 - name: Notify Discord env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} COMMIT_MSG: ${{ github.event.head_commit.message }} + ACTOR: ${{ github.actor }} run: | - SAFE_MSG=$(echo "$COMMIT_MSG" | head -1 | tr -d '"\\' | cut -c1-100) + export LC_ALL=C.UTF-8 + SAFE_MSG=$(printf '%s' "$COMMIT_MSG" | head -1 | tr -d '"\\' | tr -d '\000-\037' | cut -c1-100) + SAFE_ACTOR=$(printf '%s' "$ACTOR" | tr -cd 'A-Za-z0-9._-' | cut -c1-64) SHORT="${GITHUB_SHA:0:7}" curl -sf -H "Content-Type: application/json" -X POST "$DISCORD_WEBHOOK" -d "{ \"embeds\": [{ \"title\": \"GoaCore DEV - Deploy OK\", - \"description\": \"**Commit:** \`$SHORT\`\\n**Auteur:** ${{ github.actor }}\\n**Message:** $SAFE_MSG\\n\\n:globe_with_meridians: https://devcore.goacloud.fr\", + \"description\": \"**Commit:** \`$SHORT\`\\n**Auteur:** $SAFE_ACTOR\\n**Message:** $SAFE_MSG\\n\\n:globe_with_meridians: https://devcore.goacloud.fr\", \"color\": 3447003, \"footer\": {\"text\": \"Branche dev | Self-hosted runner\"} }] @@ -98,11 +252,11 @@ jobs: deploy-prod: name: Deploy PROD - needs: [build, helper] + needs: [build, race, helper, db-tests] runs-on: self-hosted if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # The Portainer webhook re-pulls the repo then runs compose pull -> build -> up. # The app service is build-only (no pinned `image:` registry tag — see @@ -110,22 +264,56 @@ jobs: # abort that previously killed every prod redeploy), Portainer rebuilds it from # source and `up -d` recreates the container with the new code. The db container # and ALL named volumes (MySQL data) are preserved. No pre-build / no rm needed. + # + # Le code HTTP est vérifié : un webhook révoqué ou un hôte plein renvoie 5xx et + # doit faire échouer le job. Sans ça la CI reste verte, Discord annonce + # « Deploy OK », la version est taguée — et la prod tourne sur l'ancienne image. - name: Trigger Portainer (PROD) + env: + WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK_URL_PROD }} run: | - curl -sk -X POST "${{ secrets.PORTAINER_WEBHOOK_URL_PROD }}" \ - -H "Content-Type: application/json" -d '{}' + BODY=$(mktemp) + CODE=$(curl -sk -o "$BODY" -w '%{http_code}' --max-time 300 \ + -X POST "$WEBHOOK" -H "Content-Type: application/json" -d '{}') + if [ "$CODE" -lt 200 ] || [ "$CODE" -ge 300 ]; then + echo "::error::Webhook Portainer PROD : HTTP $CODE — déploiement NON effectué" + head -c 500 "$BODY" + exit 1 + fi + echo "Webhook Portainer PROD accepté (HTTP $CODE)" + + # Le webhook est asynchrone : un 2xx signifie « pris en compte », pas + # « en ligne ». On sonde l'application jusqu'à ce qu'elle réponde (2xx/3xx — + # `/` redirige vers /login quand la session est absente). + - name: Vérifier que PROD répond + env: + PROBE_URL: ${{ vars.PROD_PROBE_URL || 'https://core.goacloud.fr/' }} + run: | + for i in $(seq 1 30); do + CODE=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 5 "$PROBE_URL" || echo 000) + if [ "$CODE" -ge 200 ] && [ "$CODE" -lt 400 ]; then + echo "PROD répond (HTTP $CODE) après $i tentative(s)" + exit 0 + fi + sleep 5 + done + echo "::error::PROD ne répond pas après 150s (dernier code : $CODE)" + exit 1 - name: Notify Discord env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} COMMIT_MSG: ${{ github.event.head_commit.message }} + ACTOR: ${{ github.actor }} run: | - SAFE_MSG=$(echo "$COMMIT_MSG" | head -1 | tr -d '"\\' | cut -c1-100) + export LC_ALL=C.UTF-8 + SAFE_MSG=$(printf '%s' "$COMMIT_MSG" | head -1 | tr -d '"\\' | tr -d '\000-\037' | cut -c1-100) + SAFE_ACTOR=$(printf '%s' "$ACTOR" | tr -cd 'A-Za-z0-9._-' | cut -c1-64) SHORT="${GITHUB_SHA:0:7}" curl -sf -H "Content-Type: application/json" -X POST "$DISCORD_WEBHOOK" -d "{ \"embeds\": [{ \"title\": \"GoaCore PROD - Deploy OK\", - \"description\": \"**Commit:** \`$SHORT\`\\n**Auteur:** ${{ github.actor }}\\n**Message:** $SAFE_MSG\\n\\n:globe_with_meridians: https://core.goacloud.fr\", + \"description\": \"**Commit:** \`$SHORT\`\\n**Auteur:** $SAFE_ACTOR\\n**Message:** $SAFE_MSG\\n\\n:globe_with_meridians: https://core.goacloud.fr\", \"color\": 3066993, \"footer\": {\"text\": \"Branche main | Self-hosted runner\"} }] @@ -138,12 +326,19 @@ jobs: if: github.ref == 'refs/heads/main' && github.event_name == 'push' permissions: contents: write + outputs: + # Consommés par le job `publish` : un tag poussé avec GITHUB_TOKEN ne + # redéclenche AUCUN workflow (garde anti-boucle GitHub), donc publish-image.yml + # ne se réveille pas tout seul — c'est pour ça qu'on chaîne la publication ici. + tag: ${{ steps.release.outputs.tag }} + created: ${{ steps.release.outputs.created }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Determine version bump and create release + id: release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} @@ -197,6 +392,7 @@ jobs: # Skip if tag already exists if git rev-parse "$NEW_TAG" >/dev/null 2>&1; then echo "Tag $NEW_TAG already exists, skipping release" + echo "created=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -234,6 +430,12 @@ jobs: echo "Release $NEW_TAG created successfully" + # Passe la main au job `publish` (image GHCR). + { + echo "tag=$NEW_TAG" + echo "created=true" + } >> "$GITHUB_OUTPUT" + # Notify Discord RELEASE_URL="https://github.com/${REPO}/releases/tag/${NEW_TAG}" if [ -n "$DISCORD_WEBHOOK" ]; then @@ -273,28 +475,100 @@ jobs: }" || true fi + # Publication de l'image publique ghcr.io/goacloud/goacore DANS LE MÊME RUN que la + # release. Le tag créé ci-dessus l'est avec GITHUB_TOKEN : GitHub ne redéclenche + # aucun workflow sur un événement produit par GITHUB_TOKEN, donc publish-image.yml + # (sur `push: tags`) ne partait jamais — l'image GHCR était figée plusieurs semaines + # derrière le dépôt alors que le README promet une image publiée à chaque version. + # publish-image.yml reste utile pour republier un tag à la main (workflow_dispatch). + publish: + name: Publish image (GHCR) + needs: release + if: needs.release.outputs.created == 'true' + # Runner GitHub : buildx + QEMU y sont disponibles (build multi-arch), et le + # self-hosted n'a pas à porter ce build. + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout (tag de la release) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.release.outputs.tag }} + + - name: Set up QEMU (émulation arm64) + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - name: Set up Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Login GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # `value=` est explicite : le run tourne sur un ref de branche (main), pas sur + # le ref du tag, donc `type=semver` ne peut pas le déduire seul. `latest` est + # légitime ici : on vient tout juste de tagger la release depuis main. + - name: Métadonnées (tags + labels) + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + with: + images: ghcr.io/goacloud/goacore + tags: | + type=semver,pattern={{version}},value=${{ needs.release.outputs.tag }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.release.outputs.tag }} + type=raw,value=latest + + - name: Build & push (amd64 + arm64) + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + notify-pr: name: PR Check - needs: [build, helper] - runs-on: self-hosted - if: github.event_name == 'pull_request' + needs: [build, race, helper, db-tests] + runs-on: ubuntu-latest + # Une PR de fork n'a de toute façon pas accès aux secrets : le webhook serait + # vide et l'étape échouerait bruyamment pour rien. On ne notifie que les PR + # internes. + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository steps: - name: Notify Discord env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} + # Valeurs contrôlées par l'auteur de la PR : jamais interpolées dans le + # script, uniquement lues (et assainies) depuis l'environnement. + PR_TITLE: ${{ github.event.pull_request.title }} + PR_NUMBER: ${{ github.event.pull_request.number }} + ACTOR: ${{ github.actor }} run: | - # Les PR Dependabot s'executent avec le store de secrets Dependabot, - # pas celui du repo : DISCORD_WEBHOOK arrive vide et `curl -sf` sort - # en 3, ce qui faisait echouer PR Check sur un build par ailleurs vert. - # Meme garde que le job de release plus haut dans ce fichier. + # Les PR Dependabot s'exécutent avec le magasin de secrets Dependabot, + # pas celui du dépôt : DISCORD_WEBHOOK arrive vide et `curl -sf` sort en 3, + # ce qui faisait échouer PR Check sur un build par ailleurs vert. if [ -z "$DISCORD_WEBHOOK" ]; then - echo "DISCORD_WEBHOOK absent (PR Dependabot) - notification ignoree" + echo "DISCORD_WEBHOOK absent (PR Dependabot) — notification ignorée" exit 0 fi + export LC_ALL=C.UTF-8 + SAFE_TITLE=$(printf '%s' "$PR_TITLE" | tr -d '"\\' | tr -d '\000-\037' | cut -c1-120) + SAFE_ACTOR=$(printf '%s' "$ACTOR" | tr -cd 'A-Za-z0-9._-' | cut -c1-64) + SAFE_NUMBER=$(printf '%s' "$PR_NUMBER" | tr -cd '0-9' | cut -c1-12) curl -sf -H "Content-Type: application/json" -X POST "$DISCORD_WEBHOOK" -d "{ \"embeds\": [{ - \"title\": \"GoaCloud - PR #${{ github.event.pull_request.number }} prete\", - \"description\": \"**PR:** ${{ github.event.pull_request.title }}\\n**Auteur:** ${{ github.actor }}\\n\\nBuild OK, pret a merge dans main (PROD).\", + \"title\": \"GoaCloud - PR #${SAFE_NUMBER} prete\", + \"description\": \"**PR:** $SAFE_TITLE\\n**Auteur:** $SAFE_ACTOR\\n\\nBuild OK, pret a merge dans main (PROD).\", \"color\": 15844367, \"footer\": {\"text\": \"Pull Request dev -> main\"} }] diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 734dcb8..bcb1b59 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -1,8 +1,16 @@ name: Publish image (GHCR) -# Publie l'image Docker publique ghcr.io/abrahamop/goacore pour faciliter -# l'installation (docker compose up sans build). Déclenché par un tag de version -# (vX.Y.Z) ou manuellement. Indépendant du CI de déploiement maison (ci-deploy.yml). +# Publie l'image Docker publique ghcr.io/goacloud/goacore pour faciliter +# l'installation (docker compose up sans build). +# +# CHEMIN NOMINAL : la publication est chaînée à la release, dans ci-deploy.yml +# (job `publish`, `needs: release`). Un tag poussé avec GITHUB_TOKEN ne redéclenche +# aucun workflow — ce fichier ne se réveillait donc jamais sur les auto-releases, +# et l'image GHCR est restée des semaines derrière le dépôt. +# +# CE WORKFLOW EST LE FILET DE SECOURS : republier un tag à la main +# (workflow_dispatch en se plaçant sur le tag), ou publier un tag poussé +# manuellement (PAT / clé de déploiement), hors du chemin d'auto-release. on: push: tags: @@ -18,16 +26,53 @@ jobs: runs-on: ubuntu-latest # runner GitHub (gratuit sur repo public) : buildx + QEMU dispo steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # historique + tags requis par la vérification `latest` ci-dessous + + # `latest` doit désigner la dernière version publiée de `main`, et rien + # d'autre. Auparavant il était posé inconditionnellement : un + # workflow_dispatch lancé depuis un vieux tag faisait RECULER `latest`, et + # chaque nouvel installateur suivant le README récupérait une version + # périmée. On ne le pose donc que si le ref est bien un tag de version, + # atteignable depuis `main`, et que c'est le plus récent de `main`. + - name: Faut-il poser le tag `latest` ? + id: latest + run: | + if [ "$GITHUB_REF_TYPE" != "tag" ]; then + echo "Ref '$GITHUB_REF_NAME' n'est pas un tag → pas de 'latest'" + echo "value=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + # En cas de doute (fetch impossible, tag hors de main), on s'abstient : + # ne pas poser 'latest' est toujours moins grave que le faire reculer. + if ! git fetch --quiet --tags origin '+refs/heads/main:refs/remotes/origin/main'; then + echo "::warning::Impossible de récupérer main → pas de 'latest'" + echo "value=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if ! git merge-base --is-ancestor "$GITHUB_REF_NAME" origin/main; then + echo "Tag $GITHUB_REF_NAME absent de main → pas de 'latest'" + echo "value=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + NEWEST=$(git tag --list 'v*.*.*' --merged origin/main --sort=-v:refname | head -1) + if [ "$NEWEST" = "$GITHUB_REF_NAME" ]; then + echo "$GITHUB_REF_NAME est la dernière version de main → 'latest' posé" + echo "value=true" >> "$GITHUB_OUTPUT" + else + echo "Version plus récente sur main ($NEWEST) → pas de 'latest'" + echo "value=false" >> "$GITHUB_OUTPUT" + fi - name: Set up QEMU (émulation arm64) - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - name: Set up Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Login GHCR - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -35,16 +80,16 @@ jobs: - name: Métadonnées (tags + labels) id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: - images: ghcr.io/abrahamop/goacore + images: ghcr.io/goacloud/goacore tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest + type=raw,value=latest,enable=${{ steps.latest.outputs.value == 'true' }} - name: Build & push (amd64 + arm64) - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c52d093..d14eee9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,15 +112,28 @@ npx tailwindcss -i ./tailwind.input.css -o ./assets/ --watch ## Lancer les tests Le projet est testé via le toolchain Go standard. Avant toute Pull Request, -assurez-vous que les trois commandes suivantes passent — ce sont celles qu'exécute -la CI : +assurez-vous que les commandes suivantes passent — ce sont celles qu'exécute la +CI, et elles y sont bloquantes : ```bash -gofmt -l . # ne doit rien afficher (sinon : gofmt -w ) -go vet ./... # analyse statique -go test ./... # suite de tests +gofmt -l . # ne doit rien afficher (sinon : gofmt -w ) +go vet ./... # analyse statique +go test ./... # suite de tests +go test ./... -race # détecteur de data races (workers, scheduler, caches) +go build ./... # compilation ``` +La CI lance en plus [`govulncheck`](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck), +qui signale les vulnérabilités connues **réellement appelées** par le code : + +```bash +go run golang.org/x/vuln/cmd/govulncheck@latest ./... +``` + +> Cette étape est temporairement non bloquante, le temps de résorber la dette de +> dépendances existante. Ne l'aggravez pas : une PR ne doit pas introduire de +> nouvelle vulnérabilité appelée. + Les tests couvrent notamment la configuration, les middlewares (rate-limit, onboarding gate), les workers et le moteur de sauvegarde/restauration. Ajoutez des tests pour tout nouveau comportement, et veillez à ne pas casser le @@ -175,15 +188,20 @@ docs(license): AGPL-3.0 + README finalisé 1. **Forkez** le dépôt (ou créez une branche si vous avez les droits). 2. Branchez-vous depuis `dev` : `git switch dev && git switch -c feat/ma-feature`. 3. Codez, en ajoutant/maintenant les tests. -4. Vérifiez localement : `gofmt -l . && go vet ./... && go test ./...`. +4. Vérifiez localement : `gofmt -l . && go vet ./... && go test ./... -race`. 5. Committez en Conventional Commits. 6. Poussez et **ouvrez la PR vers `dev`** (ou `main` si justifié). 7. Dans la description, expliquez **le pourquoi** du changement, ce que vous avez testé, et tout impact sur la configuration (nouvelle variable d'env, migration de schéma `schema.sql`, etc.). -La CI (`go vet`, `go test`, build, validation du helper `goabackup`) doit être -verte avant relecture. Gardez les PR ciblées et de taille raisonnable : une PR = -un sujet. +La CI (`gofmt`, `go vet`, `go test` avec et sans `-race`, build, `govulncheck`, +validation du helper `goabackup`) doit être verte avant relecture. Gardez les PR +ciblées et de taille raisonnable : une PR = un sujet. + +> Les jobs déclenchés par une Pull Request s'exécutent sur un runner GitHub +> hébergé et jetable, sans accès aux secrets du dépôt : c'est normal qu'une PR +> issue d'un fork n'obtienne ni notification Discord ni déploiement. Seuls les +> `push` sur `dev`/`main`, après relecture et merge, déclenchent le déploiement. Merci pour votre contribution. diff --git a/Dockerfile b/Dockerfile index bd9092f..b778dc5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,20 @@ +# Images de base épinglées PAR DIGEST : un tag comme `golang:alpine` ou +# `alpine:3.21` est mouvant, donc deux builds du même commit ne produisent pas le +# même binaire et une régression de base est indétectable. Le tag lisible reste en +# commentaire pour savoir ce qu'on épingle. +# +# Mise à jour du digest (à faire consciemment, jamais automatiquement) : +# docker buildx imagetools inspect alpine:3.21 --format '{{.Manifest.Digest}}' +# # Build Stage — exécuté sur l'arch NATIVE du runner, cross-compile le binaire Go vers # l'arch cible (TARGETOS/TARGETARCH injectés par buildx). Go cross-compile sans # émulation : les builds multi-arch (amd64/arm64) restent rapides. -FROM --platform=$BUILDPLATFORM golang:alpine AS builder +# golang:1.25-alpine (Go 1.25.12). Le digest DOIT porter un Go >= à la directive +# `toolchain` de go.mod : les images officielles fixent GOTOOLCHAIN=local, donc un +# builder trop ancien échoue au lieu de télécharger le toolchain manquant. +# 1.25.12 est aussi le patch qui corrige les vulnérabilités de la bibliothèque +# standard encore remontées par govulncheck sur les versions antérieures. +FROM --platform=$BUILDPLATFORM golang@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS builder WORKDIR /app @@ -33,32 +46,79 @@ ARG TARGETARCH RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} go build -o goacloud ./cmd/server # Final Stage -FROM alpine:3.24 +# alpine:3.24 — version reprise de main (bump Dependabot), mais épinglée par +# digest comme le reste : un tag reste mouvant, et deux builds du même commit +# doivent produire le même binaire. +FROM alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b WORKDIR /app -# Ajout des certificats CA, timezone, ansible et client ssh -RUN apk --no-cache add ca-certificates tzdata ansible openssh +# ca-certificates + tzdata : appels sortants HTTPS et horodatage local. +# +# ansible : REQUIS par le module d'automatisation (services.RunPlaybook exécute +# ansible-playbook). Il faut en connaître le prix : il tire un interpréteur Python +# complet et plusieurs centaines de modules dans l'image d'une application web. +# C'est, de loin, la plus grosse surface d'attaque du conteneur — un module Ansible +# est du code Python exécuté localement avec les droits de l'app, et Python donne +# accès à tout ce que la Go stdlib n'exposait pas. Les garde-fous qui la bornent : +# - conteneur non-root, rootfs en lecture seule et toutes capacités retirées +# (voir docker-compose.yml : read_only / cap_drop / no-new-privileges) ; +# - seuls les playbooks de /app/playbooks sont exécutables (contrôle de chemin +# côté handlers), sur des cibles dont la clé d'hôte est épinglée en base. +# Si l'automatisation Ansible n'est pas utilisée, la bonne réponse est de rebâtir +# l'image sans cette dépendance, pas de la « durcir » davantage. +# +# openssh-client (et NON openssh) : Ansible n'a besoin que du client ssh. Le +# paquet complet embarquerait aussi sshd, c'est-à-dire un serveur SSH inutile dans +# l'image d'une application web. +RUN apk --no-cache add ca-certificates tzdata ansible openssh-client # Create non-root user RUN addgroup -g 1000 goacloud && adduser -D -u 1000 -G goacloud goacloud # Copie du binaire depuis le builder COPY --from=builder /app/goacloud . -COPY playbooks/ ./playbooks/ +# Les playbooks livrés vont dans un répertoire de RÉFÉRENCE, pas directement dans +# /app/playbooks : ce dernier est un volume, et un volume masque le contenu de +# l'image. L'entrypoint les recopie au démarrage (voir plus bas). +COPY playbooks/ ./playbooks-dist/ COPY ansible.cfg ./ -# Ensure app user owns the working directory -RUN chown -R goacloud:goacloud /app +# Répertoires que l'application écrit à l'exécution, créés ici pour qu'ils existent +# et appartiennent à l'app même quand un volume vide est monté dessus : +# certs/ → certificat TLS auto-signé PERSISTÉ (cf. internal/server/tls.go) +# playbooks/→ playbooks livrés (synchronisés au boot) + ceux de l'utilisateur +RUN mkdir -p /app/certs /app/playbooks && chown -R goacloud:goacloud /app && chmod 700 /app/certs -USER goacloud +# Entrypoint : resynchronise le catalogue de playbooks livré vers le volume, puis +# exec le binaire (voir docker-entrypoint.sh pour le détail et le compromis). +COPY docker-entrypoint.sh /usr/local/bin/goacore-entrypoint.sh +RUN chmod 555 /usr/local/bin/goacore-entrypoint.sh -# Exposition du port -EXPOSE 8080 +USER goacloud -# Health check -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD wget -qO /dev/null https://localhost:8443/login --no-check-certificate || exit 1 +# Rootfs en lecture seule (docker-compose.yml) : tout ce qu'Ansible écrit doit +# aller dans le tmpfs /tmp, pas dans ~/.ansible. +ENV ANSIBLE_HOME=/tmp/.ansible + +# Port réellement servi : HTTPS 8443 (l'ancien EXPOSE 8080 ne correspondait à rien +# de publié). 8080 n'est que la redirection HTTP→HTTPS interne, non exposée. +EXPOSE 8443 + +# Health check — sonde /readyz, PAS une page de l'interface. +# /login répond 200 tant que le processus vit, base injoignable comprise : le +# conteneur restait vert pendant une panne totale de MySQL, c'est-à-dire pendant +# que toutes les pages utiles renvoyaient 500. /readyz renvoie 503 dans ce cas +# (cf. internal/handlers/health.go), donc l'état du conteneur dit enfin quelque +# chose d'exploitable — et c'est bien la READINESS qu'on veut ici : Docker ne +# redémarre pas un conteneur « unhealthy », il le signale (et un orchestrateur ou +# un proxy le sort de la rotation). +# TLS auto-signé sur 8443 : --no-check-certificate reste indispensable. +# start-period plus longue que pour /login : /readyz n'est vert qu'une fois la +# connexion à la base établie, ce qui suit le démarrage de MySQL. +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD wget -qO /dev/null https://localhost:8443/readyz --no-check-certificate || exit 1 # Commande de démarrage +ENTRYPOINT ["/usr/local/bin/goacore-entrypoint.sh"] CMD ["./goacloud"] diff --git a/README.md b/README.md index c7db9d6..42154dc 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![Go](https://img.shields.io/badge/Go-1.22-00ADD8?logo=go&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?logo=docker&logoColor=white) -[![Image GHCR](https://img.shields.io/badge/ghcr.io-goacore-2496ED?logo=docker&logoColor=white)](https://github.com/AbrahamOP/GoaCore/pkgs/container/goacore) +[![Image GHCR](https://img.shields.io/badge/ghcr.io-goacore-2496ED?logo=docker&logoColor=white)](https://github.com/GoaCloud/GoaCore/pkgs/container/goacore) ![Statut](https://img.shields.io/badge/Statut-pré--1.0-orange) ![License](https://img.shields.io/badge/License-AGPL--3.0-blue) [![Discord](https://img.shields.io/badge/Discord-Rejoindre%20la%20communauté-5865F2?logo=discord&logoColor=white)](https://discord.gg/pSW7kxJSjf) @@ -98,15 +98,15 @@ l'image publique multi-arch (amd64 / arm64) est tirée depuis GitHub Container R ```bash mkdir goacore && cd goacore -curl -O https://raw.githubusercontent.com/AbrahamOP/GoaCore/main/install/docker-compose.yml -curl -o .env https://raw.githubusercontent.com/AbrahamOP/GoaCore/main/install/.env.example +curl -O https://raw.githubusercontent.com/GoaCloud/GoaCore/main/install/docker-compose.yml +curl -o .env https://raw.githubusercontent.com/GoaCloud/GoaCore/main/install/.env.example # Éditez .env : générez SESSION_SECRET (openssl rand -hex 32) + des mots de passe MySQL forts docker compose up -d ``` Ouvrez ensuite `https://:8443/setup` pour créer le compte administrateur. Proxmox, Wazuh, IA et Discord sont optionnels et se configurent depuis l'interface. L'image est -publiée sur [`ghcr.io/abrahamop/goacore`](https://github.com/AbrahamOP/GoaCore/pkgs/container/goacore) +publiée sur [`ghcr.io/goacloud/goacore`](https://github.com/GoaCloud/GoaCore/pkgs/container/goacore) à chaque version (`vX.Y.Z`) ; épinglez une version via `GOACORE_TAG` dans `.env`. --- diff --git a/ROADMAP.md b/ROADMAP.md index 7dc5ee3..d2cd258 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -71,7 +71,7 @@ GoaCore est aujourd'hui un dashboard Go (chi / MySQL) très fonctionnel mais **c ### Jalon 4 — Installation clé en main + publication open-source — ✅ Réalisé (2026-06-27) - **Objectif** : rendre l'installation triviale et publier le projet proprement. -- **Livré** : image Docker publique multi-arch (`ghcr.io/abrahamop/goacore`, workflow `publish-image.yml` sur tag), `install/docker-compose.yml` + `.env.example` autonomes, « Quick start » README, repo public AGPL-3.0 nettoyé (aucun secret ni IP/chemin perso, y compris dans les tests). +- **Livré** : image Docker publique multi-arch (`ghcr.io/goacloud/goacore`, workflow `publish-image.yml` sur tag), `install/docker-compose.yml` + `.env.example` autonomes, « Quick start » README, repo public AGPL-3.0 nettoyé (aucun secret ni IP/chemin perso, y compris dans les tests). - **Périmètre** : - **Docker Compose** documenté, **assistant de configuration** (setup wizard), **defaults sains**. - `LICENSE`, `README`, documentation d'installation et d'usage, `CONTRIBUTING`. diff --git a/ansible.cfg b/ansible.cfg index f7d1858..b4c1add 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -1,3 +1,8 @@ [defaults] interpreter_python = auto_silent -host_key_checking = False +# host_key_checking reste à sa valeur par défaut (True) : l'identité de l'hôte est +# vérifiée contre les clés déjà épinglées par GoaCore (table ssh_host_keys). Le +# known_hosts temporaire et StrictHostKeyChecking=yes sont injectés par +# RunPlaybook (internal/services/ansible.go). Ne pas remettre host_key_checking = +# False : cela redonnerait à n'importe quel hôte de la cible le droit de recevoir +# la clé privée et l'escalade sudo. diff --git a/assets/audit_2026_08_regression_test.go b/assets/audit_2026_08_regression_test.go new file mode 100644 index 0000000..275de9e --- /dev/null +++ b/assets/audit_2026_08_regression_test.go @@ -0,0 +1,441 @@ +package assets + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +// Garde-fous sur les défauts relevés par la revue adversariale du lot de +// remédiation d'août 2026. Chacun de ces tests correspond à une régression qui +// était passée à travers la revue précédente : ils vivent ici, dans le paquet +// `assets`, parce que c'est déjà le seul endroit du dépôt où la couche +// présentation et les fichiers d'exploitation sont vérifiés par du Go. +// +// Les chemins sont relatifs à assets/ ; la racine du dépôt est donc "..". + +func repoFile(t *testing.T, parts ...string) string { + t.Helper() + path := filepath.Join(append([]string{".."}, parts...)...) + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("lecture %s: %v", path, err) + } + return string(body) +} + +// -------------------------------------------------------------------------- +// 1. Secrets par fichier et sondes de santé +// -------------------------------------------------------------------------- + +// TestDBHealthcheckHandlesSecretFiles : .env.example recommande les secrets par +// fichier (_FILE). Un health-check qui ne lit que MYSQL_ROOT_PASSWORD laisse alors +// la base éternellement « unhealthy », et `depends_on: service_healthy` empêche +// l'application de démarrer — sur l'exact chemin d'installation recommandé. +func TestDBHealthcheckHandlesSecretFiles(t *testing.T) { + for _, compose := range [][]string{ + {"docker-compose.yml"}, + {"install", "docker-compose.yml"}, + } { + body := repoFile(t, compose...) + name := filepath.Join(compose...) + if !strings.Contains(body, "mysqladmin ping") { + t.Fatalf("%s: le health-check de `db` n'utilise plus mysqladmin, ce test ne vérifie plus rien", name) + } + if !strings.Contains(body, "MYSQL_ROOT_PASSWORD_FILE") { + t.Errorf("%s: le health-check de `db` ignore MYSQL_ROOT_PASSWORD_FILE — la base reste « unhealthy » avec un secret par fichier", name) + } + } +} + +// TestContainerHealthcheckProbesReadiness : sonder une page de l'interface +// (/login) garde le conteneur vert pendant une panne totale de la base, c'est-à-dire +// pendant que toutes les pages utiles renvoient 500. /readyz répond 503 dans ce cas. +func TestContainerHealthcheckProbesReadiness(t *testing.T) { + body := repoFile(t, "Dockerfile") + idx := strings.Index(body, "HEALTHCHECK") + if idx < 0 { + t.Fatal("Dockerfile: plus aucun HEALTHCHECK") + } + hc := body[idx:] + if end := strings.Index(hc, "\nENTRYPOINT"); end > 0 { + hc = hc[:end] + } + if !strings.Contains(hc, "/readyz") { + t.Error("Dockerfile: le HEALTHCHECK ne sonde pas /readyz — il reste vert base éteinte") + } + if strings.Contains(hc, "/login") { + t.Error("Dockerfile: le HEALTHCHECK sonde encore /login") + } + // TLS auto-signé sur 8443 : sans cette option la sonde échoue toujours, et le + // conteneur passerait « unhealthy » en permanence — l'inverse du bug d'origine. + if !strings.Contains(hc, "--no-check-certificate") { + t.Error("Dockerfile: le HEALTHCHECK a perdu --no-check-certificate (certificat auto-signé)") + } +} + +// -------------------------------------------------------------------------- +// 2. install/backup.sh — comportement réel +// -------------------------------------------------------------------------- + +func backupScript(t *testing.T) string { + t.Helper() + path, err := filepath.Abs(filepath.Join("..", "install", "backup.sh")) + if err != nil { + t.Fatalf("chemin backup.sh: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("install/backup.sh: %v", err) + } + return path +} + +// runBackup exécute le script avec /bin/sh et rend sortie + succès. +func runBackup(t *testing.T, env []string, args ...string) (string, bool) { + t.Helper() + cmd := exec.Command("/bin/sh", append([]string{backupScript(t)}, args...)...) + cmd.Env = append(os.Environ(), env...) + out, err := cmd.CombinedOutput() + return string(out), err == nil +} + +// writeDump crée un faux dump gzip valide, âgé de `ageDays` jours. +func writeDump(t *testing.T, dir, name string, ageDays int, complete bool) string { + t.Helper() + path := filepath.Join(dir, name) + payload := "CREATE TABLE t (id INT);\n" + if complete { + payload += "-- Dump completed on 2026-08-01 03:00:00\n" + } else { + payload += "-- interrompu au milieu\n" + } + plain := path + ".plain" + if err := os.WriteFile(plain, []byte(payload), 0o600); err != nil { + t.Fatalf("écriture %s: %v", plain, err) + } + cmd := exec.Command("/bin/sh", "-c", "gzip -c "+plain+" > "+path) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("gzip: %v (%s)", err, out) + } + _ = os.Remove(plain) + when := time.Now().AddDate(0, 0, -ageDays) + if err := os.Chtimes(path, when, when); err != nil { + t.Fatalf("chtimes %s: %v", path, err) + } + return path +} + +// TestPruneAlwaysKeepsOneDump : sans plancher, quelques semaines de sauvegardes en +// échec (disque plein, identifiants changés) suffisent à ce que la rétention vide +// entièrement le répertoire — elle efface la dernière copie existante juste au +// moment où elle devient la seule. +func TestPruneAlwaysKeepsOneDump(t *testing.T) { + dir := t.TempDir() + old := writeDump(t, dir, "goacore-20260101-030000.sql.gz", 90, true) + older := writeDump(t, dir, "goacore-20251201-030000.sql.gz", 120, true) + + out, ok := runBackup(t, []string{"GOACORE_BACKUP_RETENTION_DAYS=14"}, "prune", dir) + if !ok { + t.Fatalf("prune a échoué : %s", out) + } + if _, err := os.Stat(old); err != nil { + t.Errorf("prune a supprimé la sauvegarde la plus récente alors qu'elle était la seule restante : %v\n%s", err, out) + } + if _, err := os.Stat(older); err == nil { + t.Errorf("prune n'a pas supprimé le dump le plus ancien, hors rétention :\n%s", out) + } +} + +// TestPruneStillRespectsRetention : le plancher ne doit pas neutraliser la +// rétention quand des dumps récents existent. +func TestPruneStillRespectsRetention(t *testing.T) { + dir := t.TempDir() + fresh := writeDump(t, dir, "goacore-20260805-030000.sql.gz", 0, true) + stale := writeDump(t, dir, "goacore-20260101-030000.sql.gz", 90, true) + + out, ok := runBackup(t, []string{"GOACORE_BACKUP_RETENTION_DAYS=14"}, "prune", dir) + if !ok { + t.Fatalf("prune a échoué : %s", out) + } + if _, err := os.Stat(fresh); err != nil { + t.Errorf("prune a supprimé un dump dans la fenêtre de rétention : %v", err) + } + if _, err := os.Stat(stale); err == nil { + t.Errorf("prune conserve un dump hors rétention alors qu'un plus récent existe :\n%s", out) + } +} + +// TestVerifyRejectsTruncatedDump : c'est le contrôle sur lequel s'appuie +// désormais `restore`. S'il accepte un dump tronqué, la vérification préalable à la +// restauration ne vaut rien. +func TestVerifyRejectsTruncatedDump(t *testing.T) { + dir := t.TempDir() + good := writeDump(t, dir, "goacore-20260805-030000.sql.gz", 0, true) + bad := writeDump(t, dir, "goacore-20260804-030000.sql.gz", 0, false) + + if out, ok := runBackup(t, nil, "verify", good); !ok { + t.Errorf("verify refuse un dump complet : %s", out) + } + if out, ok := runBackup(t, nil, "verify", bad); ok { + t.Errorf("verify accepte un dump tronqué : %s", out) + } +} + +// TestRestoreVerifiesBeforeOverwriting : `restore` a besoin d'un service compose +// démarré, donc son comportement complet n'est pas exerçable ici. Ce qui l'est — +// et qui est le cœur du défaut — c'est l'ORDRE : le contrôle d'intégrité doit +// précéder l'écriture dans la base, et la décompression ne doit plus se faire dans +// un pipeline dont /bin/sh jette silencieusement le code de retour. +func TestRestoreVerifiesBeforeOverwriting(t *testing.T) { + body := repoFile(t, "install", "backup.sh") + start := strings.Index(body, "cmd_restore() {") + if start < 0 { + t.Fatal("install/backup.sh: cmd_restore introuvable") + } + end := strings.Index(body[start:], "\ncmd_verify() {") + if end < 0 { + t.Fatal("install/backup.sh: fin de cmd_restore introuvable") + } + fn := body[start : start+end] + + check := strings.Index(fn, "assert_dump_ok") + if check < 0 { + t.Fatal("cmd_restore ne vérifie pas l'intégrité du dump avant de restaurer") + } + write := strings.Index(fn, "exec mysql ") + if write < 0 { + t.Fatal("cmd_restore n'écrit plus dans la base ? le test ne vérifie plus rien") + } + if check > write { + t.Error("cmd_restore restaure AVANT de vérifier l'intégrité du dump") + } + if regexp.MustCompile(`gunzip -c "\$file"\s*\n\s*else`).MatchString(fn) { + t.Error("cmd_restore décompresse encore dans un pipeline : /bin/sh ne rapporte que le code de retour de mysql, jamais celui de gunzip") + } +} + +// TestDumpIsCreatedRestricted : le dump contient tout l'état du produit, secrets +// chiffrés compris. Un `chmod` a posteriori le laisse lisible par tous pendant +// toute la durée du dump — la fenêtre la plus longue et la plus prévisible qui soit. +func TestDumpIsCreatedRestricted(t *testing.T) { + body := repoFile(t, "install", "backup.sh") + start := strings.Index(body, "cmd_dump() {") + end := strings.Index(body, "cmd_restore() {") + if start < 0 || end < start { + t.Fatal("install/backup.sh: cmd_dump introuvable") + } + fn := body[start:end] + umask := strings.Index(fn, "umask 077") + if umask < 0 { + t.Fatal("cmd_dump ne pose pas de umask restrictif : le dump naît en 0644") + } + if redirect := strings.Index(fn, `gzip > "$tmp"`); redirect >= 0 && umask > redirect { + t.Error("le umask est posé APRÈS la création du fichier : trop tard") + } +} + +// -------------------------------------------------------------------------- +// 3. Couleurs +// -------------------------------------------------------------------------- + +// rawPalette repère une classe utilitaire construite sur une échelle de couleur +// Tailwind brute (text-cyan-400, bg-teal-500/10…). Ces couleurs sont figées : elles +// ne suivent pas le thème et tombent à 1,6:1 sur les surfaces claires. +var rawPalette = regexp.MustCompile(`\b(?:text|bg|border|from|to|via|ring|fill|stroke|decoration|divide|outline|accent|caret|placeholder)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}(?:/\d{1,3})?\b`) + +// TestNoRawPaletteClasses : la migration vers les tokens sémantiques doit être +// complète, sans quoi il reste exactement ce qu'elle voulait éliminer. +func TestNoRawPaletteClasses(t *testing.T) { + // Hors périmètre de ce correctif (traités par un autre changement du même lot). + // partials.html a été migré : la barre latérale, présente sur chaque écran, + // ne doit plus diverger de la barre d'onglets Paramètres. + exempt := map[string]bool{ + "settings-account.html": true, + } + for _, f := range templateFiles(t) { + if exempt[filepath.Base(f)] { + continue + } + for i, line := range strings.Split(readTemplate(t, f), "\n") { + if m := rawPalette.FindString(line); m != "" { + t.Errorf("%s:%d: classe de palette brute %q — utiliser un token sémantique (text-info, text-warning, bg-primary/20…)", + f, i+1, m) + } + } + } + for _, js := range []string{"ui.js", "search.js", "theme.js"} { + if m := rawPalette.FindString(readAsset(t, js)); m != "" { + t.Errorf("static/%s: classe de palette brute %q", js, m) + } + } +} + +// severityToken extrait le token `text-…` associé à un niveau de sévérité. +func severityToken(t *testing.T, body, level string) string { + t.Helper() + re := regexp.MustCompile(level + `:\s*\{[^}]*text:\s*'(text-[a-z-]+)'`) + m := re.FindStringSubmatch(body) + if m == nil { + t.Fatalf("wazuh.html: couleur de la sévérité %s introuvable", level) + } + return m[1] +} + +// TestWazuhSeveritiesAreDistinguishable : quatre niveaux de sévérité pour trois +// teintes, c'est une sévérité qu'il faut lire pour la distinguer — exactement ce +// que la couleur est censée éviter sur un écran de sécurité. +func TestWazuhSeveritiesAreDistinguishable(t *testing.T) { + body := readTemplate(t, filepath.Join("templates", "wazuh.html")) + seen := map[string]string{} + for _, level := range []string{"Critical", "High", "Medium", "Low"} { + token := severityToken(t, body, level) + if other, dup := seen[token]; dup { + t.Errorf("wazuh.html: %s et %s partagent la couleur %s", other, level, token) + } + seen[token] = level + } +} + +// TestSparklinesFollowTheme : les trois courbes de la page Proxmox étaient les +// seuls éléments non thémés de l'écran (couleurs hexadécimales en dur). +func TestSparklinesFollowTheme(t *testing.T) { + body := readTemplate(t, filepath.Join("templates", "proxmox.html")) + calls := regexp.MustCompile(`drawSparkline\('spark-[a-z]+',[^\n]*,\s*'([^'\n]+)'\)`).FindAllStringSubmatch(body, -1) + if len(calls) == 0 { + t.Fatal("proxmox.html: aucun appel à drawSparkline, le test ne vérifie plus rien") + } + for _, c := range calls { + if !strings.HasPrefix(c[1], "var(--md-color-") { + t.Errorf("proxmox.html: sparkline en couleur figée %q — utiliser un token MD3", c[1]) + } + } + // var() n'est pas résolu dans un attribut de présentation SVG, seulement dans + // une déclaration CSS : le passage aux tokens n'a de sens qu'avec style="". + if !strings.Contains(body, `style="fill:${color}"`) { + t.Error("proxmox.html: la couleur de sparkline doit être posée en style inline, sinon var(--…) n'est pas résolu") + } +} + +// -------------------------------------------------------------------------- +// 4. Modales : saisie non enregistrée et pile unique +// -------------------------------------------------------------------------- + +// TestEscapeDoesNotDiscardUnsavedInput : Échap fermait n'importe quelle modale +// sur-le-champ, éditeur de playbook compris — une frappe malheureuse et le YAML +// tapé disparaissait sans un mot. +func TestEscapeDoesNotDiscardUnsavedInput(t *testing.T) { + ui := readAsset(t, "ui.js") + for _, needle := range []string{ + "_goaDirty", // état « modifié » + "requestClose", // fermeture par geste, distincte de closeModal + "isTrusted", // seule une saisie humaine salit la modale + "data-modal-discardable", // opt-out pour les saisies jetables + `addEventListener("input"`, // marquage + } { + if !strings.Contains(ui, needle) { + t.Errorf("ui.js: %s manquant — Échap peut de nouveau jeter une saisie non enregistrée", needle) + } + } + // Le handler clavier doit passer par la garde, pas fermer directement. + esc := regexp.MustCompile(`(?s)event\.key === "Escape".{0,240}`).FindString(ui) + if esc == "" { + t.Fatal("ui.js: gestionnaire d'Échap introuvable") + } + if !strings.Contains(esc, "requestClose(") { + t.Error("ui.js: Échap appelle closeModal directement, sans la garde « saisie non enregistrée »") + } +} + +// TestCommandPaletteSharesTheModalStack : tant que la palette Ctrl+K vivait à côté +// de la pile de ui.js, Échap était avalé par la modale du dessous et le focus lui +// était volé sans que son piège de focus ne le sache. +func TestCommandPaletteSharesTheModalStack(t *testing.T) { + search := readAsset(t, "search.js") + for _, needle := range []string{ + "GoaUI.openModal", // ouverture par la pile partagée + "GoaUI.closeModal", // fermeture par la pile partagée + "data-modal-discardable", // Échap doit fermer la palette immédiatement + "data-modal-autofocus", // le focus va dans le champ de recherche + `role="dialog"`, // c'est un dialogue, il doit le dire + "goa:modal-close", // ménage même quand la pile ferme elle-même + } { + if !strings.Contains(search, needle) { + t.Errorf("search.js: %s manquant — la palette n'est plus une couche de la pile partagée", needle) + } + } + if strings.Contains(search, "e.key === 'Escape'") { + t.Error("search.js: gestionnaire d'Échap concurrent réintroduit — la pile de ui.js est la seule autorité") + } +} + +// -------------------------------------------------------------------------- +// 5. Finitions +// -------------------------------------------------------------------------- + +// TestBackupsSettingsTableScrolls : quatre colonnes dans une modale contrainte à +// max-w-3xl, sur un écran étroit la dernière devenait inatteignable (conteneur en +// overflow-hidden, donc coupée net et non défilable). +func TestBackupsSettingsTableScrolls(t *testing.T) { + body := readTemplate(t, filepath.Join("templates", "backups.html")) + tables := strings.Count(body, "