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
This commit is contained in:
EduBox Dev
2026-06-27 21:11:20 +00:00
parent cf8b66340a
commit e946b22a42
14 changed files with 613 additions and 59 deletions
+18 -8
View File
@@ -426,11 +426,11 @@ Lagent est servi par Caddy depuis le dossier `agent/` monté dans le conteneu
### Binaires disponibles ### 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`. - 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 davoir installé Tailscale Windows séparément ou davoir les binaires dans `tailscale-bin/windows/`. - Nécessite davoir installé Tailscale Windows séparément ou davoir 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 ### Builder / préparer les binaires
@@ -444,13 +444,13 @@ cd /opt/studioe5-client-a/agent
./build.sh ./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 dactivation zéro-config (modèle commercialisable) ### Flow dactivation zéro-config (modèle commercialisable)
L’élève/employé na **aucune configuration technique** à saisir : L’élève/employé na **aucune configuration technique** à saisir :
1. **Télécharger** lagent Windows (`studioE5-agent-v0.3.4-windows.zip`). 1. **Télécharger** lagent Windows (`studioE5-agent-v0.3.9-windows.zip`).
2. **Extraire** et **lancer** `studioE5-agent.exe`. 2. **Extraire** et **lancer** `studioE5-agent.exe`.
3. **Entrer le code dactivation** à 6 caractères fourni par l’établissement (affiché dans lUI locale `http://localhost:7070`). 3. **Entrer le code dactivation** à 6 caractères fourni par l’établissement (affiché dans lUI locale `http://localhost:7070`).
4. Lagent contacte le serveur, le serveur vérifie le code et renvoie **automatiquement** : 4. Lagent contacte le serveur, le serveur vérifie le code et renvoie **automatiquement** :
@@ -776,6 +776,11 @@ Créer un package dinstallation 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. - Vérifier au démarrage que la machine Podman est démarrée, et lancer `podman machine start` si besoin.
- Gérer proprement larrêt de la machine à la fermeture de lagent (optionnel). - Gérer proprement larrêt de la machine à la fermeture de lagent (optionnel).
### Mise à jour de lagent vs dépendances système
- **Lagent peut se mettre à jour lui-même** (binaire + fichiers) depuis la v0.3.9.
- **Podman / Docker / Tailscale restent gérés par linstallateur** : lagent vérifie leur présence et alertera lutilisateur 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 ## 📋 Prochaines étapes à faire
@@ -785,6 +790,7 @@ Créer un package dinstallation unique et professionnel par OS, incluant l
- [x] Rate limit Lets Encrypt levé. - [x] Rate limit Lets Encrypt levé.
- [x] Flux HTTPS public validé (`test-wp-001.studioe5.edudeploy.com`). - [x] Flux HTTPS public validé (`test-wp-001.studioe5.edudeploy.com`).
- [x] Branche `feat/studioe5-vpn-ondemand` créée, commit `124543d`. - [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] 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] 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). - [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 dinstallation 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] **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] **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 à lemploi** (`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] **Template WordPress prêt à lemploi** (`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 lUI locale** : version de lagent, nodeId, version attendue par le serveur, notification de mise à jour.
- [x] **Agent v0.3.9 mise à jour automatique de lagent** : 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 ### ⏳ Reste à faire
- [ ] **Certificat wildcard** : transféré au deployeur (`docs/ONBOARDING_CLIENT.md`). L’étude technique reste disponible ci-dessous pour référence. - [ ] **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.). - [ ] **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 dinstallation unique incluant lagent 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. - [ ] **Installateur agent professionnel (Windows / macOS / Linux)** : créer un package dinstallation unique incluant lagent 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 à lemploi (usage examen/classe)** : - [ ] **Template WordPress prêt à lemploi (usage examen/classe)** :
+1
View File
@@ -0,0 +1 @@
0.3.9
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
set -e set -e
VERSION="0.3.8" VERSION="$(cat "$(dirname "$0")/VERSION")"
APP_NAME="studioE5" APP_NAME="studioE5"
BIN_NAME="studioE5-agent" BIN_NAME="studioE5-agent"
LDFLAGS="-X main.version=${VERSION}" LDFLAGS="-X main.version=${VERSION}"
+1
View File
@@ -71,6 +71,7 @@ func main() {
} }
go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey) go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey)
go updateCheckerLoop(*dataDir, cfg.Server)
shutdownCh := make(chan struct{}) shutdownCh := make(chan struct{})
+32
View File
@@ -56,6 +56,8 @@ func startUI(dataDir, nodeID, serverAddr string) {
return return
} }
// Expose a merged view with the agent version for the UI. // Expose a merged view with the agent version for the UI.
serverVersion := getServerAgentVersion()
updateAvailable := serverVersion != "" && serverVersion != version
response := map[string]interface{}{ response := map[string]interface{}{
"server": cfg.Server, "server": cfg.Server,
"headscale_url": cfg.HeadscaleURL, "headscale_url": cfg.HeadscaleURL,
@@ -63,6 +65,8 @@ func startUI(dataDir, nodeID, serverAddr string) {
"node_id": cfg.NodeID, "node_id": cfg.NodeID,
"data_dir": cfg.DataDir, "data_dir": cfg.DataDir,
"version": version, "version": version,
"server_version": serverVersion,
"update_available": updateAvailable,
} }
json.NewEncoder(w).Encode(response) json.NewEncoder(w).Encode(response)
case http.MethodPost: 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) { http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
+63
View File
@@ -479,6 +479,8 @@
<h1 id="welcome-text">Bienvenue</h1> <h1 id="welcome-text">Bienvenue</h1>
<p class="subtitle">État de ton poste</p> <p class="subtitle">État de ton poste</p>
<div id="update-banner"></div>
<div class="services"> <div class="services">
<div class="service-item" id="svc-connection"> <div class="service-item" id="svc-connection">
<div class="service-icon pending"></div> <div class="service-icon pending"></div>
@@ -628,6 +630,7 @@
: 'Bienvenue'; : 'Bienvenue';
showView('dashboard'); showView('dashboard');
ws.send(JSON.stringify({action: 'instances'})); ws.send(JSON.stringify({action: 'instances'}));
loadSettings();
break; break;
case 'activation_failed': case 'activation_failed':
@@ -671,6 +674,18 @@
case 'config': case 'config':
fillSettings(msg.config); fillSettings(msg.config);
break; 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) { function fillSettings(cfg) {
currentConfig = cfg;
document.getElementById('cfg-server').value = cfg.server || ''; document.getElementById('cfg-server').value = cfg.server || '';
document.getElementById('cfg-node').value = cfg.node_id || ''; document.getElementById('cfg-node').value = cfg.node_id || '';
document.getElementById('cfg-headscale-url').value = cfg.headscale_url || ''; document.getElementById('cfg-headscale-url').value = cfg.headscale_url || '';
@@ -885,6 +907,46 @@
document.getElementById('detail-version').textContent = cfg.version || 'dev'; document.getElementById('detail-version').textContent = cfg.version || 'dev';
document.getElementById('detail-nodeid').textContent = cfg.node_id || '-'; document.getElementById('detail-nodeid').textContent = cfg.node_id || '-';
document.getElementById('detail-server').textContent = cfg.server || '-'; document.getElementById('detail-server').textContent = cfg.server || '-';
const updateBanner = document.getElementById('update-banner');
if (cfg.update_available) {
updateBanner.innerHTML = `
<div class="service-item" style="background: var(--warning-bg); border-radius: 8px; padding: 0.75rem; margin-bottom: 0.75rem;">
<div class="service-icon" style="background: var(--warning);">⬆️</div>
<div>
<div class="service-text">Mise à jour disponible</div>
<div class="service-detail">Version ${cfg.server_version} disponible. <button class="ghost" style="padding: 0; font-weight: 600;" onclick="startAgentUpdate()">Mettre à jour maintenant</button></div>
</div>
</div>
`;
} else {
updateBanner.innerHTML = '';
}
}
function showUpdateProgress(percent, message) {
const banner = document.getElementById('update-banner');
banner.innerHTML = `
<div class="service-item" style="background: var(--info-bg); border-radius: 8px; padding: 0.75rem; margin-bottom: 0.75rem;">
<div class="service-icon" style="background: var(--info);">⬇️</div>
<div>
<div class="service-text">Mise à jour en cours</div>
<div class="service-detail">${escapeHtml(message || '')} (${percent || 0}%)</div>
</div>
</div>
`;
}
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) { async function saveSettings(event) {
@@ -919,6 +981,7 @@
function showDashboard() { function showDashboard() {
showView('dashboard'); showView('dashboard');
ws.send(JSON.stringify({action: 'get_status'})); ws.send(JSON.stringify({action: 'get_status'}));
loadSettings();
} }
function controlInstance(instanceId, action) { function controlInstance(instanceId, action) {
+223
View File
@@ -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)
}
}
+138 -35
View File
@@ -10,22 +10,35 @@ import (
"github.com/gorilla/websocket" "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 { type WSMessage struct {
Action string `json:"action"` Action string `json:"action"`
NodeID string `json:"nodeId,omitempty"` NodeID string `json:"nodeId,omitempty"`
Code string `json:"code,omitempty"` Code string `json:"code,omitempty"`
InstanceID string `json:"instanceId,omitempty"` InstanceID string `json:"instanceId,omitempty"`
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
Port int `json:"port,omitempty"` Port int `json:"port,omitempty"`
ComposeConfig string `json:"composeConfig,omitempty"` ComposeConfig string `json:"composeConfig,omitempty"`
InitScript string `json:"initScript,omitempty"` InitScript string `json:"initScript,omitempty"`
StudentId string `json:"studentId,omitempty"` StudentId string `json:"studentId,omitempty"`
StudentName string `json:"studentName,omitempty"` StudentName string `json:"studentName,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
TailscaleIP string `json:"tailscaleIp,omitempty"` TailscaleIP string `json:"tailscaleIp,omitempty"`
HeadscaleURL string `json:"headscaleUrl,omitempty"` HeadscaleURL string `json:"headscaleUrl,omitempty"`
HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"` HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"`
Token string `json:"token,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 ( var (
@@ -54,6 +67,25 @@ func getHeadscaleConfig() (string, string) {
return currentHeadscaleURL, currentHeadscaleAuthKey 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 { func sendMessage(msg WSMessage) error {
mainConnMu.Lock() mainConnMu.Lock()
defer mainConnMu.Unlock() defer mainConnMu.Unlock()
@@ -63,9 +95,33 @@ func sendMessage(msg WSMessage) error {
if msg.Action != "heartbeat" { if msg.Action != "heartbeat" {
log.Printf("sendMessage: sending %+v", msg) log.Printf("sendMessage: sending %+v", msg)
} }
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in sendMessage: %v", r)
}
}()
return mainConn.WriteJSON(msg) 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 // UI notifier system: broadcast activation results to all connected UI clients
type uiNotifier func(msg map[string]interface{}) type uiNotifier func(msg map[string]interface{})
@@ -258,7 +314,15 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
"studentName": msg.StudentName, "studentName": msg.StudentName,
}) })
case "registered": 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 return
case "start_vpn": case "start_vpn":
log.Printf("Server requested VPN start") log.Printf("Server requested VPN start")
@@ -327,27 +391,66 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
}() }()
case "stop": case "stop":
log.Printf("Stop instance %s", msg.InstanceID) log.Printf("Stop instance %s", msg.InstanceID)
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil { go func() {
removeTailscaleServe(inst[msg.InstanceID].Port) defer func() {
} if r := recover(); r != nil {
if err := dockerComposeStop(dataDir, msg.InstanceID); err != nil { log.Printf("PANIC in stop goroutine instance=%s: %v", msg.InstanceID, r)
log.Printf("dockerComposeStop error: %v", err) }
} }()
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil { if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
inst[msg.InstanceID].Status = "stopped" removeTailscaleServe(inst[msg.InstanceID].Port)
_ = saveInstances(dataDir, inst) }
} if err := dockerComposeStop(dataDir, msg.InstanceID); err != nil {
go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: msg.InstanceID}) log.Printf("dockerComposeStop error: %v", err)
notifyUI(map[string]interface{}{"action": "instances_updated"}) }
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": case "delete":
log.Printf("Delete instance %s", msg.InstanceID) log.Printf("Delete instance %s", msg.InstanceID)
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil { go func() {
removeTailscaleServe(inst[msg.InstanceID].Port) defer func() {
} if r := recover(); r != nil {
dockerComposeRm(dataDir, msg.InstanceID) log.Printf("PANIC in delete goroutine instance=%s: %v", msg.InstanceID, r)
removeInstance(dataDir, msg.InstanceID) }
go sendMessage(WSMessage{Action: "instance_deleted", InstanceID: msg.InstanceID}) }()
notifyUI(map[string]interface{}{"action": "instances_updated"}) 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": case "reset":
log.Printf("Reset instance %s", msg.InstanceID) log.Printf("Reset instance %s", msg.InstanceID)
dockerComposeRm(dataDir, msg.InstanceID) dockerComposeRm(dataDir, msg.InstanceID)
+1
View File
@@ -24,6 +24,7 @@ services:
container_name: studioe5-server container_name: studioe5-server
volumes: volumes:
- ./server/public:/app/public:ro - ./server/public:/app/public:ro
- ./agent/VERSION:/app/agent-version:ro
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
+6
View File
@@ -0,0 +1,6 @@
import { NextResponse } from "next/server";
import { getAgentVersionInfo } from "@/lib/agent-version";
export async function GET() {
return NextResponse.json(getAgentVersionInfo());
}
+6 -7
View File
@@ -1,13 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getAgentVersionInfo } from "@/lib/agent-version";
const AGENT_VERSION = "0.3.4";
const AGENT_BIN_NAME = "studioE5-agent";
export async function GET() { export async function GET() {
const info = getAgentVersionInfo();
return NextResponse.json({ return NextResponse.json({
version: AGENT_VERSION, version: info.version,
windows: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}.exe`, windows: info.downloadUrls.windows,
linux: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}`, linux: info.downloadUrls.linux,
mac: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}-mac`, mac: info.downloadUrls.mac,
}); });
} }
+8 -7
View File
@@ -1,15 +1,16 @@
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { getAgentVersionInfo } from "@/lib/agent-version";
const AGENT_VERSION = "0.3.8";
const AGENT_BIN_NAME = "studioE5-agent";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default function DownloadPage() { export default function DownloadPage() {
const info = getAgentVersionInfo();
const { version, downloadUrls } = info;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-3xl font-bold">Téléchargements Agent</h1> <h1 className="text-3xl font-bold">Téléchargements Agent</h1>
<p className="text-sm text-muted-foreground">Version actuelle : <strong>{AGENT_VERSION}</strong></p> <p className="text-sm text-muted-foreground">Version actuelle : <strong>{version}</strong></p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card> <Card>
<CardHeader> <CardHeader>
@@ -17,7 +18,7 @@ export default function DownloadPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Windows (64 bits). Nécessite Tailscale installé séparément ou les binaires dans <code>tailscale-bin/windows/</code>.</p> <p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Windows (64 bits). Nécessite Tailscale installé séparément ou les binaires dans <code>tailscale-bin/windows/</code>.</p>
<a href={`/${AGENT_BIN_NAME}-v${AGENT_VERSION}.exe`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.exe)</a> <a href={downloadUrls.windows} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.exe)</a>
</CardContent> </CardContent>
</Card> </Card>
@@ -27,7 +28,7 @@ export default function DownloadPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground mb-4">Archive complète incluant l&apos;agent, Tailscale et le README Windows.</p> <p className="text-sm text-muted-foreground mb-4">Archive complète incluant l&apos;agent, Tailscale et le README Windows.</p>
<a href={`/${AGENT_BIN_NAME}-v${AGENT_VERSION}-windows.zip`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.zip)</a> <a href={downloadUrls.windowsZip} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.zip)</a>
</CardContent> </CardContent>
</Card> </Card>
@@ -37,7 +38,7 @@ export default function DownloadPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Linux (64 bits).</p> <p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Linux (64 bits).</p>
<a href={`/${AGENT_BIN_NAME}-v${AGENT_VERSION}`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (Linux)</a> <a href={downloadUrls.linux} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (Linux)</a>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+55
View File
@@ -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),
};
}
+60 -1
View File
@@ -3,6 +3,7 @@ import { randomBytes } from "crypto";
import type { IncomingMessage } from "http"; import type { IncomingMessage } from "http";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { createEphemeralPreAuthKey, getHeadscaleUserId } from "./headscale"; import { createEphemeralPreAuthKey, getHeadscaleUserId } from "./headscale";
import { getAgentVersion } from "./agent-version";
interface NodeMessage { interface NodeMessage {
action: string; action: string;
@@ -12,10 +13,16 @@ interface NodeMessage {
type?: string; type?: string;
port?: number; port?: number;
composeConfig?: string; composeConfig?: string;
initScript?: string;
studentName?: string; studentName?: string;
error?: string; error?: string;
tailscaleIp?: string; tailscaleIp?: string;
token?: 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<string, WebSocket>(); const nodes = new Map<string, WebSocket>();
@@ -126,7 +133,7 @@ export function initWebSocketServer(wss: WebSocketServer) {
create: { id, status: "online", lastSeen: new Date() }, create: { id, status: "online", lastSeen: new Date() },
}); });
} }
ws.send(JSON.stringify({ action: "registered" })); ws.send(JSON.stringify({ action: "registered", serverVersion: getAgentVersion() }));
return; return;
} }
@@ -263,6 +270,58 @@ export function initWebSocketServer(wss: WebSocketServer) {
return; 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) { if (msg.action === "instance_started" && msg.instanceId) {
const { count } = await prisma.instance.updateMany({ const { count } = await prisma.instance.updateMany({
where: { id: msg.instanceId }, where: { id: msg.instanceId },