From cf8b66340afbbed8eb3a9cb333bab195fa3551ad Mon Sep 17 00:00:00 2001 From: EduBox Dev Date: Fri, 26 Jun 2026 15:24:21 +0000 Subject: [PATCH] agent v0.3.8: fix crash UI notifications, auto Podman machine DNS, WordPress 7.0.0 ready template --- .gitignore | 1 + SUIVI_VPN_ONDEMAND.md | 240 +++++++++++++++++- agent/build.sh | 2 +- agent/docker.go | 9 + agent/main.go | 18 +- agent/podman.go | 65 +++++ agent/ui.go | 32 ++- agent/websocket.go | 75 +++++- server/app/api/instances/route.ts | 25 +- server/app/dashboard/download/page.tsx | 26 +- server/lib/websocket.ts | 14 +- .../migration.sql | 2 + server/prisma/schema.prisma | 1 + server/prisma/seed.ts | 68 ++++- server/templates/wordpress-ready/wp-init.sh | 47 ++++ 15 files changed, 590 insertions(+), 35 deletions(-) create mode 100644 agent/podman.go create mode 100644 server/prisma/migrations/20250626125000_add_init_script_to_template/migration.sql create mode 100644 server/templates/wordpress-ready/wp-init.sh diff --git a/.gitignore b/.gitignore index 2911748..934614b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ headscale/*.state agent/resolv.conf agent/tailscale-bin/ agent/studioE5-agent-test +agent/.cache-go/ server/tsconfig.tsbuildinfo diff --git a/SUIVI_VPN_ONDEMAND.md b/SUIVI_VPN_ONDEMAND.md index 414e7e8..4ad022f 100644 --- a/SUIVI_VPN_ONDEMAND.md +++ b/SUIVI_VPN_ONDEMAND.md @@ -64,6 +64,68 @@ HTTP/2 200 - Certificat Let’s Encrypt obtenu automatiquement par Caddy (`tls { on_demand }`). - Le resolver réécrit les en-têtes `Location` et le contenu HTML pour passer de `http://` à `https://`. +## 📝 Template WordPress prêt à l’emploi + +Un nouveau template `wordpress-ready-wordpress-latest` a été créé et validé le 2026-06-26. Il fournit un WordPress déjà initialisé en français, prêt à l’usage en classe ou en examen. + +### Contenu du template + +| Élément | Valeur / État | +|---|---| +| Langue | **Français** (`fr_FR`) | +| Titre du site | **Mon site wordpress** | +| Compte administrateur | **admin / admin** | +| Thème actif | **Astra** | +| Spectra | installé et **actif** | +| Yoast SEO | installé mais **inactif** | +| Mises à jour automatiques | **désactivées** (core, plugins, thèmes) | +| DNS conteneur | `8.8.8.8` + `1.1.1.1` pour permettre l’accès à `api.wordpress.org` | + +### Architecture technique + +- Le modèle `Template` de Prisma dispose d’un nouveau champ `initScript` (`TEXT?`). +- Le seed génère le template avec : + - une section `dns` dans le service `app` du `docker-compose.yml` ; + - un service sidecar `wp-init` (image `wordpress:cli`) exécutant le script d’initialisation. +- L’agent écrit le script `wp-init.sh` dans le dossier de l’instance au démarrage. +- Le conteneur `wp-init` attend que WordPress soit prêt, puis exécute WP-CLI en tant que `www-data`. +- Un fichier flag `.studioe5-init-done` évite de réinitialiser l’instance à chaque redémarrage. + +### Fichiers modifiés / ajoutés + +- `server/prisma/schema.prisma` – champ `initScript` sur `Template`. +- `server/prisma/seed.ts` – génération du template `wordpress-ready-wordpress-latest`. +- `server/templates/wordpress-ready/wp-init.sh` – script d’initialisation WP-CLI. +- `server/app/api/instances/route.ts` – envoi de `initScript` à l’agent avec remplacement des placeholders. +- `agent/websocket.go` – réception et transmission de `InitScript`. +- `agent/docker.go` – écriture du script dans le dossier instance (`writeInitScript`). + +### Validation + +Instance de test créée via l’API (`cmqv03a6v0001vg8zrpe8zqfy`) : + +```bash +$ curl -sS -I -L https://cmqv03a6v0001vg8zrpe8zqfy.studioe5.edudeploy.com/ +HTTP/2 200 +``` + +- Page d’accueil en français, titre **« Mon site wordpress »**. +- Connexion admin `/wp-login.php` avec **admin / admin** fonctionnelle. +- Tableau de bord en français. +- Plugins : Spectra actif, Yoast SEO inactif. +- `wp-config.php` contient les constantes de désactivation des mises à jour automatiques. + +Les instances de test ont été nettoyées après validation. + +### Template versionné WordPress 7.0.0 + +Un second template `wordpress-ready-wordpress-7.0.0-php8.3` a été ajouté pour figer WordPress sur la version **7.0.0** (PHP 8.3) au lieu de `latest`. Cela garantit que tous les postes déploient exactement la même version, sans dépendre du cache local de `wordpress:latest`. + +| Template | Image Docker | +|---|---| +| `wordpress-ready-wordpress-latest` | `wordpress:latest` | +| `wordpress-ready-wordpress-7.0.0-php8.3` | `wordpress:7.0.0-php8.3` | + ## 📁 Fichiers modifiés (non exhaustif) - `agent/tailscale.go` – lancement `tailscaled` + `tailscale up`, gestion start/stop/status. @@ -79,7 +141,7 @@ Agent de test lancé en arrière-plan : - data-dir : `/tmp/studioe5-test-clienta` - node-id : `vps-8fc665eb` - tailnet IP actuelle : `100.64.0.8` -- PID : `3151830` (lancé le 2026-06-23 09:36 UTC) +- PID : voir `/tmp/studioe5-test-clienta/agent.pid` (relancé le 2026-06-26 11:53 UTC avec l’agent v0.3.5 corrigé) Instance de test créée : - ID : `test-wp-001` @@ -165,6 +227,127 @@ L’agent redémarre `tailscaled` automatiquement au lancement, même si la clé - **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5.exe` - **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5` +## 🪟 Agent v0.3.6 – recover() dans les goroutines de démarrage d’instance + +### Problème + +Lors de la création d’une instance depuis le dashboard vers certains agents (notamment Windows), l’agent s’arrêtait brutalement. Le `recover()` présent dans `handleMessage` ne capturait pas le panic car celui-ci survenait dans les goroutines lancées par `go handleStartInstance(...)`. + +### Corrections apportées + +- Ajout d’un `defer recover()` dans `handleStartInstance` ; en cas de panic, l’instance passe en statut `error` et un message `instance_error` est envoyé au serveur. +- Ajout d’un `defer recover()` dans toutes les goroutines critiques du WebSocket : + - `start_vpn` + - `stop_vpn` + - `start` + - `reset` + - `startTailscaleAndReport` + - cleanup au shutdown +- Ajout de logs de traçage au début de `handleStartInstance` (`instance`, `type`, `port`, `dataDir`, `initScriptLen`). + +### Téléchargement + +- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.6-windows.zip` +- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.6.exe` +- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.6` + +### Redeploiement + +- Agent rebuildé en v0.3.6 pour Windows et Linux. +- Binaires versionnés copiés dans `server/public/`. +- Page `/dashboard/download` mise à jour vers la v0.3.6. +- Serveur rebuildé et redémarré. + +## 🪟 Agent v0.3.7 – recover() dans les notifications UI + +### Problème + +L’agent continuait de s’arrêter brutalement lors de la création d’une instance depuis le dashboard. Le crash survenait juste après les logs `Start instance ...` et `notifyUI: broadcasting to ...`, sans laisser de trace de panic. Cela pointait vers une panique dans les goroutines de notification UI ou dans l’écriture des logs vers les clients UI locaux. + +### Corrections apportées + +- Ajout d’un `defer recover()` dans `notifyUI` pour chaque goroutine de notification. +- Ajout d’un `defer recover()` dans `sendUILog` (logs diffusés aux clients UI). +- Ajout d’un `defer recover()` dans `broadcastUI` (messages diffusés aux clients UI). + +### Téléchargement + +- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.7-windows.zip` +- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.7.exe` +- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.7` + +## 🪟 Agent v0.3.8 – DNS automatique pour Podman machine (Windows/macOS) + +### Problème + +Après correction du crash, l’agent Windows avec Podman échouait au `docker compose up` avec : +```text +lookup registry-1.docker.io: Temporary failure in name resolution +``` +La VM Podman machine n’avait pas de DNS fonctionnel, ce qui empêchait le téléchargement des images Docker. Le DNS des conteneurs (`dns: 8.8.8.8` dans le compose) résout le problème à l’intérieur des conteneurs, mais pas pour le pull d’images par Podman machine. + +### Solution + +L’agent configure automatiquement le DNS des machines Podman en cours d’exécution au démarrage : +- Détection de Podman sur Windows/macOS. +- Liste des machines Podman (`podman machine list --format json`). +- Pour chaque machine `running`, exécution de : + ```bash + podman machine ssh sudo sh -c 'echo nameserver 8.8.8.8 > /etc/resolv.conf && echo nameserver 1.1.1.1 >> /etc/resolv.conf' + ``` + +Fichier ajouté : `agent/podman.go`. Appel depuis `agent/main.go` au démarrage. + +### Téléchargement + +- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.8-windows.zip` +- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.8.exe` +- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.8` + +## 🐛 Fix synchronisation agent / dashboard + +### Problème + +Le statut affiché dans le dashboard pouvait diverger de l’état réel de l’agent : +- Après un **Arrêter** lancé depuis le dashboard, l’instance restait affichée comme elle l’était avant, ou disparaissait avec perte des données. +- Après une **Suppression**, l’instance n’était pas retirée de la liste. + +### Causes racines + +1. **Action `stop` du dashboard envoyée comme `delete` à l’agent** (`server/app/api/instances/route.ts`). + L’agent exécutait alors `docker compose down -v` + suppression des fichiers, c’est-à-dire une suppression réelle, tout en marquant l’instance `stopped` en base. +2. **L’agent ne confirmait pas les actions serveur** (`agent/websocket.go`). + Les handlers `stop` et `delete` ne renvoyaient jamais les messages `instance_stopped` / `instance_deleted` au serveur ; seule l’UI locale le faisait. +3. **Le handler `stop` de l’agent utilisait `dockerComposeDown`** au lieu de `dockerComposeStop`, ne respectant pas le cycle de vie documenté (arrêt = conteneurs et volumes conservés). + +### Corrections apportées + +| Fichier | Changement | +|---------|------------| +| `server/app/api/instances/route.ts` | L’action dashboard `stop` envoie désormais `action: "stop"` à l’agent (et non plus `"delete"`). | +| `agent/websocket.go` | Le cas `stop` utilise `dockerComposeStop`, puis envoie `instance_stopped` au serveur. Le cas `delete` envoie `instance_deleted` au serveur. | +| `server/lib/websocket.ts` | Utilisation de `updateMany`/`deleteMany` pour ignorer silencieusement les messages d’instances déjà absentes/supprimées (évite les erreurs Prisma en double suppression). | + +### Résultat + +Le dashboard reflète désormais l’état réel après une action serveur-initiée, dès le rechargement de la page. Le cycle de vie respecte la sémantique attendue : +- **Arrêter** : `docker compose stop` → statut `stopped`. +- **Démarrer** : `docker compose up -d` → statut `running`. +- **Redémarrer** : `docker compose down -v` + recréation. +- **Supprimer** : `docker compose down -v` + suppression fichiers. + +### Redeploiement effectué le 2026-06-26 + +- **Agent rebuildé** en v0.3.5 (`agent/studioE5-agent`, `.exe`, `.zip` et `server/public/` mis à jour). +- **Serveur rebuildé et redémarré** (`docker compose up -d --build server`) pour intégrer les corrections TypeScript. +- **Page `/dashboard/download` mise à jour** : passage à la version 0.3.5 et ajout des liens Windows (.exe, .zip) et Linux. +- **Corrections défensives agent** après signalement d’arrêt brutal lors d’actions dashboard : + - `sendMessage` exécuté de manière asynchrone (`go`) dans les handlers `stop`, `delete`, `stop_vpn` et cleanup, pour ne pas bloquer la boucle de lecture WebSocket. + - Ajout d’un `recover` dans `handleMessage` pour capturer d’éventuels panics sans tuer l’agent. + - Correction du cleanup `main.go` : modification de `inst[id].Status` (et non de la copie locale `info`). +- **Agent de test Linux relancé** (PID dans `/tmp/studioe5-test-clienta/agent.pid`). +- **Agents clients** : il faut redémarrer l’agent sur chaque poste, ou télécharger à nouveau le binaire v0.3.5 depuis le dashboard pour Windows. + ## 🛠️ Commandes utiles pour reprendre ### Voir l’agent de test @@ -551,6 +734,50 @@ Si la clé doit être changée : --- +## 🖥️ Installateur agent professionnel + +### Objectif + +Créer un package d’installation unique et professionnel par OS, incluant l’agent studioE5, Tailscale et **Podman CLI**, afin de ne plus dépendre d’installations manuelles préalables par l’utilisateur. + +### Choix des outils + +| OS | Outil | Format | Justification | +|---|---|---|---| +| **Windows** | **Inno Setup** | `.exe` | Gratuit, open source, très répandu, personnalisable, exécution de scripts PowerShell/silencieux. | +| **macOS** | **`pkgbuild`** | `.pkg` | Outil natif Apple, gratuit, format professionnel pour la distribution macOS. | +| **Linux** | **Script shell** (+ `.deb`/`.rpm` optionnels) | `.sh` | Universel, détecte le package manager, simple à maintenir. | + +### Contenu du package par OS + +- **Windows** (Inno Setup) : + - Installer l’agent dans `C:\Program Files\studioE5-agent\`. + - Extraire Tailscale dans `C:\Program Files\studioE5-agent\tailscale-bin\windows\`. + - Installer Podman CLI via le MSI officiel en mode silencieux. + - Exécuter `podman machine init` puis `podman machine start`. + - Créer un raccourci de démarrage et/ou un service Windows. + +- **macOS** (`pkgbuild`) : + - Installer l’agent dans `/Applications/studioE5-agent/`. + - Installer Podman CLI. + - Exécuter `podman machine init` puis `podman machine start`. + - Optionnellement créer un LaunchAgent pour démarrer l’agent au login. + +- **Linux** (script shell) : + - Détecter le package manager (`apt`, `dnf`, `pacman`, etc.). + - Installer Podman et Podman Compose. + - Copier l’agent dans `/opt/studioe5-agent/`. + - Créer le service systemd `studioe5-agent.service`. + - Activer et démarrer le service. + +### Adaptations nécessaires dans l’agent + +- Détecter si Podman est utilisé et si une machine est requise (Windows/macOS). +- Vérifier au démarrage que la machine Podman est démarrée, et lancer `podman machine start` si besoin. +- Gérer proprement l’arrêt de la machine à la fermeture de l’agent (optionnel). + +--- + ## 📋 Prochaines étapes à faire ### ✅ Terminé @@ -568,7 +795,8 @@ Si la clé doit être changée : - [x] **Agent v0.3.5 – UI locale moderne** (dashboard, logs, progression, actions d’instance). - [x] **Agent v0.3.5 – cycle de vie des instances** (`stop`/`start` préservent les volumes, `reset`/`delete` effacent). - [x] **Agent v0.3.5 – cleanup au shutdown** (arrêt propre de Tailscale et des instances, notification serveur). -- [x] **Synchronisation dashboard** (messages `instance_stopped` / `instance_deleted` traités côté serveur). +- [x] **Synchronisation dashboard** (messages `instance_stopped` / `instance_deleted` traités côté serveur, et agent renvoyant correctement ces messages après un ordre serveur `stop`/`delete`). +- [x] **Template WordPress prêt à l’emploi** (`wordpress-ready-wordpress-latest`) : WordPress en français, titre « Mon site wordpress », compte admin/admin, thème Astra, Spectra actif, Yoast SEO inactif, mises à jour automatiques désactivées, DNS `8.8.8.8`/`1.1.1.1` pour `api.wordpress.org`. ### ⏳ Reste à faire @@ -577,6 +805,14 @@ Si la clé doit être changée : - [ ] **Nettoyer les anciens nodes/volumes Headscale** de test (nœuds `edubox`, `prof`, `invalid-*` hors ligne à supprimer). - [ ] **Pousser la branche** vers Gitea dès que le remote sera accessible. - [ ] **Documenter la procédure de mise en production** pour le client A (config agent, clés Headscale, ports, ACL, etc.). +- [ ] **Installateur agent professionnel (Windows / macOS / Linux)** : créer un package d’installation unique incluant l’agent studioE5, Tailscale et **Podman CLI**. Voir la section « 🖥️ Installateur agent professionnel » ci-dessous pour le détail des outils (Inno Setup, pkgbuild, script shell) et du contenu par OS. +- [ ] **Template WordPress prêt à l’emploi (usage examen/classe)** : + - Forcer le DNS (`8.8.8.8`, `1.1.1.1`) dans le `docker-compose.yml` pour permettre l’accès à la bibliothèque de plugins/mises à jour depuis le conteneur. + - Pré-installer WordPress en **français** via WP-CLI avec le titre **“Mon site wordpress”** et le compte **admin / admin**. + - Désactiver les **mises à jour automatiques** (core, plugins, thèmes) pour figer l’environnement. + - Installer et activer le **thème Astra**. + - Installer **Yoast SEO** (inactif) et **Spectra** (actif). +- [ ] **Barre de progression basée sur les logs d’installation** : enrichir la barre de progression agent/dashboard en lisant les logs Podman/Docker (`podman logs -f`) pendant le premier démarrage d’une instance. Définir des patterns de logs par template (ex. `Installation successful` pour PrestaShop) et relayer les étapes réelles au dashboard via WebSocket. - [ ] **Étude – interface de déploiement multi-clients** : outil de provisionning d’un nouveau serveur client + agent générique (option A : URL serveur déterminée à l’activation). - [ ] **Sécurité – gestion et rotation des secrets** (`INTERNAL_API_KEY`, `HEADSCALE_API_KEY`, `NEXTAUTH_SECRET`, `DATABASE_URL`). - [ ] **Sécurité – durcissement des conteneurs** (`cap_add`, utilisateurs non-root, scans CVE). diff --git a/agent/build.sh b/agent/build.sh index 4716625..f3db3e9 100755 --- a/agent/build.sh +++ b/agent/build.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -VERSION="0.3.5" +VERSION="0.3.8" APP_NAME="studioE5" BIN_NAME="studioE5-agent" LDFLAGS="-X main.version=${VERSION}" diff --git a/agent/docker.go b/agent/docker.go index 2d675e7..9c3aff9 100644 --- a/agent/docker.go +++ b/agent/docker.go @@ -39,6 +39,15 @@ func writeCompose(dataDir, instanceID, compose string, port int) error { return os.WriteFile(f, []byte(compose), 0644) } +func writeInitScript(dataDir, instanceID, script string) error { + dir := instanceDir(dataDir, instanceID) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + f := filepath.Join(dir, "wp-init.sh") + return os.WriteFile(f, []byte(script), 0755) +} + func configureEngineCmd(cmd *exec.Cmd, dir string) { hideWindow(cmd) logPath := filepath.Join(dir, "compose.log") diff --git a/agent/main.go b/agent/main.go index 55e4673..871b150 100644 --- a/agent/main.go +++ b/agent/main.go @@ -62,6 +62,10 @@ func main() { log.Printf("[%s Agent] version=%s node=%s data-dir=%s server=%s", APP_NAME, version, cfg.NodeID, *dataDir, cfg.Server) + // Ensure Podman machine DNS is configured on Windows/macOS so images can be + // pulled and containers can reach the internet. + ensurePodmanMachineDNS() + if *uiEnabled { go startUI(*dataDir, cfg.NodeID, cfg.Server) } @@ -84,6 +88,11 @@ func main() { cleanupWg.Add(1) go func() { defer cleanupWg.Done() + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in cleanup goroutine: %v", r) + } + }() <-shutdownCh log.Println("Cleaning up before exit...") @@ -98,8 +107,8 @@ func main() { if info.Status == "running" { log.Printf("Stopping instance %s", id) _ = dockerComposeStop(*dataDir, id) - info.Status = "stopped" - _ = sendMessage(WSMessage{Action: "instance_stopped", InstanceID: id}) + inst[id].Status = "stopped" + go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: id}) } } _ = saveInstances(*dataDir, inst) @@ -127,6 +136,11 @@ func main() { } func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in startTailscaleAndReport: %v", r) + } + }() ip, err := startTailscale(dataDir, nodeID, headscaleURL, authKey) if err != nil { log.Printf("Tailscale error: %v", err) diff --git a/agent/podman.go b/agent/podman.go new file mode 100644 index 0000000..89ad2c5 --- /dev/null +++ b/agent/podman.go @@ -0,0 +1,65 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os/exec" + "runtime" +) + +type podmanMachine struct { + Name string `json:"name"` + Running bool `json:"running"` + VMType string `json:"vm_type"` +} + +// ensurePodmanMachineDNS configures public DNS resolvers on running Podman +// machines on Windows and macOS. This is required because the Podman VM does +// not always inherit a working DNS from the host, which prevents pulling +// images and reaching api.wordpress.org from containers. +func ensurePodmanMachineDNS() { + if runtime.GOOS != "windows" && runtime.GOOS != "darwin" { + return + } + if getContainerEngine() != "podman" { + return + } + + out, err := exec.Command("podman", "machine", "list", "--format", "json").Output() + if err != nil { + log.Printf("ensurePodmanMachineDNS: cannot list machines: %v", err) + return + } + + var machines []podmanMachine + if err := json.Unmarshal(out, &machines); err != nil { + log.Printf("ensurePodmanMachineDNS: cannot parse machine list: %v", err) + return + } + + for _, m := range machines { + if !m.Running { + continue + } + if err := configurePodmanMachineDNS(m.Name); err != nil { + log.Printf("ensurePodmanMachineDNS: failed for %s: %v", m.Name, err) + } else { + log.Printf("ensurePodmanMachineDNS: DNS configured for %s", m.Name) + } + } +} + +func configurePodmanMachineDNS(name string) error { + cmd := exec.Command( + "podman", "machine", "ssh", name, + "sudo", "sh", "-c", + "echo nameserver 8.8.8.8 > /etc/resolv.conf && echo nameserver 1.1.1.1 >> /etc/resolv.conf", + ) + hideWindow(cmd) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%w: %s", err, string(out)) + } + return nil +} diff --git a/agent/ui.go b/agent/ui.go index 956affe..4bcb4cb 100644 --- a/agent/ui.go +++ b/agent/ui.go @@ -251,9 +251,16 @@ func sendUILog(message string) { "level": "info", } for _, conn := range conns { - if err := conn.WriteJSON(msg); err != nil { - // Client may have disconnected; ignore. - } + func(c *websocket.Conn) { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in sendUILog: %v", r) + } + }() + if err := c.WriteJSON(msg); err != nil { + // Client may have disconnected; ignore. + } + }(conn) } } @@ -278,9 +285,16 @@ func broadcastUI(msg map[string]interface{}) { uiConnectionsMu.RUnlock() for _, conn := range conns { - if err := conn.WriteJSON(msg); err != nil { - // Ignore write errors for disconnected clients. - } + func(c *websocket.Conn) { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in broadcastUI: %v", r) + } + }() + if err := c.WriteJSON(msg); err != nil { + // Ignore write errors for disconnected clients. + } + }(conn) } } @@ -416,7 +430,7 @@ func uiStopInstance(dataDir, instanceID string) { inst[instanceID].Status = "stopped" _ = saveInstances(dataDir, inst) } - _ = sendMessage(WSMessage{Action: "instance_stopped", InstanceID: instanceID}) + go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: instanceID}) notifyUI(map[string]interface{}{"action": "instances_updated"}) } @@ -427,7 +441,7 @@ func uiDeleteInstance(dataDir, instanceID string) { } dockerComposeRm(dataDir, instanceID) removeInstance(dataDir, instanceID) - _ = sendMessage(WSMessage{Action: "instance_deleted", InstanceID: instanceID}) + go sendMessage(WSMessage{Action: "instance_deleted", InstanceID: instanceID}) notifyUI(map[string]interface{}{"action": "instances_updated"}) } @@ -446,7 +460,7 @@ func uiResetInstance(dataDir, nodeID, instanceID string) { return } dockerComposeRm(dataDir, instanceID) - handleStartInstance(dataDir, nodeID, instanceID, info.TemplateName, string(composeBytes), info.Port) + handleStartInstance(dataDir, nodeID, instanceID, info.TemplateName, string(composeBytes), "", info.Port) } // instanceContainersExist returns true if compose containers already exist for this instance. diff --git a/agent/websocket.go b/agent/websocket.go index c822aa2..b7dddaa 100644 --- a/agent/websocket.go +++ b/agent/websocket.go @@ -18,6 +18,7 @@ type WSMessage struct { Type string `json:"type,omitempty"` Port int `json:"port,omitempty"` ComposeConfig string `json:"composeConfig,omitempty"` + InitScript string `json:"initScript,omitempty"` StudentId string `json:"studentId,omitempty"` StudentName string `json:"studentName,omitempty"` Error string `json:"error,omitempty"` @@ -101,7 +102,14 @@ func notifyUI(msg map[string]interface{}) { log.Printf("notifyUI: broadcasting to %d UI clients", len(notifiers)) for _, fn := range notifiers { - go fn(msg) + go func(notify uiNotifier) { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in notifyUI goroutine: %v", r) + } + }() + notify(msg) + }(fn) } } @@ -192,6 +200,12 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey } func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in handleMessage (action=%s): %v", msg.Action, r) + } + }() + switch msg.Action { case "set_token": if msg.Token != "" { @@ -251,10 +265,15 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) hsURL, hsKey := getHeadscaleConfig() if hsURL == "" || hsKey == "" { log.Printf("Cannot start VPN: headscale config missing") - sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"}) + go sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"}) return } go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in start_vpn goroutine: %v", r) + } + }() ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey) if err != nil { log.Printf("start_vpn error: %v", err) @@ -282,7 +301,14 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) case "stop_vpn": log.Printf("Server requested VPN stop") stopTailscale() - sendMessage(WSMessage{Action: "vpn_stopped", NodeID: nodeID}) + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in stop_vpn goroutine: %v", r) + } + }() + sendMessage(WSMessage{Action: "vpn_stopped", NodeID: nodeID}) + }() case "activation_failed": log.Printf("handleMessage: activation_failed received, error=%s", msg.Error) notifyUI(map[string]interface{}{ @@ -291,19 +317,27 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) }) case "start": log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port) - go handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.Port) + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in start goroutine instance=%s: %v", msg.InstanceID, r) + } + }() + handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.InitScript, msg.Port) + }() case "stop": log.Printf("Stop instance %s", msg.InstanceID) if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil { removeTailscaleServe(inst[msg.InstanceID].Port) } - if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil { - log.Printf("dockerComposeDown error: %v", err) + if err := dockerComposeStop(dataDir, msg.InstanceID); err != nil { + log.Printf("dockerComposeStop error: %v", err) } if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil { inst[msg.InstanceID].Status = "stopped" _ = saveInstances(dataDir, inst) } + go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: msg.InstanceID}) notifyUI(map[string]interface{}{"action": "instances_updated"}) case "delete": log.Printf("Delete instance %s", msg.InstanceID) @@ -312,17 +346,37 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) } dockerComposeRm(dataDir, msg.InstanceID) removeInstance(dataDir, msg.InstanceID) + go sendMessage(WSMessage{Action: "instance_deleted", InstanceID: msg.InstanceID}) notifyUI(map[string]interface{}{"action": "instances_updated"}) case "reset": log.Printf("Reset instance %s", msg.InstanceID) dockerComposeRm(dataDir, msg.InstanceID) - go handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.Port) + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in reset goroutine instance=%s: %v", msg.InstanceID, r) + } + }() + handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.InitScript, msg.Port) + }() default: log.Printf("Unknown action: %s", msg.Action) } } -func handleStartInstance(dataDir, nodeID, instanceID, instanceType, composeConfig string, port int) { +func handleStartInstance(dataDir, nodeID, instanceID, instanceType, composeConfig, initScript string, port int) { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in handleStartInstance instance=%s: %v", instanceID, r) + _ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: "error"}) + sendMessage(WSMessage{Action: "instance_error", InstanceID: instanceID, Error: fmt.Sprintf("internal panic: %v", r)}) + sendInstanceProgress(instanceID, "start", "0", "Erreur interne") + notifyUI(map[string]interface{}{"action": "instances_updated"}) + } + }() + + log.Printf("handleStartInstance begin: instance=%s type=%s port=%d dataDir=%s initScriptLen=%d", instanceID, instanceType, port, dataDir, len(initScript)) + notifyInstanceProgress := func(percent, message string) { sendInstanceProgress(instanceID, "start", percent, message) } @@ -344,6 +398,11 @@ func handleStartInstance(dataDir, nodeID, instanceID, instanceType, composeConfi notifyUI(map[string]interface{}{"action": "instances_updated"}) return } + if initScript != "" { + if err := writeInitScript(dataDir, instanceID, initScript); err != nil { + log.Printf("writeInitScript error: %v", err) + } + } notifyInstanceProgress("30", "Configuration de l'application...") if err := dockerComposeUp(dataDir, instanceID); err != nil { diff --git a/server/app/api/instances/route.ts b/server/app/api/instances/route.ts index 5b69470..fe03535 100644 --- a/server/app/api/instances/route.ts +++ b/server/app/api/instances/route.ts @@ -128,6 +128,13 @@ export async function POST(req: NextRequest) { .replace(/{INSTANCE_ID}/g, instance.id) .replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`) .replace(/{PUBLIC_DOMAIN}/g, "localhost"), + initScript: template.initScript + ? template.initScript + .replace(/{PORT}/g, String(instance.port)) + .replace(/{INSTANCE_ID}/g, instance.id) + .replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`) + .replace(/{PUBLIC_DOMAIN}/g, "localhost") + : undefined, }); if (!sent) { @@ -161,8 +168,8 @@ export async function PATCH(req: NextRequest) { const publicUrl = domain ? `https://${publicDomain}` : null; if (action === "stop") { - sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id }); - await prisma.instance.update({ where: { id }, data: { status: "stopped" } }); + const sent = sendToNode(instance.nodeId, { action: "stop", instanceId: instance.id }); + if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } }); } else if (action === "start") { const sent = sendToNode(instance.nodeId, { action: "start", @@ -174,6 +181,13 @@ export async function PATCH(req: NextRequest) { .replace(/{INSTANCE_ID}/g, instance.id) .replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`) .replace(/{PUBLIC_DOMAIN}/g, "localhost"), + initScript: instance.template.initScript + ? instance.template.initScript + .replace(/{PORT}/g, String(instance.port)) + .replace(/{INSTANCE_ID}/g, instance.id) + .replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`) + .replace(/{PUBLIC_DOMAIN}/g, "localhost") + : undefined, }); if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } }); } else if (action === "reset") { @@ -187,6 +201,13 @@ export async function PATCH(req: NextRequest) { .replace(/{INSTANCE_ID}/g, instance.id) .replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`) .replace(/{PUBLIC_DOMAIN}/g, "localhost"), + initScript: instance.template.initScript + ? instance.template.initScript + .replace(/{PORT}/g, String(instance.port)) + .replace(/{INSTANCE_ID}/g, instance.id) + .replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`) + .replace(/{PUBLIC_DOMAIN}/g, "localhost") + : undefined, }); if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } }); } else { diff --git a/server/app/dashboard/download/page.tsx b/server/app/dashboard/download/page.tsx index 2a046eb..52d0aec 100644 --- a/server/app/dashboard/download/page.tsx +++ b/server/app/dashboard/download/page.tsx @@ -1,6 +1,6 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; -const AGENT_VERSION = "0.3.0"; +const AGENT_VERSION = "0.3.8"; const AGENT_BIN_NAME = "studioE5-agent"; export const dynamic = "force-dynamic"; @@ -13,13 +13,33 @@ export default function DownloadPage() {
- Windows + Windows (.exe) -

Agent studioE5 pour Windows (64 bits)

+

Agent studioE5 pour Windows (64 bits). Nécessite Tailscale installé séparément ou les binaires dans tailscale-bin/windows/.

Télécharger (.exe)
+ + + + Windows (archive) + + +

Archive complète incluant l'agent, Tailscale et le README Windows.

+ Télécharger (.zip) +
+
+ + + + Linux + + +

Agent studioE5 pour Linux (64 bits).

+ Télécharger (Linux) +
+
); diff --git a/server/lib/websocket.ts b/server/lib/websocket.ts index b227cd4..4851109 100644 --- a/server/lib/websocket.ts +++ b/server/lib/websocket.ts @@ -264,35 +264,37 @@ export function initWebSocketServer(wss: WebSocketServer) { } if (msg.action === "instance_started" && msg.instanceId) { - await prisma.instance.update({ + const { count } = await prisma.instance.updateMany({ where: { id: msg.instanceId }, data: { status: "running" }, }); + if (count) console.log("[WS] Instance started:", msg.instanceId); return; } if (msg.action === "instance_stopped" && msg.instanceId) { - await prisma.instance.update({ + const { count } = await prisma.instance.updateMany({ where: { id: msg.instanceId }, data: { status: "stopped" }, }); - console.log("[WS] Instance stopped:", msg.instanceId); + if (count) console.log("[WS] Instance stopped:", msg.instanceId); return; } if (msg.action === "instance_deleted" && msg.instanceId) { - await prisma.instance.delete({ + const { count } = await prisma.instance.deleteMany({ where: { id: msg.instanceId }, }); - console.log("[WS] Instance deleted:", msg.instanceId); + if (count) console.log("[WS] Instance deleted:", msg.instanceId); return; } if (msg.action === "instance_error" && msg.instanceId) { - await prisma.instance.update({ + const { count } = await prisma.instance.updateMany({ where: { id: msg.instanceId }, data: { status: "error" }, }); + if (count) console.log("[WS] Instance error:", msg.instanceId); return; } } catch (err) { diff --git a/server/prisma/migrations/20250626125000_add_init_script_to_template/migration.sql b/server/prisma/migrations/20250626125000_add_init_script_to_template/migration.sql new file mode 100644 index 0000000..53a2b04 --- /dev/null +++ b/server/prisma/migrations/20250626125000_add_init_script_to_template/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Template" ADD COLUMN "initScript" TEXT; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index ea42644..af7c239 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -91,6 +91,7 @@ model Template { type String dockerImage String composeConfig String + initScript String? isPublic Boolean @default(true) establishmentId String? establishment Establishment? @relation(fields: [establishmentId], references: [id], onDelete: Cascade) diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index 30e3d19..eb3b523 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -1,5 +1,7 @@ import { PrismaClient } from "@prisma/client"; import bcrypt from "bcryptjs"; +import fs from "fs"; +import path from "path"; const prisma = new PrismaClient(); @@ -19,6 +21,11 @@ async function main() { }, }); + const wpReadyInitScript = fs.readFileSync( + path.join(__dirname, "../templates/wordpress-ready/wp-init.sh"), + "utf-8" + ); + const templates = [ { name: "WordPress latest vierge", @@ -50,6 +57,30 @@ async function main() { dbPassword: "wordpress", dbRootPassword: "rootpassword", }, + { + name: "WordPress latest prêt à l'emploi", + type: "wordpress-ready", + dockerImage: "wordpress:latest", + dbImage: "mariadb:10.11", + dbName: "wordpress", + dbUser: "wordpress", + dbPassword: "wordpress", + dbRootPassword: "rootpassword", + ready: true, + initScript: wpReadyInitScript, + }, + { + name: "WordPress 7.0.0 prêt à l'emploi", + type: "wordpress-ready", + dockerImage: "wordpress:7.0.0-php8.3", + dbImage: "mariadb:10.11", + dbName: "wordpress", + dbUser: "wordpress", + dbPassword: "wordpress", + dbRootPassword: "rootpassword", + ready: true, + initScript: wpReadyInitScript, + }, { name: "PrestaShop 9 vierge (edubox)", type: "prestashop", @@ -66,6 +97,7 @@ async function main() { const dbHost = "db"; const dbPort = "3306"; const isPrestaShop = t.type === "prestashop"; + const isWordPressReady = (t as any).ready === true; const appEnv = isPrestaShop ? ` DB_SERVER: ${dbHost} @@ -93,6 +125,12 @@ async function main() { WORDPRESS_DB_PREFIX: wp_ # No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header`; + const appDNS = isWordPressReady + ? ` dns: + - 8.8.8.8 + - 1.1.1.1` + : ""; + const appVolumes = isPrestaShop ? ` volumes: - app_data:/var/www/html` @@ -100,6 +138,28 @@ async function main() { - app_data:/var/www/html - {MU_PLUGINS_DIR}/edubox-public-url.php:/var/www/html/wp-content/mu-plugins/edubox-public-url.php:ro`; + const wpInitService = isWordPressReady + ? ` wp-init: + image: wordpress:cli + user: "0:0" + environment: + WORDPRESS_DB_HOST: ${dbHost}:${dbPort} + WORDPRESS_DB_NAME: ${t.dbName} + WORDPRESS_DB_USER: ${t.dbUser} + WORDPRESS_DB_PASSWORD: ${t.dbPassword} + depends_on: + db: + condition: service_healthy + app: + condition: service_started + volumes: + - app_data:/var/www/html + - ./wp-init.sh:/wp-init.sh:ro + restart: "no" + entrypoint: ["/bin/sh", "/wp-init.sh"] +` + : ""; + const composeConfig = `services: db: image: ${t.dbImage} @@ -123,24 +183,28 @@ async function main() { environment: ${appEnv} INSTANCE_ID: {INSTANCE_ID} +${appDNS} depends_on: db: condition: service_healthy ${appVolumes} restart: unless-stopped -volumes: +${wpInitService}volumes: db_data: app_data: `; + const initScript = isWordPressReady ? wpReadyInitScript : null; + await prisma.template.upsert({ where: { id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}` }, - update: { composeConfig }, + update: { composeConfig, initScript }, create: { id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}`, name: t.name, type: t.type, dockerImage: t.dockerImage, composeConfig, + initScript, isPublic: true, createdBy: "system", }, diff --git a/server/templates/wordpress-ready/wp-init.sh b/server/templates/wordpress-ready/wp-init.sh new file mode 100644 index 0000000..8728cc9 --- /dev/null +++ b/server/templates/wordpress-ready/wp-init.sh @@ -0,0 +1,47 @@ +#!/bin/sh +set -e + +WP_PATH=/var/www/html +FLAG=$WP_PATH/.studioe5-init-done +WP_USER=www-data + +if [ -f "$FLAG" ]; then + echo "[studioE5] WordPress already initialized." + exit 0 +fi + +echo "[studioE5] Waiting for WordPress config and database..." +until [ -f "$WP_PATH/wp-config.php" ] && wp --path="$WP_PATH" db query "SELECT 1" --allow-root > /dev/null 2>&1; do + sleep 2 +done + +echo "[studioE5] Fixing permissions..." +# Ensure the web server (www-data) can write to wp-content and wp-config.php. +chmod -R 777 "$WP_PATH/wp-content" +chmod 666 "$WP_PATH/wp-config.php" + +run_wp() { + su -s /bin/sh "$WP_USER" -c "wp --path=$WP_PATH $1" +} + +echo "[studioE5] Installing WordPress..." +run_wp "core install --url='{PUBLIC_URL}' --title='Mon site wordpress' --admin_user='admin' --admin_password='admin' --admin_email='admin@example.com' --skip-email --allow-root" + +echo "[studioE5] Setting language to French..." +run_wp "language core install fr_FR --activate --allow-root" || true + +echo "[studioE5] Installing and activating theme Astra..." +run_wp "theme install astra --activate --allow-root" || true + +echo "[studioE5] Installing plugins..." +run_wp "plugin install wordpress-seo --allow-root" || true +run_wp "plugin install ultimate-addons-for-gutenberg --activate --allow-root" || true + +echo "[studioE5] Disabling automatic updates..." +run_wp "config set AUTOMATIC_UPDATER_DISABLED true --raw --allow-root" || true +run_wp "config set WP_AUTO_UPDATE_CORE false --raw --allow-root" || true +run_wp "config set AUTO_UPDATE_PLUGIN false --raw --allow-root" || true +run_wp "config set AUTO_UPDATE_THEME false --raw --allow-root" || true + +touch "$FLAG" +echo "[studioE5] Initialization complete."