From e946b22a42a164b9c84e44fb17a4e55512dd3dd6 Mon Sep 17 00:00:00 2001 From: EduBox Dev Date: Sat, 27 Jun 2026 21:11:20 +0000 Subject: [PATCH] feat(agent): v0.3.9 sync, UI details, self-update, centralized version - Add agent/server startup sync (sync/sync_response) - Centralize agent version in agent/VERSION + expose /api/agent/version - Display agent version, nodeId and server version in local UI - Add agent self-update detection/download/restart via helper scripts - Run start/stop/delete/reset handlers in goroutines to avoid WebSocket blocking - Update dashboard download links and SUIVI_VPN_ONDEMAND.md - Document Podman stays installer-managed, not agent-updated --- SUIVI_VPN_ONDEMAND.md | 26 ++- agent/VERSION | 1 + agent/build.sh | 2 +- agent/main.go | 1 + agent/ui.go | 32 ++++ agent/ui/index.html | 63 +++++++ agent/update.go | 223 +++++++++++++++++++++++++ agent/websocket.go | 173 +++++++++++++++---- docker-compose.yml | 1 + server/app/api/agent/version/route.ts | 6 + server/app/api/download/route.ts | 13 +- server/app/dashboard/download/page.tsx | 15 +- server/lib/agent-version.ts | 55 ++++++ server/lib/websocket.ts | 61 ++++++- 14 files changed, 613 insertions(+), 59 deletions(-) create mode 100644 agent/VERSION create mode 100644 agent/update.go create mode 100644 server/app/api/agent/version/route.ts create mode 100644 server/lib/agent-version.ts diff --git a/SUIVI_VPN_ONDEMAND.md b/SUIVI_VPN_ONDEMAND.md index 4ad022f..99cf7b6 100644 --- a/SUIVI_VPN_ONDEMAND.md +++ b/SUIVI_VPN_ONDEMAND.md @@ -426,11 +426,11 @@ L’agent est servi par Caddy depuis le dossier `agent/` monté dans le conteneu ### Binaires disponibles -- **Windows (archive complète)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5-windows.zip` +- **Windows (archive complète)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.9-windows.zip` - Contient `studioE5-agent.exe` + `tailscale-bin/windows/` (`tailscale.exe`, `tailscaled.exe`, `wintun.dll`) + `README-Windows.txt`. -- **Windows (exécutable seul)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5.exe` +- **Windows (exécutable seul)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.9.exe` - Nécessite d’avoir installé Tailscale Windows séparément ou d’avoir les binaires dans `tailscale-bin/windows/`. -- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5` +- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.9` ### Builder / préparer les binaires @@ -444,13 +444,13 @@ cd /opt/studioe5-client-a/agent ./build.sh ``` -Le `build.sh` génère automatiquement `studioE5-agent-v0.3.5-windows.zip` et copie les binaires versionnés dans `server/public/`. +Le `build.sh` génère automatiquement `studioE5-agent-v0.3.9-windows.zip` et copie les binaires versionnés dans `server/public/`. ### Flow d’activation zéro-config (modèle commercialisable) L’élève/employé n’a **aucune configuration technique** à saisir : -1. **Télécharger** l’agent Windows (`studioE5-agent-v0.3.4-windows.zip`). +1. **Télécharger** l’agent Windows (`studioE5-agent-v0.3.9-windows.zip`). 2. **Extraire** et **lancer** `studioE5-agent.exe`. 3. **Entrer le code d’activation** à 6 caractères fourni par l’établissement (affiché dans l’UI locale `http://localhost:7070`). 4. L’agent contacte le serveur, le serveur vérifie le code et renvoie **automatiquement** : @@ -776,6 +776,11 @@ Créer un package d’installation unique et professionnel par OS, incluant l’ - 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). +### Mise à jour de l’agent vs dépendances système + +- **L’agent peut se mettre à jour lui-même** (binaire + fichiers) depuis la v0.3.9. +- **Podman / Docker / Tailscale restent gérés par l’installateur** : l’agent vérifie leur présence et alertera l’utilisateur si une dépendance est manquante ou trop ancienne, mais ne les met pas à jour automatiquement (droits élevés, risque de casser les machines Podman, etc.). + --- ## 📋 Prochaines étapes à faire @@ -785,6 +790,7 @@ Créer un package d’installation unique et professionnel par OS, incluant l’ - [x] Rate limit Let’s Encrypt levé. - [x] Flux HTTPS public validé (`test-wp-001.studioe5.edudeploy.com`). - [x] Branche `feat/studioe5-vpn-ondemand` créée, commit `124543d`. +- [x] **Pousser la branche** vers Gitea : la branche est synchronisée sur `origin/feat/studioe5-vpn-ondemand` (commit `cf8b663`). - [x] Flux complet UI → API → WebSocket → agent → Docker → VPN → Caddy validé. - [x] Packager les binaires Tailscale pour Windows (`studioE5-agent-v0.3.5-windows.zip`). - [x] **Sécurité – authentification du canal serveur → agent** (token par nœud, clé API interne, sessions NextAuth sur les routes API). @@ -797,13 +803,17 @@ Créer un package d’installation unique et professionnel par OS, incluant l’ - [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, 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`. +- [x] **Nettoyer les instances/agent de test** (2026-06-27) : agent de test arrêté (`vps-8fc665eb`), `tailscaled` associé arrêté, data-dir `/tmp/studioe5-test-clienta` supprimé ; **13 instances de test supprimées de la base PostgreSQL** (`vps-8fc665eb` + `OMEGA-GAMER-60d7f87c`). +- [x] **Nettoyer les anciens nodes/volumes Headscale de test** (2026-06-27) : nœuds `edubox`, `prof`, `invalid-*`, anciens `vps-8fc665eb`, anciens `studioe5-resolver` et `test-node-b` supprimés ; volume Docker anonyme orphelin supprimé. +- [x] **Centralisation de la version agent** : fichier unique `agent/VERSION`, API `GET /api/agent/version`, dashboard et route `/api/download` alignés. +- [x] **Agent v0.3.9 – synchronisation agent ↔ serveur au démarrage** : protocole `sync` / `sync_response`, suppression/lancement automatique des instances décalées pendant un offline. +- [x] **Agent v0.3.9 – détails techniques dans l’UI locale** : version de l’agent, nodeId, version attendue par le serveur, notification de mise à jour. +- [x] **Agent v0.3.9 – mise à jour automatique de l’agent** : détection de nouvelle version, téléchargement, remplacement du binaire via script helper et redémarrage. +- [x] **Agent v0.3.9 – handlers asynchrones** : `start`, `stop`, `delete`, `reset` exécutés dans des goroutines pour ne plus bloquer la boucle WebSocket. ### ⏳ Reste à faire - [ ] **Certificat wildcard** : transféré au deployeur (`docs/ONBOARDING_CLIENT.md`). L’étude technique reste disponible ci-dessous pour référence. -- [ ] **Nettoyer les instances/agent de test** une fois le push effectué. -- [ ] **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)** : diff --git a/agent/VERSION b/agent/VERSION new file mode 100644 index 0000000..940ac09 --- /dev/null +++ b/agent/VERSION @@ -0,0 +1 @@ +0.3.9 diff --git a/agent/build.sh b/agent/build.sh index f3db3e9..0aed78c 100755 --- a/agent/build.sh +++ b/agent/build.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -VERSION="0.3.8" +VERSION="$(cat "$(dirname "$0")/VERSION")" APP_NAME="studioE5" BIN_NAME="studioE5-agent" LDFLAGS="-X main.version=${VERSION}" diff --git a/agent/main.go b/agent/main.go index 871b150..bbb05f4 100644 --- a/agent/main.go +++ b/agent/main.go @@ -71,6 +71,7 @@ func main() { } go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey) + go updateCheckerLoop(*dataDir, cfg.Server) shutdownCh := make(chan struct{}) diff --git a/agent/ui.go b/agent/ui.go index 4bcb4cb..80249a0 100644 --- a/agent/ui.go +++ b/agent/ui.go @@ -56,6 +56,8 @@ func startUI(dataDir, nodeID, serverAddr string) { return } // Expose a merged view with the agent version for the UI. + serverVersion := getServerAgentVersion() + updateAvailable := serverVersion != "" && serverVersion != version response := map[string]interface{}{ "server": cfg.Server, "headscale_url": cfg.HeadscaleURL, @@ -63,6 +65,8 @@ func startUI(dataDir, nodeID, serverAddr string) { "node_id": cfg.NodeID, "data_dir": cfg.DataDir, "version": version, + "server_version": serverVersion, + "update_available": updateAvailable, } json.NewEncoder(w).Encode(response) case http.MethodPost: @@ -104,6 +108,34 @@ func startUI(dataDir, nodeID, serverAddr string) { }() }) + http.HandleFunc("/api/update", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + cfg, _, err := loadOrCreateConfig(dataDir) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + go func() { + broadcastUI(map[string]interface{}{ + "action": "update_progress", + "percent": "10", + "message": "Téléchargement de la mise à jour...", + }) + if err := startAgentUpdate(dataDir, cfg.Server); err != nil { + log.Printf("Agent update failed: %v", err) + broadcastUI(map[string]interface{}{ + "action": "update_progress", + "percent": "0", + "message": "Échec de la mise à jour : " + err.Error(), + }) + } + }() + w.WriteHeader(http.StatusNoContent) + }) + http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { diff --git a/agent/ui/index.html b/agent/ui/index.html index 7a9bbd9..45b9b1a 100644 --- a/agent/ui/index.html +++ b/agent/ui/index.html @@ -479,6 +479,8 @@

Bienvenue

État de ton poste

+
+
@@ -628,6 +630,7 @@ : 'Bienvenue'; showView('dashboard'); ws.send(JSON.stringify({action: 'instances'})); + loadSettings(); break; case 'activation_failed': @@ -671,6 +674,18 @@ case 'config': fillSettings(msg.config); break; + + case 'update_available': + fillSettings({ + ...getCurrentConfig(), + server_version: msg.version, + update_available: true + }); + break; + + case 'update_progress': + showUpdateProgress(msg.percent, msg.message); + break; } } @@ -876,7 +891,14 @@ } } + let currentConfig = {}; + + function getCurrentConfig() { + return currentConfig; + } + function fillSettings(cfg) { + currentConfig = cfg; document.getElementById('cfg-server').value = cfg.server || ''; document.getElementById('cfg-node').value = cfg.node_id || ''; document.getElementById('cfg-headscale-url').value = cfg.headscale_url || ''; @@ -885,6 +907,46 @@ document.getElementById('detail-version').textContent = cfg.version || 'dev'; document.getElementById('detail-nodeid').textContent = cfg.node_id || '-'; document.getElementById('detail-server').textContent = cfg.server || '-'; + + const updateBanner = document.getElementById('update-banner'); + if (cfg.update_available) { + updateBanner.innerHTML = ` +
+
⬆️
+
+
Mise à jour disponible
+
Version ${cfg.server_version} disponible.
+
+
+ `; + } else { + updateBanner.innerHTML = ''; + } + } + + function showUpdateProgress(percent, message) { + const banner = document.getElementById('update-banner'); + banner.innerHTML = ` +
+
⬇️
+
+
Mise à jour en cours
+
${escapeHtml(message || '')} (${percent || 0}%)
+
+
+ `; + } + + async function startAgentUpdate() { + showUpdateProgress('10', 'Préparation de la mise à jour...'); + try { + const res = await fetch('/api/update', {method: 'POST'}); + if (!res.ok) { + showUpdateProgress('0', 'Erreur ' + res.status); + } + } catch (err) { + showUpdateProgress('0', escapeHtml(err.message)); + } } async function saveSettings(event) { @@ -919,6 +981,7 @@ function showDashboard() { showView('dashboard'); ws.send(JSON.stringify({action: 'get_status'})); + loadSettings(); } function controlInstance(instanceId, action) { diff --git a/agent/update.go b/agent/update.go new file mode 100644 index 0000000..2b47d8c --- /dev/null +++ b/agent/update.go @@ -0,0 +1,223 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +const updateCheckInterval = 15 * time.Minute + +// AgentVersionInfo matches the server's /api/agent/version response. +type AgentVersionInfo struct { + Version string `json:"version"` + DownloadUrls struct { + Windows string `json:"windows"` + WindowsZip string `json:"windowsZip"` + Linux string `json:"linux"` + Mac string `json:"mac"` + } `json:"downloadUrls"` +} + +// checkForUpdate fetches the latest agent version from the server and compares +// it with the running binary's version. +func checkForUpdate(serverBaseURL string) (*AgentVersionInfo, bool, error) { + if serverBaseURL == "" { + return nil, false, fmt.Errorf("no server URL configured") + } + wsURL := strings.TrimSuffix(serverBaseURL, "/api/websocket") + url := wsURL + "/api/agent/version" + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, false, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, false, fmt.Errorf("server returned %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, false, err + } + var info AgentVersionInfo + if err := json.Unmarshal(body, &info); err != nil { + return nil, false, err + } + if info.Version == "" { + return nil, false, fmt.Errorf("server returned empty version") + } + available := info.Version != version + return &info, available, nil +} + +// downloadUpdate downloads the new agent binary to the update directory. +func downloadUpdate(dataDir, downloadURL string) (string, error) { + updateDir := filepath.Join(dataDir, "update") + if err := os.MkdirAll(updateDir, 0755); err != nil { + return "", err + } + ext := "" + if runtime.GOOS == "windows" { + ext = ".exe" + } + dest := filepath.Join(updateDir, "studioE5-agent-new"+ext) + log.Printf("Downloading update from %s to %s", downloadURL, dest) + + resp, err := http.Get(downloadURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download returned %d", resp.StatusCode) + } + out, err := os.Create(dest) + if err != nil { + return "", err + } + defer out.Close() + if _, err := io.Copy(out, resp.Body); err != nil { + return "", err + } + if err := out.Close(); err != nil { + return "", err + } + if runtime.GOOS != "windows" { + if err := os.Chmod(dest, 0755); err != nil { + return "", err + } + } + return dest, nil +} + +// applyUpdate replaces the running binary with the downloaded one using an +// external helper script, then exits the current process. +func applyUpdate(currentPath, newPath, dataDir string) error { + pid := os.Getpid() + switch runtime.GOOS { + case "windows": + return applyUpdateWindows(currentPath, newPath, dataDir, pid) + default: + return applyUpdateUnix(currentPath, newPath, dataDir, pid) + } +} + +func applyUpdateWindows(currentPath, newPath, dataDir string, pid int) error { + scriptPath := filepath.Join(dataDir, "update", "apply-update.ps1") + script := fmt.Sprintf(`$old = "%s" +$new = "%s" +$pid = %d +$args = '-no-tray', '-data-dir', '%s' +Wait-Process -Id $pid -ErrorAction SilentlyContinue +Start-Sleep -Seconds 2 +Move-Item -Path $new -Destination $old -Force +Start-Process -FilePath $old -ArgumentList $args -WindowStyle Hidden +`, currentPath, newPath, pid, dataDir) + if err := os.WriteFile(scriptPath, []byte(script), 0644); err != nil { + return err + } + cmd := exec.Command("powershell.exe", "-ExecutionPolicy", "Bypass", "-File", scriptPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return err + } + log.Printf("Update helper started, exiting current process") + os.Exit(0) + return nil +} + +func applyUpdateUnix(currentPath, newPath, dataDir string, pid int) error { + scriptPath := filepath.Join(dataDir, "update", "apply-update.sh") + script := fmt.Sprintf(`#!/bin/bash +set -e +old="%s" +new="%s" +pid=%d +while kill -0 "$pid" 2>/dev/null; do sleep 1; done +sleep 2 +mv "$new" "$old" +chmod +x "$old" +nohup "$old" -no-tray -data-dir "%s" >/dev/null 2>&1 & +`, currentPath, newPath, pid, dataDir) + if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { + return err + } + cmd := exec.Command("/bin/bash", scriptPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return err + } + log.Printf("Update helper started, exiting current process") + os.Exit(0) + return nil +} + +// startAgentUpdate performs the full update flow: download + replace + restart. +func startAgentUpdate(dataDir, serverBaseURL string) error { + info, available, err := checkForUpdate(serverBaseURL) + if err != nil { + return fmt.Errorf("update check failed: %w", err) + } + if !available { + return fmt.Errorf("no update available") + } + currentPath, err := os.Executable() + if err != nil { + return err + } + currentPath, err = filepath.Abs(currentPath) + if err != nil { + return err + } + var downloadURL string + switch runtime.GOOS { + case "windows": + downloadURL = info.DownloadUrls.Windows + case "darwin": + downloadURL = info.DownloadUrls.Mac + default: + downloadURL = info.DownloadUrls.Linux + } + if downloadURL == "" { + return fmt.Errorf("no download URL for %s", runtime.GOOS) + } + newPath, err := downloadUpdate(dataDir, downloadURL) + if err != nil { + return fmt.Errorf("download failed: %w", err) + } + log.Printf("Applying update to version %s", info.Version) + broadcastUI(map[string]interface{}{ + "action": "update_progress", + "percent": "90", + "message": "Redémarrage de l'agent...", + }) + return applyUpdate(currentPath, newPath, dataDir) +} + +// updateCheckerLoop periodically checks for agent updates and notifies the UI. +func updateCheckerLoop(dataDir, serverBaseURL string) { + for { + info, available, err := checkForUpdate(serverBaseURL) + if err == nil && available && info != nil { + log.Printf("Agent update available: %s (current: %s)", info.Version, version) + setServerAgentVersion(info.Version) + broadcastUI(map[string]interface{}{ + "action": "update_available", + "version": info.Version, + "update_available": true, + }) + } + time.Sleep(updateCheckInterval) + } +} diff --git a/agent/websocket.go b/agent/websocket.go index b7dddaa..7746c1c 100644 --- a/agent/websocket.go +++ b/agent/websocket.go @@ -10,22 +10,35 @@ import ( "github.com/gorilla/websocket" ) +type SyncInstanceInfo struct { + ID string `json:"id"` + Type string `json:"type"` + Port int `json:"port"` + ComposeConfig string `json:"composeConfig,omitempty"` + InitScript string `json:"initScript,omitempty"` +} + type WSMessage struct { - Action string `json:"action"` - NodeID string `json:"nodeId,omitempty"` - Code string `json:"code,omitempty"` - InstanceID string `json:"instanceId,omitempty"` - 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"` - TailscaleIP string `json:"tailscaleIp,omitempty"` - HeadscaleURL string `json:"headscaleUrl,omitempty"` - HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"` - Token string `json:"token,omitempty"` + Action string `json:"action"` + NodeID string `json:"nodeId,omitempty"` + Code string `json:"code,omitempty"` + InstanceID string `json:"instanceId,omitempty"` + 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"` + TailscaleIP string `json:"tailscaleIp,omitempty"` + HeadscaleURL string `json:"headscaleUrl,omitempty"` + HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"` + Token string `json:"token,omitempty"` + ServerVersion string `json:"serverVersion,omitempty"` + Instances []InstanceInfo `json:"instances"` + ToStart []SyncInstanceInfo `json:"toStart"` + ToDelete []string `json:"toDelete"` + ToStop []string `json:"toStop"` } var ( @@ -54,6 +67,25 @@ func getHeadscaleConfig() (string, string) { return currentHeadscaleURL, currentHeadscaleAuthKey } +// serverAgentVersion holds the agent version expected by the server. It is used +// to notify the user when an update is available. +var ( + serverAgentVersion string + serverAgentVersionMu sync.RWMutex +) + +func setServerAgentVersion(v string) { + serverAgentVersionMu.Lock() + serverAgentVersion = v + serverAgentVersionMu.Unlock() +} + +func getServerAgentVersion() string { + serverAgentVersionMu.RLock() + defer serverAgentVersionMu.RUnlock() + return serverAgentVersion +} + func sendMessage(msg WSMessage) error { mainConnMu.Lock() defer mainConnMu.Unlock() @@ -63,9 +95,33 @@ func sendMessage(msg WSMessage) error { if msg.Action != "heartbeat" { log.Printf("sendMessage: sending %+v", msg) } + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in sendMessage: %v", r) + } + }() return mainConn.WriteJSON(msg) } +// sendSyncMessage sends the local instance list to the server so it can +// reconcile any differences (instances created/deleted while offline). +func sendSyncMessage(dataDir, nodeID string) { + inst, err := loadInstances(dataDir) + if err != nil { + log.Printf("sendSyncMessage: loadInstances error: %v", err) + return + } + list := make([]InstanceInfo, 0, len(inst)) + for _, info := range inst { + list = append(list, *info) + } + if err := sendMessage(WSMessage{Action: "sync", NodeID: nodeID, Instances: list}); err != nil { + log.Printf("sendSyncMessage error: %v", err) + } else { + log.Printf("sendSyncMessage: sent %d local instances", len(list)) + } +} + // UI notifier system: broadcast activation results to all connected UI clients type uiNotifier func(msg map[string]interface{}) @@ -258,7 +314,15 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) "studentName": msg.StudentName, }) case "registered": - // Server acknowledged our register message; nothing to do. + if msg.ServerVersion != "" { + setServerAgentVersion(msg.ServerVersion) + log.Printf("Server agent version: %s", msg.ServerVersion) + } + // After registration, send a sync request with our local instances so + // the server can reconcile any changes that happened while offline. + if act, err := loadActivation(dataDir); err == nil && act.Activated { + go sendSyncMessage(dataDir, nodeID) + } return case "start_vpn": log.Printf("Server requested VPN start") @@ -327,27 +391,66 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) }() case "stop": log.Printf("Stop instance %s", msg.InstanceID) - if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil { - removeTailscaleServe(inst[msg.InstanceID].Port) - } - 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"}) + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in stop goroutine instance=%s: %v", msg.InstanceID, r) + } + }() + if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil { + removeTailscaleServe(inst[msg.InstanceID].Port) + } + 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) - if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil { - removeTailscaleServe(inst[msg.InstanceID].Port) - } - dockerComposeRm(dataDir, msg.InstanceID) - removeInstance(dataDir, msg.InstanceID) - go sendMessage(WSMessage{Action: "instance_deleted", InstanceID: msg.InstanceID}) - notifyUI(map[string]interface{}{"action": "instances_updated"}) + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in delete goroutine instance=%s: %v", msg.InstanceID, r) + } + }() + if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil { + removeTailscaleServe(inst[msg.InstanceID].Port) + } + 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 "sync_response": + log.Printf("Sync response received: start=%d delete=%d stop=%d", len(msg.ToStart), len(msg.ToDelete), len(msg.ToStop)) + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in sync_response goroutine: %v", r) + } + }() + for _, id := range msg.ToDelete { + handleMessage(mainConn, WSMessage{Action: "delete", InstanceID: id}, dataDir, nodeID) + } + for _, id := range msg.ToStop { + handleMessage(mainConn, WSMessage{Action: "stop", InstanceID: id}, dataDir, nodeID) + } + for _, info := range msg.ToStart { + handleMessage(mainConn, WSMessage{ + Action: "start", + InstanceID: info.ID, + Type: info.Type, + Port: info.Port, + ComposeConfig: info.ComposeConfig, + InitScript: info.InitScript, + }, dataDir, nodeID) + } + }() case "reset": log.Printf("Reset instance %s", msg.InstanceID) dockerComposeRm(dataDir, msg.InstanceID) diff --git a/docker-compose.yml b/docker-compose.yml index 55c53bb..4acf9c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: container_name: studioe5-server volumes: - ./server/public:/app/public:ro + - ./agent/VERSION:/app/agent-version:ro restart: unless-stopped environment: DATABASE_URL: ${DATABASE_URL} diff --git a/server/app/api/agent/version/route.ts b/server/app/api/agent/version/route.ts new file mode 100644 index 0000000..44ba0c0 --- /dev/null +++ b/server/app/api/agent/version/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from "next/server"; +import { getAgentVersionInfo } from "@/lib/agent-version"; + +export async function GET() { + return NextResponse.json(getAgentVersionInfo()); +} diff --git a/server/app/api/download/route.ts b/server/app/api/download/route.ts index d12b9b6..50556ba 100644 --- a/server/app/api/download/route.ts +++ b/server/app/api/download/route.ts @@ -1,13 +1,12 @@ import { NextResponse } from "next/server"; - -const AGENT_VERSION = "0.3.4"; -const AGENT_BIN_NAME = "studioE5-agent"; +import { getAgentVersionInfo } from "@/lib/agent-version"; export async function GET() { + const info = getAgentVersionInfo(); return NextResponse.json({ - version: AGENT_VERSION, - windows: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}.exe`, - linux: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}`, - mac: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}-mac`, + version: info.version, + windows: info.downloadUrls.windows, + linux: info.downloadUrls.linux, + mac: info.downloadUrls.mac, }); } diff --git a/server/app/dashboard/download/page.tsx b/server/app/dashboard/download/page.tsx index 52d0aec..92565fe 100644 --- a/server/app/dashboard/download/page.tsx +++ b/server/app/dashboard/download/page.tsx @@ -1,15 +1,16 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; - -const AGENT_VERSION = "0.3.8"; -const AGENT_BIN_NAME = "studioE5-agent"; +import { getAgentVersionInfo } from "@/lib/agent-version"; export const dynamic = "force-dynamic"; export default function DownloadPage() { + const info = getAgentVersionInfo(); + const { version, downloadUrls } = info; + return (

Téléchargements Agent

-

Version actuelle : {AGENT_VERSION}

+

Version actuelle : {version}

@@ -17,7 +18,7 @@ export default function DownloadPage() {

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

- Télécharger (.exe) + Télécharger (.exe)
@@ -27,7 +28,7 @@ export default function DownloadPage() {

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

- Télécharger (.zip) + Télécharger (.zip)
@@ -37,7 +38,7 @@ export default function DownloadPage() {

Agent studioE5 pour Linux (64 bits).

- Télécharger (Linux) + Télécharger (Linux)
diff --git a/server/lib/agent-version.ts b/server/lib/agent-version.ts new file mode 100644 index 0000000..4e1152b --- /dev/null +++ b/server/lib/agent-version.ts @@ -0,0 +1,55 @@ +import fs from "fs"; +import path from "path"; + +const BIN_NAME = "studioE5-agent"; + +function findVersionFile(): string | null { + // Try a few common paths relative to the server workspace and Next.js build output. + const candidates = [ + path.join(process.cwd(), "..", "agent", "VERSION"), + path.join(process.cwd(), "..", "..", "agent", "VERSION"), + path.join(process.cwd(), "agent", "VERSION"), + path.join(__dirname, "..", "..", "..", "agent", "VERSION"), + path.join(__dirname, "..", "..", "agent", "VERSION"), + "/app/agent-version", + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} + +export function getAgentVersion(): string { + const versionFile = findVersionFile(); + if (versionFile) { + return fs.readFileSync(versionFile, "utf-8").trim(); + } + // Fallback used when the agent workspace is not mounted (should not happen). + return "0.3.9"; +} + +export interface AgentDownloadUrls { + windows: string; + windowsZip: string; + linux: string; + mac: string; +} + +export function getAgentDownloadUrls(version: string): AgentDownloadUrls { + return { + windows: `/${BIN_NAME}-v${version}.exe`, + windowsZip: `/${BIN_NAME}-v${version}-windows.zip`, + linux: `/${BIN_NAME}-v${version}`, + mac: `/${BIN_NAME}-v${version}-mac`, + }; +} + +export function getAgentVersionInfo() { + const version = getAgentVersion(); + return { + version, + downloadUrls: getAgentDownloadUrls(version), + }; +} diff --git a/server/lib/websocket.ts b/server/lib/websocket.ts index 4851109..11a86de 100644 --- a/server/lib/websocket.ts +++ b/server/lib/websocket.ts @@ -3,6 +3,7 @@ import { randomBytes } from "crypto"; import type { IncomingMessage } from "http"; import { prisma } from "./prisma"; import { createEphemeralPreAuthKey, getHeadscaleUserId } from "./headscale"; +import { getAgentVersion } from "./agent-version"; interface NodeMessage { action: string; @@ -12,10 +13,16 @@ interface NodeMessage { type?: string; port?: number; composeConfig?: string; + initScript?: string; studentName?: string; error?: string; tailscaleIp?: string; token?: string; + serverVersion?: string; + instances?: Array<{ id: string; status: string; port: number; templateName?: string }>; + toStart?: Array<{ id: string; type: string; port: number; composeConfig?: string; initScript?: string }>; + toDelete?: string[]; + toStop?: string[]; } const nodes = new Map(); @@ -126,7 +133,7 @@ export function initWebSocketServer(wss: WebSocketServer) { create: { id, status: "online", lastSeen: new Date() }, }); } - ws.send(JSON.stringify({ action: "registered" })); + ws.send(JSON.stringify({ action: "registered", serverVersion: getAgentVersion() })); return; } @@ -263,6 +270,58 @@ export function initWebSocketServer(wss: WebSocketServer) { return; } + if (msg.action === "sync" && msg.instances) { + const serverInstances = await prisma.instance.findMany({ + where: { nodeId }, + include: { template: true }, + }); + + const localIds = new Set(msg.instances.map((i) => i.id)); + const serverIds = new Set(serverInstances.map((i) => i.id)); + + const toDelete = msg.instances + .filter((i) => !serverIds.has(i.id)) + .map((i) => i.id); + + const toStop = msg.instances + .filter((i) => { + const server = serverInstances.find((s) => s.id === i.id); + return server && server.status === "stopped" && i.status === "running"; + }) + .map((i) => i.id); + + const toStart = serverInstances + .filter((s) => !localIds.has(s.id)) + .map((s) => ({ + id: s.id, + type: s.template.type, + port: s.port, + composeConfig: s.template.composeConfig, + initScript: s.template.initScript ?? undefined, + })); + + console.log( + "[WS] Sync for", + nodeId, + "- toStart:", + toStart.length, + "toDelete:", + toDelete.length, + "toStop:", + toStop.length + ); + + ws.send( + JSON.stringify({ + action: "sync_response", + toStart, + toDelete, + toStop, + }) + ); + return; + } + if (msg.action === "instance_started" && msg.instanceId) { const { count } = await prisma.instance.updateMany({ where: { id: msg.instanceId },