Compare commits

..

15 Commits

Author SHA1 Message Date
EduBox Dev fc61404271 feat: installation offline complete, HTTPS registry, 8Go WSL, v0.3.18
- Wizard: installation 100% offline (WSL bundle, Podman MSI, machine image, docker-compose)
- Wizard: suppression de wsl --install --no-distribution
- Wizard: .wslconfig avec 8Go RAM / 4 CPU
- Wizard: operations asynchrones pour eviter le freeze UI
- Wizard: detection automatique de podman.exe
- Wizard: version 0.1.1
- Agent: passage en v0.3.18
- Serveur: registry PrestaShop en HTTPS via gitea.alfrednobel.edudeploy.com
- Caddy: config gitea.alfrednobel.edudeploy.com
- Docs: mise a jour SUIVI_INSTALLER.md, README.md, seed.ts
2026-07-02 22:52:28 +00:00
EduBox Dev 3c519629d2 installer: corrections wizard C# (System.Management, AppContext.BaseDirectory), fix script Inno Setup GetDiskFreeSpaceEx, ajout SUIVI_INSTALLER 2026-06-28 22:52:45 +00:00
EduBox Dev 0f07a2d2a3 installer: wizard C# Windows d'installation guidee (WSL2, Podman, agent, desinstallation) 2026-06-28 20:49:57 +00:00
EduBox Dev d2c3edea2f agent v0.3.17: correction statut Tailscale + overlay de reconnexion pendant l'update 2026-06-28 20:03:49 +00:00
EduBox Dev 41929be34c agent v0.3.16: statut service d'applications basé sur la connexion Tailscale effective 2026-06-28 19:57:06 +00:00
EduBox Dev adab165274 agent v0.3.15: mode proxy auto/manuel, correction auto-update et conservation systray, animation UI update 2026-06-28 19:53:19 +00:00
EduBox Dev 33d89c66c0 fix(agent): v0.3.10 cleanup orphan instance dirs on startup
- Add cleanupOrphanInstanceDirs() to remove leftover instance directories
  after failed deletes (common on Windows when compose.log is locked)
- Log RemoveAll errors in dockerComposeRm for better visibility
- Bump version to 0.3.10 and rebuild binaries
2026-06-27 21:36:02 +00:00
EduBox Dev e946b22a42 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
2026-06-27 21:11:20 +00:00
EduBox Dev cf8b66340a agent v0.3.8: fix crash UI notifications, auto Podman machine DNS, WordPress 7.0.0 ready template 2026-06-26 15:24:21 +00:00
EduBox Dev a414f03a59 feat(agent): v0.3.5 Windows inbound forwarding, UI actions, lifecycle
- Configure tailscale serve automatically for each instance on Windows userspace networking.
- Add local UI buttons: start/stop/reset/delete instances (stop/start preserve volumes).
- Clean shutdown: stop tailscaled and instances, notify server with instance_stopped.
- Restart tailscaled on agent boot using persisted state when pre-auth key is absent.
- Sync instance stopped/deleted status to dashboard (server/lib/websocket.ts).
- Security: include prior authz/scoping changes across API routes, ephemeral pre-auth keys, ACL policy, internal API key.
- Update SUIVI_VPN_ONDEMAND.md and docs/ONBOARDING_CLIENT.md.
- Bump agent version to 0.3.5.
2026-06-25 22:59:09 +00:00
EduBox Dev 331187e9b5 docs: update SUIVI_VPN_ONDEMAND.md with v0.3.3 links and --unattended fix 2026-06-23 18:36:58 +00:00
EduBox Dev 281c7c9a19 fix(agent/windows): add --unattended to tailscale up
On Windows tailscaled stops the backend when the CLI client disconnects.
--unattended keeps the VPN session alive so the agent can report its IP
and the server can reach the node. Bump to v0.3.3.
2026-06-23 18:36:14 +00:00
EduBox Dev 3a3e3ed202 fix(agent): correct version log, bump to v0.3.2, increase tailscale timeout
- Replace hardcoded AGENT_VERSION constant with injected version variable
- Bump agent version to 0.3.2
- Increase tailscale up/status timeout from 60s to 120s
2026-06-23 18:29:21 +00:00
EduBox Dev d090f67bff fix(agent/windows): named pipe Tailscale + hideWindow + logs
- Use Windows named pipe \.\pipe\studioe5-tailscaled instead of Unix socket
- Apply hideWindow to all child processes (tailscale, podman, docker, browser)
- Redirect agent logs to <data-dir>/agent.log and tailscaled logs to tailscaled.log
- Fix double tailscale/ tailscale dir path in startTailscaleAndReport
- Remove --operator=root on Windows
- Bump agent version to 0.3.1
2026-06-23 18:18:26 +00:00
EduBox Dev 03b2f1267d docs(vpn): ajout des améliorations UI (console intégrée + barre de progression) 2026-06-23 17:08:36 +00:00
64 changed files with 7229 additions and 421 deletions
+5
View File
@@ -5,6 +5,11 @@ NEXTAUTH_URL=http://localhost
SUPERADMIN_EMAIL=admin@edudeploy.fr SUPERADMIN_EMAIL=admin@edudeploy.fr
SUPERADMIN_PASSWORD=CHANGE_ME SUPERADMIN_PASSWORD=CHANGE_ME
HEADSCALE_URL=http://headscale:8080 HEADSCALE_URL=http://headscale:8080
# Legacy reusable pre-auth key (kept for manual/debug setups).
HEADSCALE_AUTH_KEY=CHANGE_ME HEADSCALE_AUTH_KEY=CHANGE_ME
# Headscale API key used by the server to generate ephemeral pre-auth keys.
HEADSCALE_API_KEY=CHANGE_ME
HEADSCALE_RESOLVER_AUTH_KEY=CHANGE_ME
INTERNAL_API_KEY=CHANGE_ME
GITEA_URL=http://gitea:3000 GITEA_URL=http://gitea:3000
GITEA_TOKEN=CHANGE_ME GITEA_TOKEN=CHANGE_ME
+3
View File
@@ -26,3 +26,6 @@ headscale/*.key
headscale/*.state headscale/*.state
agent/resolv.conf agent/resolv.conf
agent/tailscale-bin/ agent/tailscale-bin/
agent/studioE5-agent-test
agent/.cache-go/
server/tsconfig.tsbuildinfo
+5
View File
@@ -26,6 +26,11 @@ headscale.studioe5.edudeploy.com:443 {
reverse_proxy headscale:8080 reverse_proxy headscale:8080
} }
gitea.alfrednobel.edudeploy.com {
tls admin@edudeploy.com
reverse_proxy 151.80.60.98:3001
}
studioe5.edudeploy.com:443 { studioe5.edudeploy.com:443 {
route /studioE5-agent* { route /studioE5-agent* {
file_server { file_server {
+674 -15
View File
@@ -64,6 +64,68 @@ HTTP/2 200
- Certificat Lets Encrypt obtenu automatiquement par Caddy (`tls { on_demand }`). - Certificat Lets 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://`. - Le resolver réécrit les en-têtes `Location` et le contenu HTML pour passer de `http://` à `https://`.
## 📝 Template WordPress prêt à lemploi
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 à lusage 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 laccès à `api.wordpress.org` |
### Architecture technique
- Le modèle `Template` de Prisma dispose dun 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 dinitialisation.
- Lagent écrit le script `wp-init.sh` dans le dossier de linstance 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 linstance à 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 dinitialisation WP-CLI.
- `server/app/api/instances/route.ts` envoi de `initScript` à lagent 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 lAPI (`cmqv03a6v0001vg8zrpe8zqfy`) :
```bash
$ curl -sS -I -L https://cmqv03a6v0001vg8zrpe8zqfy.studioe5.edudeploy.com/
HTTP/2 200
```
- Page daccueil 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) ## 📁 Fichiers modifiés (non exhaustif)
- `agent/tailscale.go` lancement `tailscaled` + `tailscale up`, gestion start/stop/status. - `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` - data-dir : `/tmp/studioe5-test-clienta`
- node-id : `vps-8fc665eb` - node-id : `vps-8fc665eb`
- tailnet IP actuelle : `100.64.0.8` - 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 lagent v0.3.5 corrigé)
Instance de test créée : Instance de test créée :
- ID : `test-wp-001` - ID : `test-wp-001`
@@ -88,6 +150,204 @@ Instance de test créée :
- Template : `wordpress-wordpress-latest` - Template : `wordpress-wordpress-latest`
- État : WordPress répond sur `http://127.0.0.1:8001` **et en HTTPS public sur `https://test-wp-001.studioe5.edudeploy.com/`**. - État : WordPress répond sur `http://127.0.0.1:8001` **et en HTTPS public sur `https://test-wp-001.studioe5.edudeploy.com/`**.
## 🪟 Fix agent Windows v0.3.1
Problème rencontré sur le PC de test (`OMEGA-GAMER-dc166b1a`) :
- Le nœud apparaissait `online` dans le dashboard mais sans IP Tailscale.
- `tailscale.exe ip -4` retournait une erreur de connexion au socket local.
Cause racine :
- Lagent lançait `tailscaled` avec `--socket=<fichier>.sock`, mais **Tailscale sur Windows utilise des named pipes** (`\\.\pipe\...`), pas des sockets Unix.
- De plus, les commandes `podman`/`docker`/`tailscale` ouvraient une fenêtre console à chaque exécution.
Corrections apportées (`agent/tailscale.go`, `agent/docker.go`, `agent/instance.go`, `agent/systray.go`, `agent/ui.go`, `agent/main.go`) :
- Sur Windows, utilisation de la named pipe `\\.\pipe\studioe5-tailscaled`.
- Application de `hideWindow` à tous les processus enfants (Tailscale, Podman, Docker, ouverture navigateur, redémarrage agent).
- Redirection des logs agent vers `<data-dir>/agent.log` et des logs `tailscaled` vers `<data-dir>/tailscale/tailscaled.log`.
- Suppression de `--operator=root` sur Windows (non pertinent).
- Ajout de `--unattended` au `tailscale up` sur Windows pour que le daemon reste connecté après la déconnexion du client CLI.
- Correction du chemin `dataDir` passé à `startTailscale` (évitait un double dossier `tailscale/tailscale`).
Validation manuelle sur Windows :
```powershell
.\tailscaled.exe --state="C:\...\data\tailscale.state" --socket="\\.\pipe\studioe5-tailscaled" --tun=userspace-networking
.\tailscale.exe --socket="\\.\pipe\studioe5-tailscaled" status # => Logged out (NeedsLogin)
```
## 🪟 Agent v0.3.5 forwarding entrant Windows + UI locale + cycle de vie
### Problème
Sur Windows, Tailscale en `userspace-networking` ne forwarde pas automatiquement les connexions entrantes du Tailnet vers `localhost`. Résultat : les URLs publiques retournaient une erreur 502/timeout, bien que lagent soit `online`.
Logs caractéristiques :
```text
client -> backend close connection: close tcp 100.64.0.12:8080->100.64.0.11:xxxxx: endpoint not connected
```
### Solution : `tailscale serve` automatique
Lagent configure automatiquement un proxy TCP pour chaque instance démarrée :
```powershell
tailscale serve --bg --tcp=<port> tcp://localhost:<port>
```
| Action agent | Commande Tailscale |
|--------------|--------------------|
| Démarrage dinstance | `serve --bg --tcp=<port> tcp://localhost:<port>` |
| Arrêt dinstance | `serve --bg --tcp=<port> off` |
| Suppression dinstance | `serve --bg --tcp=<port> off` |
| Redémarrage de lagent | reconfiguration pour les instances déjà `running` |
Fichiers modifiés : `agent/tailscale.go`, `agent/websocket.go`, `agent/main.go`, `agent/ui.go`.
### UI locale modernisée
- Tableau de bord avec indicateurs de service.
- Liste des applications avec badges de statut.
- Boutons daction par instance : **Démarrer**, **Arrêter**, **Redémarrer**, **Supprimer**.
- Panneau de logs et diagnostic intégré.
- Panneau de configuration (URL serveur, Headscale, node ID).
### Cycle de vie des instances
- **Arrêter** → `docker compose stop` (volumes conservés).
- **Démarrer** → `docker compose start` (ou `up -d` la première fois).
- **Redémarrer** → `docker compose down -v` + recréation (données remises à zéro).
- **Supprimer** → `docker compose down -v` + suppression des fichiers.
- À la fermeture de lagent, les instances en cours sont arrêtées proprement (`stop`) et le serveur est notifié (`instance_stopped`).
### Démarrage du VPN après activation
Lagent redémarre `tailscaled` automatiquement au lancement, même si la clé pré-auth a déjà été utilisée. Il se base sur l’état persistant `tailscaled.state` (`tailscale up` sans `--authkey`).
### Téléchargement
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5-windows.zip`
- **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 dinstance
### Problème
Lors de la création dune instance depuis le dashboard vers certains agents (notamment Windows), lagent sarrê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 dun `defer recover()` dans `handleStartInstance` ; en cas de panic, linstance passe en statut `error` et un message `instance_error` est envoyé au serveur.
- Ajout dun `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
Lagent continuait de sarrêter brutalement lors de la création dune 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 dun `defer recover()` dans `notifyUI` pour chaque goroutine de notification.
- Ajout dun `defer recover()` dans `sendUILog` (logs diffusés aux clients UI).
- Ajout dun `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, lagent Windows avec Podman échouait au `docker compose up` avec :
```text
lookup registry-1.docker.io: Temporary failure in name resolution
```
La VM Podman machine navait 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 à lintérieur des conteneurs, mais pas pour le pull dimages par Podman machine.
### Solution
Lagent configure automatiquement le DNS des machines Podman en cours dexé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 <name> 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 lagent :
- Après un **Arrêter** lancé depuis le dashboard, linstance restait affichée comme elle l’était avant, ou disparaissait avec perte des données.
- Après une **Suppression**, linstance n’était pas retirée de la liste.
### Causes racines
1. **Action `stop` du dashboard envoyée comme `delete` à lagent** (`server/app/api/instances/route.ts`).
Lagent exécutait alors `docker compose down -v` + suppression des fichiers, cest-à-dire une suppression réelle, tout en marquant linstance `stopped` en base.
2. **Lagent 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 lUI locale le faisait.
3. **Le handler `stop` de lagent 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` | Laction dashboard `stop` envoie désormais `action: "stop"` à lagent (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 dinstances 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 darrêt brutal lors dactions 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 dun `recover` dans `handleMessage` pour capturer d’éventuels panics sans tuer lagent.
- 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 lagent sur chaque poste, ou télécharger à nouveau le binaire v0.3.5 depuis le dashboard pour Windows.
## 🛠️ Commandes utiles pour reprendre ## 🛠️ Commandes utiles pour reprendre
### Voir lagent de test ### Voir lagent de test
@@ -166,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.0-windows.zip` - **Windows (archive complète)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.10-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.0.exe` - **Windows (exécutable seul)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.10.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.0` - **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.10`
### Builder / préparer les binaires ### Builder / préparer les binaires
@@ -184,13 +444,13 @@ cd /opt/studioe5-client-a/agent
./build.sh ./build.sh
``` ```
Le `build.sh` génère automatiquement `studioE5-agent-v0.3.0-windows.zip` et copie les binaires versionnés dans `server/public/`. Le `build.sh` génère automatiquement `studioE5-agent-v0.3.10-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.0-windows.zip`). 1. **Télécharger** lagent Windows (`studioE5-agent-v0.3.10-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** :
@@ -221,17 +481,416 @@ Lancement :
.\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data .\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data
``` ```
## 🔒 Durcissement du code dactivation
### Génération
- Les codes sont générés avec `crypto.randomBytes` (au lieu de `Math.random`).
- Longueur conservée à 6 caractères, alphabet sans ambiguïté (`ABCDEFGHJKLMNPQRSTUVWXYZ23456789`).
- Un champ `activationCodeExpiresAt` a été ajouté au modèle `Student` ; les codes expirent après **60 minutes**.
### Rate-limiting
- Maximum de **5 tentatives dactivation par code** sur une fenêtre de **15 minutes**.
- Maximum de **5 tentatives par `nodeId`** sur la même fenêtre.
- Au-delà, le serveur répond `activation_failed` avec `Too many attempts`.
### Cycle de vie
- Le code est **invalide après une activation réussie** (`activationCode` et `activationCodeExpiresAt` mis à `null`).
- Un code expiré renvoie `Code expired`.
- Un code déjà utilisé renvoie `Invalid code`.
### Tests validés
- Activation valide → `activated` + token node reçu.
- Code expiré → `Code expired`.
- Code déjà utilisé → `Invalid code`.
- 5+ tentatives invalides → `Too many attempts`.
## 🔒 ACL Headscale (isolation du tailnet)
### Objectif
Par défaut, tous les nœuds du tailnet peuvent communiquer entre eux. Les ACL restreignent la connectivité au strict nécessaire :
- les agents élèves ne peuvent pas se parler entre eux ;
- le resolver peut atteindre les agents sur leurs ports dinstance ;
- les agents peuvent joindre le resolver sur son port HTTP interne.
### Mise en œuvre
- Fichier de politique : `headscale/acl_policy.hujson`.
- `headscale/config.yaml` pointe vers ce fichier via `policy.path`.
- Le resolver a été déplacé dans un utilisateur Headscale dédié `resolver` (clé `HEADSCALE_RESOLVER_AUTH_KEY`).
- Les agents utilisent lutilisateur `studioe5` et sont tagués `tag:student-agent`.
- Les nouveaux agents recevront automatiquement le tag via la nouvelle clé pré-auth `HEADSCALE_AUTH_KEY` (créée avec `--tags tag:student-agent`).
### Contenu de la politique
```json
{
"groups": {
"group:agents": ["studioe5@studioe5.local"],
"group:resolvers": ["resolver@studioe5.local"]
},
"tagOwners": {
"tag:student-agent": ["studioe5@studioe5.local"],
"tag:resolver": ["resolver@studioe5.local"]
},
"acls": [
{ "action": "accept", "src": ["tag:resolver"], "dst": ["tag:student-agent:*"] },
{ "action": "accept", "src": ["tag:student-agent"], "dst": ["tag:resolver:2020"] }
]
}
```
### Tests validés
| Test | Résultat |
|------|----------|
| `resolver` ping agent | ✅ OK |
| Agent → agent (port instance) | ❌ bloqué (timeout) |
| Agent → resolver:2020 | ✅ OK |
| Flux HTTPS public | ✅ HTTP 200 |
## 🔒 Authentification du canal serveur → agent
### Token dauthentification par nœud
- Le modèle `Node` dispose dun champ `token` unique.
- Lagent envoie son token dans len-tête `Authorization: Bearer <token>` lors de la connexion WebSocket.
- Le serveur rejette toute connexion/register dont le token ne correspond pas au `nodeId` (fermeture `1008`).
- Lors de lactivation, le serveur génère un token aléatoire (32 octets hex) et le renvoie dans le message `activated` ; lagent le sauvegarde dans `<data-dir>/node.token` (permissions `0600`).
- Pour les nœuds existants sans token, le serveur en génère un à la première connexion et lenvoie via `set_token`.
### Endpoint `/api/internal/send-to-node`
- Protégé par la variable denvironnement `INTERNAL_API_KEY`.
- Requiert len-tête `Authorization: Bearer <INTERNAL_API_KEY>`.
- Appel sans clé → `401 Unauthorized`.
### Routes API métier
- Les routes de gestion des instances (`/api/instances`) requièrent une session NextAuth valide.
- Un administrateur ne peut agir que sur les ressources de son établissement ; le `superadmin` peut tout voir/tout faire.
### Endpoint `/api/resolve`
- Protégé par la même clé `INTERNAL_API_KEY`.
- Requiert len-tête `Authorization: Bearer <INTERNAL_API_KEY>`.
- Le resolver (`resolver:2020`) ne lutilise pas ; il interroge directement PostgreSQL. Cette route est donc réservée aux outils/scripts internes authentifiés.
### Exemples de commandes avec la clé interne
```bash
KEY=$(grep INTERNAL_API_KEY /opt/studioe5-client-a/.env | cut -d= -f2)
curl -sS -X POST https://studioe5.edudeploy.com/api/internal/send-to-node \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $KEY" \
-d '{"nodeId":"vps-8fc665eb","message":{"action":"start_vpn"}}'
curl -sS -H "Authorization: Bearer $KEY" \
"https://studioe5.edudeploy.com/api/resolve?subdomain=test-wp-001"
```
## 🔒 Clés pré-auth Headscale éphémères
### Principe
À lactivation zero-config, le serveur génère désormais une **clé pré-auth unique et à usage unique** pour chaque agent, au lieu denvoyer la clé réutilisable `HEADSCALE_AUTH_KEY`.
Avantages :
- une clé compromise ne permet pas denregistrer dautres nœuds ;
- traçabilité directe entre une activation et une clé Headscale ;
- expiration courte (15 min) ;
- la clé nest **pas persistée** dans `studioE5-config.json` côté agent.
### Implémentation
| Composant | Changement |
|-----------|------------|
| `server/lib/headscale.ts` | Nouveau helper : `getHeadscaleUserId()` + `createEphemeralPreAuthKey()` appelant `POST /api/v1/preauthkey`. |
| `server/lib/websocket.ts` | Sur `activate`, génère une clé éphémère taguée `tag:student-agent` pour lutilisateur `studioe5`. Fallback sur `HEADSCALE_AUTH_KEY` si `HEADSCALE_API_KEY` nest pas configurée. |
| `agent/websocket.go` | La clé reçue est utilisée immédiatement mais **nest plus écrite** dans `studioE5-config.json`. |
| `agent/tailscale.go` | `tailscale up` fonctionne sans `--authkey` quand le state Tailscale existe déjà (reconnexion). |
| `.env.example` / `docker-compose.yml` | Ajout de `HEADSCALE_API_KEY` pour le service `server`. |
### Configuration requise
Générer une clé API Headscale (depuis le conteneur ou la CLI) :
```bash
cd /opt/studioe5-client-a
# Clé valable 10 ans (87600h) pour éviter un renouvellement fréquent.
docker compose exec headscale headscale apikeys create -e 87600h
```
Puis lajouter dans `.env` :
```bash
HEADSCALE_API_KEY=hskey-api-...
```
> ⚠️ La clé API Headscale a une expiration par défaut de 90 jours. La clé de production a été créée avec une expiration de **10 ans** (`-e 87600h`). Les anciennes clés ont été révoquées.
### Rotation / renouvellement
Si la clé doit être changée :
1. Créer une nouvelle clé API :
```bash
docker compose exec headscale headscale apikeys create -e 87600h
```
2. Mettre à jour `.env` :
```bash
HEADSCALE_API_KEY=<nouvelle_clé>
```
3. Redémarrer le serveur :
```bash
docker compose up -d server
```
4. Révoquer lancienne clé :
```bash
docker compose exec headscale headscale apikeys expire --id <id_ancienne>
```
### Déploiement effectué
- Clé API créée et ajoutée au `.env` de production.
- Image serveur rebuildée et redémarrée.
- Agents Linux/Windows rebuildés en v0.3.4 et copiés dans `server/public/`.
## 🔒 Sécurité — points restants à traiter
> Le certificat wildcard `*.studioe5.edudeploy.com` est désormais du ressort du **deployeur** (voir `docs/ONBOARDING_CLIENT.md`). Les points ci-dessous concernent lapplication studioE5 proprement dite.
### Gestion et rotation des secrets
| Secret | Où ? | Action |
|--------|------|--------|
| `INTERNAL_API_KEY` | `.env` serveur | Prévoir une procédure de rotation régulière. |
| `HEADSCALE_API_KEY` | `.env` serveur | Rotation tous les 10 ans max, stockage sécurisé. |
| `NEXTAUTH_SECRET` | `.env` serveur | Génération robuste, rotation si suspicion de fuite. |
| `DATABASE_URL` | `.env` serveur | Utilisateur DB dédié, mot de passe fort. |
| `node.token` | `<data-dir>/node.token` | Vérifier permissions `0600` sur tous les OS. |
### Durcissement des conteneurs
- Limiter les `cap_add` au strict minimum.
- Faire tourner les services avec un utilisateur non-root quand possible.
- Mettre à jour régulièrement les images de base (Caddy, Node, Postgres, Headscale).
- Scanner les images Docker pour les CVE.
### Mises à jour de sécurité
- Mise à jour des binaires Tailscale (Windows et Linux).
- Mise à jour des images Docker (`server`, `resolver`, `caddy`, `postgres`, `headscale`).
- Mise à jour de lOS des VPS et des postes agents.
- Mécanisme de mise à jour automatique ou notification de lagent.
### Logs daudit
- Tracer la création / suppression dinstances.
- Tracer la génération et lusage des codes dactivation.
- Tracer les actions admin (connexion, création d’élève, démarrage/arrêt VPN).
- Conservation et consultation des logs daudit.
### Backups et reprise dactivité
- Backup régulier de la base PostgreSQL.
- Backup du state Headscale.
- Backup des states Tailscale côté agents.
- Procédure de restauration documentée et testée.
### Sécurité du build et distribution de lagent
- Vérifier lintégrité des binaires Tailscale téléchargés (checksum / signature).
- Signer lexécutable Windows `studioE5-agent.exe` pour éviter les alertes Defender.
- Fournir un hash SHA256 des archives dagent.
### RGPD et données personnelles
- Justifier la conservation des noms/prénoms des élèves.
- Gérer les droits daccès, la suppression de compte et lexport de données.
- Définir la durée de conservation des logs et historiques.
### Sécurité réseau complémentaire
- Restreindre laccès à `/api/internal/send-to-node` par IP source si possible.
- Vérifier lexposition publique du dashboard Headscale et la durcir si nécessaire.
- Évaluer si `headscale.studioe5.edudeploy.com` doit rester public.
### Rate limiting et quotas
- Rate-limiting global sur les routes publiques (`/api/auth/*`, activation, création dinstance).
- Limitation du nombre dinstances par élève et par établissement.
- Protection contre les abus sur la génération de codes dactivation.
### Tests de sécurité
- Tests dintrusion légers (agent → agent, accès aux endpoints internes sans clé, accès à une instance dun autre élève).
- Tests automatisés du flux complet avant chaque release.
---
## 🖥️ Installateur agent professionnel
### Objectif
Créer un package dinstallation unique et professionnel par OS, incluant lagent studioE5, Tailscale et **Podman CLI**, afin de ne plus dépendre dinstallations manuelles préalables par lutilisateur.
### 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 lagent 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 lagent dans `/Applications/studioE5-agent/`.
- Installer Podman CLI.
- Exécuter `podman machine init` puis `podman machine start`.
- Optionnellement créer un LaunchAgent pour démarrer lagent au login.
- **Linux** (script shell) :
- Détecter le package manager (`apt`, `dnf`, `pacman`, etc.).
- Installer Podman et Podman Compose.
- Copier lagent dans `/opt/studioe5-agent/`.
- Créer le service systemd `studioe5-agent.service`.
- Activer et démarrer le service.
### Adaptations nécessaires dans lagent
- 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 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.10.
- **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
- [x] ~~Attendre la fin du rate limit Lets Encrypt~~ (levé le 2026-06-23). ### ✅ Terminé
- [x] ~~Relancer un test HTTPS sur `https://test-wp-001.studioe5.edudeploy.com/`~~ → **OK** (HTTP/2 200).
- [x] ~~Créer une branche dédiée et commiter les modifications VPN on-demand~~ → branche `feat/studioe5-vpn-ondemand`, commit `124543d`. Push vers Gitea à faire dès que le remote sera accessible (actuellement `localhost:3001` et `gitea.alfrednobel.edudeploy.com` injoignables). - [x] Rate limit Lets Encrypt levé.
- [x] ~~Tester le flux complet depuis linterface web~~ → **OK** via lAPI authentifiée (`POST /api/instances`), instance `cmqqgrur20001lw67t2bdgzkg` accessible en HTTPS public. - [x] Flux HTTPS public validé (`test-wp-001.studioe5.edudeploy.com`).
- [ ] **Obtenir un certificat wildcard** pour `*.studioe5.edudeploy.com` (voir étude ci-dessous). - [x] Branche `feat/studioe5-vpn-ondemand` créée, commit `124543d`.
- [ ] **Nettoyer les instances/agent de test** une fois le wildcard en place et le push effectué. - [x] **Pousser la branche** vers Gitea : la branche est synchronisée sur `origin/feat/studioe5-vpn-ondemand` (commit `cf8b663`).
- [x] ~~Packager les binaires Tailscale pour Windows~~ → **OK**, `download-tailscale-bins.sh` + `studioE5-agent-v0.3.0-windows.zip` prêt. - [x] Flux complet UI → API → WebSocket → agent → Docker → VPN → Caddy validé.
- [ ] **Nettoyer les anciens nodes/volumes Headscale** créés pendant les tests. - [x] Packager les binaires Tailscale pour Windows (`studioE5-agent-v0.3.5-windows.zip`).
- [ ] **Documenter la procédure de mise en production** pour le client A (config agent, clés Headscale, ports, etc.). - [x] **Sécurité authentification du canal serveur → agent** (token par nœud, clé API interne, sessions NextAuth sur les routes API).
- [x] **Sécurité durcissement du code dactivation** (`crypto.randomBytes`, expiration 60 min, rate-limiting, invalidation après usage).
- [x] **Sécurité ACL Headscale** (isolation agent ↔ agent, resolver → agent autorisé).
- [x] **Sécurité clés pré-auth Headscale éphémères** (génération côté serveur via `HEADSCALE_API_KEY`, non persistées côté agent).
- [x] **Agent v0.3.5 forwarding entrant Windows** (`tailscale serve` automatique au démarrage de chaque instance).
- [x] **Agent v0.3.5 UI locale moderne** (dashboard, logs, progression, actions dinstance).
- [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, 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] **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.10 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.10 détails techniques dans lUI locale** : version de lagent, nodeId, version attendue par le serveur, notification de mise à jour.
- [x] **Agent v0.3.10 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.10 handlers asynchrones** : `start`, `stop`, `delete`, `reset` exécutés dans des goroutines pour ne plus bloquer la boucle WebSocket.
- [x] **Agent v0.3.10 nettoyage des dossiers instances orphelins au démarrage** : supprime les répertoires résiduels laissés par des `delete` incomplets (souvent `compose.log` verrouillé sous Windows).
### ⏳ Reste à faire
- [ ] **Certificat wildcard** : transféré au deployeur (`docs/ONBOARDING_CLIENT.md`). L’étude technique reste disponible ci-dessous pour référence.
- [ ] **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.
- [ ] **Template WordPress prêt à lemploi (usage examen/classe)** :
- Forcer le DNS (`8.8.8.8`, `1.1.1.1`) dans le `docker-compose.yml` pour permettre laccè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 lenvironnement.
- Installer et activer le **thème Astra**.
- Installer **Yoast SEO** (inactif) et **Spectra** (actif).
- [ ] **Barre de progression basée sur les logs dinstallation** : enrichir la barre de progression agent/dashboard en lisant les logs Podman/Docker (`podman logs -f`) pendant le premier démarrage dune 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 dun nouveau serveur client + agent générique (option A : URL serveur déterminée à lactivation).
- [ ] **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).
- [ ] **Sécurité mises à jour de sécurité** (Tailscale, images Docker, OS agents).
- [ ] **Sécurité logs daudit** (instances, codes dactivation, actions admin).
- [ ] **Sécurité backups et reprise dactivité** (DB, state Headscale, states agents).
- [ ] **Sécurité intégrité et signature de lagent** (checksum Tailscale, signature Windows, hash SHA256).
- [ ] **Sécurité conformité RGPD** (données élèves, suppression de compte, export).
- [ ] **Sécurité restriction réseau** (endpoint interne, dashboard Headscale).
- [ ] **Sécurité rate limiting et quotas** (routes publiques, instances par élève/établissement).
- [ ] **Sécurité tests de sécurité** (intrusion légère, tests automatisés avant release).
## 💡 Améliorations UI
### ✅ Console / log intégrée dans lagent (v0.3.5)
Les logs de lagent sont redirigés vers `<data-dir>/agent.log` et diffusés en temps réel dans lUI locale (`http://localhost:7070`) via le WebSocket existant.
### ✅ Barre de progression (v0.3.5)
Lagent envoie des messages `progress` au frontend pendant le démarrage dune instance :
| Étape | Poids |
|-------|-------|
| Préparation de lapplication | 10 % |
| Configuration de lapplication | 30 % |
| Application en cours de démarrage | 60 % |
| Connexion sécurisée active | 80 % |
| Finalisation de linstallation | 90 % |
| Application prête | 100 % |
### Boutons daction par instance (v0.3.5)
LUI locale affiche désormais des boutons **Démarrer**, **Arrêter**, **Redémarrer** et **Supprimer** pour chaque instance.
## 🚀 Scalabilité commerciale — déploiement multi-clients
### Objectif
Permettre de déployer facilement une stack studioE5 complète pour un nouvel établissement/client sur un VPS dédié, sans intervention technique lourde.
### Architecture cible
- **Un serveur = un client** : chaque établissement a sa propre stack Docker Compose, sa base PostgreSQL, son Headscale et son Caddy.
- **Agent générique (option A)** : un seul binaire agent pour tous les clients. LURL du serveur cible est déterminée au moment de lactivation, pas hardcodée dans lagent.
- Pistes : code dactivation résolu par un hub central, code structuré contenant lidentifiant du serveur, ou champ URL serveur saisi dans lUI locale.
- **Interface de déploiement** : dashboard superadmin (hub) permettant de créer un client, provisionner le VPS, générer les secrets et retourner les informations de connexion.
### Prérequis techniques à préparer
Avant de pouvoir déployer un nouveau client en quelques clics, il faut encore préparer les éléments suivants :
| # | Élément | État | Détail |
|---|---------|------|--------|
| 1 | **Agent générique** | ⏳ À faire | `defaultServerURL` est hardcodé (`wss://studioe5.edudeploy.com/api/websocket`). Lagent doit pouvoir déterminer lURL serveur cible à lactivation (option A : champ URL, hub de résolution, ou code structuré). |
| 2 | **Script de provisionning** | ⏳ À faire | Aucun outil automatisé pour provisionner un VPS vierge : installation Docker, déploiement de la stack, génération des secrets, création des clés Headscale, configuration DNS wildcard. |
| 3 | **Registry dimages** | ⏳ À faire | Les images `server` et `resolver` sont buildées sur le serveur cible. Il faut un registry privé pour builder une fois et déployer partout. |
| 4 | **Hub central** | ⏳ À faire | Dashboard superadmin listant les clients, état des serveurs, versions déployées, logs distants et mises à jour. |
| 5 | **Mises à jour à distance** | ⏳ À faire | Mécanisme pour pousser une nouvelle version du serveur et de lagent sur tous les déploiements clients. |
| 6 | **Monitoring / support** | ⏳ À faire | Collecte centralisée de logs, alertes (serveur down, certificat expiré, agent hors ligne). |
| 7 | **Branding / personnalisation** | ⏳ À faire | Logo, nom de l’établissement, couleurs configurables par client. |
| 8 | **Tests automatisés** | ⏳ À faire | Tests du flux activation → VPN → instance → HTTPS public pour valider chaque nouveau déploiement. |
| 9 | **Documentation procédure prod** | ⏳ À faire | Procédure complète de mise en production pour un nouveau client. |
### Statut
- ⏳ À étudier et planifier plus tard. Larchitecture actuelle (un serveur par client + agent zero-config) est déjà compatible avec cette vision, mais le code nest pas encore industrialisé pour un déploiement à grande échelle.
## 🔒 Étude certificat wildcard `*.studioe5.edudeploy.com` ## 🔒 Étude certificat wildcard `*.studioe5.edudeploy.com`
+1
View File
@@ -0,0 +1 @@
0.3.18
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
set -e set -e
VERSION="0.3.0" 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}"
+12 -2
View File
@@ -14,8 +14,18 @@ type AgentConfig struct {
Server string `json:"server"` Server string `json:"server"`
HeadscaleURL string `json:"headscale_url"` HeadscaleURL string `json:"headscale_url"`
HeadscaleAuthKey string `json:"headscale_auth_key"` HeadscaleAuthKey string `json:"headscale_auth_key"`
NodeID string `json:"node_id"` NodeID string `json:"node_id"`
DataDir string `json:"data_dir"` DataDir string `json:"data_dir"`
// ProxyURL is an optional HTTP(S) proxy used for all outbound agent traffic
// (WebSocket, update checks, downloads).
ProxyURL string `json:"proxy_url,omitempty"`
// ProxyMode controls how the proxy is used:
// - "disabled" : never use the proxy.
// - "auto" : the agent tries direct connections first and falls back to
// the proxy after a few failures (useful when moving between
// home network and school network).
// - "enabled" : always use the proxy.
ProxyMode string `json:"proxy_mode,omitempty"`
} }
const configFileName = "studioE5-config.json" const configFileName = "studioE5-config.json"
+45 -15
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -39,31 +40,64 @@ func writeCompose(dataDir, instanceID, compose string, port int) error {
return os.WriteFile(f, []byte(compose), 0644) 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")
if f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
cmd.Stdout = f
cmd.Stderr = f
}
}
func dockerComposeUp(dataDir, instanceID string) error { func dockerComposeUp(dataDir, instanceID string) error {
dir := instanceDir(dataDir, instanceID) dir := instanceDir(dataDir, instanceID)
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "up", "-d") cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "up", "-d")
cmd.Stdout = os.Stdout configureEngineCmd(cmd, dir)
cmd.Stderr = os.Stderr
return cmd.Run() return cmd.Run()
} }
func dockerComposeDown(dataDir, instanceID string) error { func dockerComposeDown(dataDir, instanceID string) error {
dir := instanceDir(dataDir, instanceID) dir := instanceDir(dataDir, instanceID)
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down") cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down")
cmd.Stdout = os.Stdout configureEngineCmd(cmd, dir)
cmd.Stderr = os.Stderr return cmd.Run()
}
func dockerComposeStop(dataDir, instanceID string) error {
dir := instanceDir(dataDir, instanceID)
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "stop")
configureEngineCmd(cmd, dir)
return cmd.Run()
}
func dockerComposeStart(dataDir, instanceID string) error {
dir := instanceDir(dataDir, instanceID)
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "start")
configureEngineCmd(cmd, dir)
return cmd.Run() return cmd.Run()
} }
func dockerComposeRm(dataDir, instanceID string) error { func dockerComposeRm(dataDir, instanceID string) error {
dir := instanceDir(dataDir, instanceID) dir := instanceDir(dataDir, instanceID)
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v") cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v")
cmd.Stdout = os.Stdout configureEngineCmd(cmd, dir)
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return err return err
} }
return os.RemoveAll(dir) if err := os.RemoveAll(dir); err != nil {
log.Printf("dockerComposeRm: failed to remove %s: %v (will retry on next startup)", dir, err)
return err
}
return nil
} }
// extractPublicURL tries to find the public URL from a WordPress compose config. // extractPublicURL tries to find the public URL from a WordPress compose config.
@@ -104,15 +138,13 @@ fi
defer os.Remove(scriptPath) defer os.Remove(scriptPath)
cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/update-wp-urls.sh") cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/update-wp-urls.sh")
cpCmd.Stdout = os.Stdout configureEngineCmd(cpCmd, dir)
cpCmd.Stderr = os.Stderr
if err := cpCmd.Run(); err != nil { if err := cpCmd.Run(); err != nil {
return err return err
} }
execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/update-wp-urls.sh") execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/update-wp-urls.sh")
execCmd.Stdout = os.Stdout configureEngineCmd(execCmd, dir)
execCmd.Stderr = os.Stderr
return execCmd.Run() return execCmd.Run()
} }
@@ -141,14 +173,12 @@ fi
defer os.Remove(scriptPath) defer os.Remove(scriptPath)
cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/strip-wp-urls.sh") cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/strip-wp-urls.sh")
cpCmd.Stdout = os.Stdout configureEngineCmd(cpCmd, dir)
cpCmd.Stderr = os.Stderr
if err := cpCmd.Run(); err != nil { if err := cpCmd.Run(); err != nil {
return err return err
} }
execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/strip-wp-urls.sh") execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/strip-wp-urls.sh")
execCmd.Stdout = os.Stdout configureEngineCmd(execCmd, dir)
execCmd.Stderr = os.Stderr
return execCmd.Run() return execCmd.Run()
} }
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package main
import "os/exec"
// hideWindow is a no-op on non-Windows platforms.
func hideWindow(cmd *exec.Cmd) {}
+18
View File
@@ -0,0 +1,18 @@
//go:build windows
package main
import (
"os/exec"
"syscall"
)
// hideWindow configures a command so that it does not open a console window
// when it starts. This is essential for the agent running on student Windows
// machines, otherwise every docker/podman/tailscale command flashes a window.
func hideWindow(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.HideWindow = true
}
+127
View File
@@ -0,0 +1,127 @@
# Feuille de route — Installateur studioE5 Agent
## Objectif
Fournir un **installateur professionnel Windows** pour studioE5 Agent, guidé pas à pas, qui gère les prérequis (WSL2 / Podman) et propose une désinstallation complète.
## Architecture choisie
- **Wizard C# Windows Forms (.NET 8)** : `setup-wizard/`
- Détecte les prérequis.
- Installe WSL2 si besoin (avec reprise après redémarrage via `RunOnce`).
- Installe Podman depuis le MSI officiel.
- Initialise et démarre la machine Podman.
- Lance le package Inno Setup de studioE5 Agent.
- Mode désinstallation via `/uninstall`.
- **Package agent (Inno Setup)** : `studioE5-agent.iss`
- Installe `studioE5-agent.exe` + binaires Tailscale.
- Crée les raccourcis.
- Gère la désinstallation.
## État actuel
### ✅ Réalisé
- Wizard C# avec 7 étapes guidées.
- Détection des prérequis : Windows, RAM, disque, WSL2, Podman.
- Installation WSL2 en plusieurs étapes contrôlées avec suivi visuel :
1. Activation des fonctionnalités Windows (`Microsoft-Windows-Subsystem-Linux` et `VirtualMachinePlatform`) avec gestion du code 3010 (redémarrage nécessaire).
2. Installation du package WSL2 complet depuis le bundle Microsoft Store offline (`Microsoft.WSL_*.msixbundle`) via `Add-AppxPackage`.
3. Mise à jour du noyau WSL2 depuis le MSI bundlé (`wsl_update_x64.msi`) ou, en dernier recours, via `wsl --update`.
4. Définition de WSL2 comme version par défaut (`wsl --set-default-version 2`).
5. L’étape `wsl --install --no-distribution` nest plus utilisée : linstallation est entièrement offline grâce au bundle.
6. Conversion des distributions WSL1 existantes vers WSL2 (`wsl --set-version <nom> 2`) si nécessaire.
- Reprise automatique après redémarrage grâce à `RunOnce` (y compris redémarrage post-installation WSL2).
- Versioning indépendant du wizard (fichier `setup-wizard/VERSION`, affiché dans la fenêtre et la page daccueil).
- Amélioration de linterface : fenêtre agrandie à 800×600, `AutoScaleMode = Dpi`, meilleur rendu sur petits écrans (11 pouces).
- `RunCommand` capture désormais stdout + stderr pour des diagnostics et fallbacks plus fiables.
- Détection des prérequis vérifie que WSL2 (et non WSL1) est installé via `wsl --status`.
- Installation Podman via MSI bundlé.
- Configuration Podman (`machine init` + `machine start`), avec initialisation offline possible depuis une image locale (`podman-machine.*.wsl.tar.zst`).
- Lancement du package Inno Setup agent.
- Mode désinstallation complet.
- Script Inno Setup de base pour lagent.
### 🔄 En cours / À tester
- Compilation et test du wizard sur Windows.
- Packaging final (wizard + MSI Podman + setup agent) en un seul dossier distribuable.
### ⏳ À venir
- Signature de lexécutable pour éviter les alertes SmartScreen.
- Support macOS et Linux.
- Installateur silencieux possible pour déploiement GPO.
## Build du wizard
### Prérequis
- Windows 10/11
- .NET 8 SDK
- Inno Setup 6 (pour générer `studioE5-agent-setup.exe`)
### Fichiers à placer
Dans `setup-wizard/Resources/` :
```text
podman-installer-windows-amd64.msi
studioE5-agent-setup.exe
Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle # package WSL2 complet (offline)
podman-machine.x86_64.wsl.tar.zst # image Podman machine pour WSL (offline)
docker-compose-windows-x86_64.exe # Docker Compose standalone (offline)
wsl_update_x64.msi # optionnel, fallback noyau WSL2
```
### Commande
```powershell
cd setup-wizard
dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true
```
### Sortie
```text
setup-wizard\bin\Release\net8.0-windows\win-x64\publish\StudioE5-SetupWizard.exe
```
## Build du package agent (Inno Setup)
Structure attendue :
```text
agent/
├── studioE5-agent.exe
├── tailscale-bin/
│ └── windows/
│ ├── tailscale.exe
│ ├── tailscaled.exe
│ └── wintun.dll
└── installer/
└── studioE5-agent.iss
```
Ouvrir `studioE5-agent.iss` avec Inno Setup Compiler et compiler (`Ctrl+F9`).
Le fichier généré se trouve dans `installer-output/`.
## Notes importantes
- Le wizard doit être exécuté **en administrateur**.
- Linstallation de WSL2 nécessite un **redémarrage** de lordinateur après lactivation des fonctionnalités Windows.
- Linstallation de WSL2 est découpée en étapes et chaque étape est enregistrée pour permettre la reprise après redémarrage.
- Podman nécessite WSL2 : le wizard vérifie donc que la version par défaut de WSL est 2.
- Le MSI Podman officiel pour Windows est `podman-installer-windows-amd64.msi`.
- Le bundle WSL2 offline est disponible sur <https://github.com/microsoft/WSL/releases> : `Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle`.
- Limage Podman machine offline est disponible sur <https://github.com/containers/podman-machine-os/releases> : `podman-machine.x86_64.wsl.tar.zst`.
- Docker Compose standalone est disponible sur <https://github.com/docker/compose/releases> : `docker-compose-windows-x86_64.exe`.
- Le wizard crée automatiquement un fichier `.wslconfig` allouant **8 Go de RAM et 4 CPU** à WSL2, ce qui est nécessaire pour des applications lourdes comme PrestaShop.
- Pour la désinstallation, le MSI Podman doit être présent dans `Resources/`.
## Liens utiles
- Releases Podman : <https://github.com/containers/podman/releases>
- Inno Setup : <https://jrsoftware.org/isdl.php>
- .NET 8 SDK : <https://dotnet.microsoft.com/download/dotnet/8.0>
@@ -0,0 +1,90 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StudioE5.SetupWizard;
public enum WizardStep
{
Welcome,
Prerequisites,
InstallVirtualEnvironment,
RestartRequired,
InstallPodman,
ConfigurePodman,
InstallAgent,
Finished,
Uninstall
}
public class InstallerState
{
[JsonPropertyName("step")]
public WizardStep Step { get; set; } = WizardStep.Welcome;
[JsonPropertyName("virtualEnvironmentInstalled")]
public bool VirtualEnvironmentInstalled { get; set; }
[JsonPropertyName("wslFeaturesEnabled")]
public bool WslFeaturesEnabled { get; set; }
[JsonPropertyName("wslPackageInstalled")]
public bool WslPackageInstalled { get; set; }
[JsonPropertyName("wslDefaultVersionSet")]
public bool WslDefaultVersionSet { get; set; }
[JsonPropertyName("wslKernelUpdated")]
public bool WslKernelUpdated { get; set; }
[JsonPropertyName("podmanInstalled")]
public bool PodmanInstalled { get; set; }
[JsonPropertyName("podmanConfigured")]
public bool PodmanConfigured { get; set; }
[JsonPropertyName("agentInstalled")]
public bool AgentInstalled { get; set; }
private static string StateFilePath
{
get
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"studioE5",
"installer");
Directory.CreateDirectory(dir);
return Path.Combine(dir, "installer-state.json");
}
}
public static InstallerState Load()
{
var path = StateFilePath;
if (!File.Exists(path))
return new InstallerState();
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallerState>(json) ?? new InstallerState();
}
catch
{
return new InstallerState();
}
}
public void Save()
{
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(StateFilePath, json);
}
public static void Delete()
{
var path = StateFilePath;
if (File.Exists(path))
File.Delete(path);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,399 @@
using System;
using System.Diagnostics;
using System.Management;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
namespace StudioE5.SetupWizard;
public record PrerequisiteResult(
bool WindowsCompatible,
ulong RamMB,
ulong FreeDiskMB,
bool VirtualEnvironmentInstalled,
bool PodmanInstalled,
bool PodmanMachineReady)
{
public bool AllReady => WindowsCompatible && RamMB >= 4096 && FreeDiskMB >= 5120 && VirtualEnvironmentInstalled && PodmanInstalled && PodmanMachineReady;
}
public static class PrerequisiteChecker
{
public static PrerequisiteResult Check()
{
var wsl2Ready = IsWSL2Ready();
var podmanMachineReady = IsPodmanMachineReady();
// Fallback : si la machine Podman est prête, WSL2 est nécessairement fonctionnel.
// Cela contourne les problèmes de détection WSL liés à l'encodage ou au PATH.
var virtualEnvironmentInstalled = wsl2Ready || podmanMachineReady;
return new PrerequisiteResult(
WindowsCompatible: IsWindowsCompatible(),
RamMB: GetTotalPhysicalMemoryMB(),
FreeDiskMB: GetFreeDiskSpaceMB("C:\\"),
VirtualEnvironmentInstalled: virtualEnvironmentInstalled,
PodmanInstalled: IsPodmanInstalled(),
PodmanMachineReady: podmanMachineReady
);
}
private static bool IsWindowsCompatible()
{
var os = Environment.OSVersion;
if (os.Platform != PlatformID.Win32NT)
return false;
// Windows 10 version 2004 (build 19041) or Windows 11.
return Environment.OSVersion.Version.Build >= 19041;
}
private static ulong GetTotalPhysicalMemoryMB()
{
try
{
using var searcher = new ManagementObjectSearcher("SELECT TotalVisibleMemorySize FROM Win32_OperatingSystem");
foreach (ManagementObject obj in searcher.Get())
{
var kb = Convert.ToUInt64(obj["TotalVisibleMemorySize"]);
return kb / 1024;
}
}
catch
{
// ignored
}
return 0;
}
private static ulong GetFreeDiskSpaceMB(string path)
{
try
{
var drive = new DriveInfo(Path.GetPathRoot(path) ?? path);
return (ulong)(drive.AvailableFreeSpace / (1024 * 1024));
}
catch
{
return 0;
}
}
public static bool IsWSL2Ready()
{
// PowerShell gère mieux l'encodage de la sortie WSL que Process.Start en C#.
if (IsWSL2ReadyViaPowerShell())
return true;
// Fallback natif si PowerShell n'est pas disponible.
return IsWSL2ReadyNative();
}
private static bool IsWSL2ReadyViaPowerShell()
{
try
{
var tempFile = Path.GetTempFileName();
var script =
"$status = & wsl.exe --status 2>&1; " +
"$ready = ($status -match 'Version par d\\u00E9faut\\s*:\\s*2') -or " +
"($status -match 'Default Version\\s*:\\s*2'); " +
"$ready | Out-File -FilePath '" + tempFile + "' -Encoding utf8 -NoNewline";
var psi = new ProcessStartInfo("powershell.exe", $"-ExecutionPolicy Bypass -Command \"{script}\"")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using var process = Process.Start(psi);
if (process == null) return false;
process.WaitForExit();
if (!File.Exists(tempFile))
return false;
var result = File.ReadAllText(tempFile).Trim();
File.Delete(tempFile);
return result.Equals("True", StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
private static bool IsWSL2ReadyNative()
{
try
{
// wsl --status est plus fiable que --version pour savoir si WSL2 est prêt.
var psi = new ProcessStartInfo("wsl.exe", "--status")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using var process = Process.Start(psi);
if (process == null) return false;
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
// wsl --status peut retourner un code non nul même quand linfo utile est affichée
// (par exemple si aucune distribution nest installée). On parse quand même.
var combined = output + "\n" + error;
var normalized = combined
.Replace('\u00A0', ' ')
.Replace('\u202F', ' ');
if (normalized.Contains("Version par défaut : 2", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("Default Version: 2", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("Version défaut : 2", StringComparison.OrdinalIgnoreCase))
{
return true;
}
var defaultVersion = ParseWslDefaultVersion(combined);
if (defaultVersion == 2)
return true;
// Si aucune version par défaut n'est trouvée, on tente les autres méthodes.
return (defaultVersion == 0 && WslVersionIndicatesWsl2()) ||
WslListIndicatesWsl2();
}
catch
{
return false;
}
}
private static bool IsWSLInstalled()
{
return IsWSL2Ready();
}
private static int ParseWslDefaultVersion(string text)
{
try
{
foreach (var rawLine in text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
// Normalise les espaces insécables et les espaces multiples.
var trimmed = rawLine
.Replace('\u00A0', ' ')
.Replace('\u202F', ' ')
.Trim();
// Regex souple pour matcher :
// - Default Version: 2
// - Version par défaut : 2
// - Version défaut:2
// etc.
var match = Regex.Match(
trimmed,
@"(?i)(?:default\s+version|version\s+(?:par\s+)?d[eé]faut)\s*[:\-]?\s*(\d+)",
RegexOptions.CultureInvariant);
if (match.Success && int.TryParse(match.Groups[1].Value, out var version))
return version;
}
}
catch
{
// ignored
}
return 0;
}
private static bool WslVersionIndicatesWsl2()
{
try
{
var psi = new ProcessStartInfo("wsl.exe", "--version")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using var process = Process.Start(psi);
if (process == null) return false;
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0) return false;
var combined = output + "\n" + error;
// Si la sortie mentionne explicitement WSL 2 ou un noyau 5.10+, on considère WSL2 prêt.
return combined.Contains("WSL version: 2", StringComparison.OrdinalIgnoreCase) ||
combined.Contains("WSL version: 2.0", StringComparison.OrdinalIgnoreCase) ||
combined.Contains("Kernel version: 5.10", StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
private static bool WslListIndicatesWsl2()
{
try
{
var psi = new ProcessStartInfo("wsl.exe", "--list --verbose")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using var process = Process.Start(psi);
if (process == null) return false;
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
var combined = output + "\n" + error;
// Si au moins une distribution est en version 2, WSL2 est fonctionnel.
foreach (var line in combined.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var parts = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2 && parts[^1] == "2")
return true;
}
return false;
}
catch
{
return false;
}
}
public static string? GetPodmanExePath()
{
// 1. Chercher dans le PATH actuel du processus.
try
{
var psi = new ProcessStartInfo("where.exe", "podman.exe")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using var process = Process.Start(psi);
if (process != null)
{
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit();
if (process.ExitCode == 0)
{
var firstLine = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
if (!string.IsNullOrWhiteSpace(firstLine) && File.Exists(firstLine))
return firstLine;
}
}
}
catch
{
// ignored
}
// 2. Chercher dans les emplacements d'installation connus.
var candidates = new[]
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "podman", "podman.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "RedHat", "Podman", "podman.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Podman", "podman.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "RedHat", "Podman", "podman.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Podman", "podman.exe"),
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
return candidate;
}
return null;
}
private static bool IsPodmanInstalled()
{
var podmanPath = GetPodmanExePath();
if (string.IsNullOrEmpty(podmanPath))
return false;
try
{
var psi = new ProcessStartInfo(podmanPath, "--version")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using var process = Process.Start(psi);
if (process == null) return false;
process.WaitForExit();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
private static bool IsPodmanMachineReady()
{
var podmanPath = GetPodmanExePath();
if (string.IsNullOrEmpty(podmanPath))
return false;
try
{
var psi = new ProcessStartInfo(podmanPath, "machine list --format json")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using var process = Process.Start(psi);
if (process == null) return false;
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0) return false;
// Very permissive check: if podman machine list returns any JSON, we consider it ready.
return output.TrimStart().StartsWith("[") || output.TrimStart().StartsWith("{");
}
catch
{
return false;
}
}
}
+19
View File
@@ -0,0 +1,19 @@
using StudioE5.SetupWizard;
static class Program
{
[STAThread]
static void Main(string[] args)
{
ApplicationConfiguration.Initialize();
if (args.Contains("/uninstall", StringComparer.OrdinalIgnoreCase))
{
Application.Run(new MainForm(startInUninstallMode: true));
}
else
{
Application.Run(new MainForm(startInUninstallMode: false));
}
}
}
+105
View File
@@ -0,0 +1,105 @@
# StudioE5 Setup Wizard
Assistant dinstallation graphique Windows pour studioE5 Agent.
## Rôle
Ce wizard guide lutilisateur pas à pas pour :
1. Vérifier les prérequis (RAM, disque, Windows, environnement virtuel, Podman).
2. Installer l**environnement virtuel** (WSL2) si nécessaire, avec reprise après redémarrage.
3. Installer **Podman** depuis le MSI bundlé.
4. Initialiser et démarrer la **machine Podman**.
5. Lancer le package **Inno Setup** de studioE5 Agent.
Il propose aussi un mode **désinstallation** complet (`/uninstall`).
## Prérequis de build
- Windows 10/11
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
- Visual Studio 2022 ou Visual Studio Code (optionnel)
## Structure
```text
setup-wizard/
├── SetupWizard.csproj
├── Program.cs
├── MainForm.cs
├── InstallerState.cs
├── PrerequisiteChecker.cs
├── app.manifest
└── Resources/
├── podman-installer-windows-amd64.msi # MSI officiel Podman pour Windows
├── studioE5-agent-setup.exe # Package Inno Setup de l'agent
├── Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle # Package WSL2 complet (offline)
├── podman-machine.x86_64.wsl.tar.zst # Image Podman machine pour WSL (offline)
├── docker-compose-windows-x86_64.exe # Docker Compose standalone (offline)
└── wsl_update_x64.msi # Noyau WSL2 (optionnel, fallback)
```
## Build
Ouvrir un terminal PowerShell dans ce dossier et exécuter :
```powershell
dotnet build -c Release
```
Pour publier un exécutable autonome (pas besoin du runtime .NET sur le poste cible) :
```powershell
dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true
```
Lexécutable se trouve dans :
```text
bin\Release\net8.0-windows\win-x64\publish\StudioE5-SetupWizard.exe
```
## Préparation du package
1. Télécharger le MSI Podman Windows :
<https://github.com/containers/podman/releases>
Le renommer en `podman-installer-windows-amd64.msi` et le placer dans `Resources/`.
2. Générer le package Inno Setup de lagent (`studioE5-agent-setup.exe`) et le placer dans `Resources/`.
3. Télécharger le package WSL2 complet (offline) :
<https://github.com/microsoft/WSL/releases>
Par exemple : `Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle`.
Le placer dans `Resources/`.
4. Télécharger limage Podman machine pour WSL (offline) :
<https://github.com/containers/podman-machine-os/releases>
Par exemple : `podman-machine.x86_64.wsl.tar.zst`.
Le placer dans `Resources/`.
5. Télécharger Docker Compose standalone (offline) :
<https://github.com/docker/compose/releases>
Par exemple : `docker-compose-windows-x86_64.exe`.
Le placer dans `Resources/`.
6. *(Optionnel, fallback)* Télécharger le noyau WSL2 :
<https://github.com/microsoft/WSL/releases>
Par exemple : `wsl.2.7.10.0.x64.msi`, à renommer en `wsl_update_x64.msi`.
Le placer dans `Resources/`.
6. Builder et publier le wizard.
## Lancement
### Mode installation
```powershell
.\StudioE5-SetupWizard.exe
```
### Mode désinstallation
```powershell
.\StudioE5-SetupWizard.exe /uninstall
```
## Notes
- Le wizard doit être exécuté **en administrateur**.
- Linstallation de WSL2 nécessite un **redémarrage** de lordinateur. Le wizard senregistre dans `RunOnce` pour se relancer automatiquement.
- Le wizard configure WSL2 avec **8 Go de RAM et 4 CPU** via le fichier `.wslconfig` de lutilisateur.
- Le MSI Podman doit correspondre à larchitecture `x64`.
@@ -0,0 +1,127 @@
# Feuille de route — Installateur studioE5 Agent
## Objectif
Fournir un **installateur professionnel Windows** pour studioE5 Agent, guidé pas à pas, qui gère les prérequis (WSL2 / Podman) et propose une désinstallation complète.
## Architecture choisie
- **Wizard C# Windows Forms (.NET 8)** : `setup-wizard/`
- Détecte les prérequis.
- Installe WSL2 si besoin (avec reprise après redémarrage via `RunOnce`).
- Installe Podman depuis le MSI officiel.
- Initialise et démarre la machine Podman.
- Lance le package Inno Setup de studioE5 Agent.
- Mode désinstallation via `/uninstall`.
- **Package agent (Inno Setup)** : `studioE5-agent.iss`
- Installe `studioE5-agent.exe` + binaires Tailscale.
- Crée les raccourcis.
- Gère la désinstallation.
## État actuel
### ✅ Réalisé
- Wizard C# avec 7 étapes guidées.
- Détection des prérequis : Windows, RAM, disque, WSL2, Podman.
- Installation WSL2 en plusieurs étapes contrôlées avec suivi visuel :
1. Activation des fonctionnalités Windows (`Microsoft-Windows-Subsystem-Linux` et `VirtualMachinePlatform`) avec gestion du code 3010 (redémarrage nécessaire).
2. Installation du package WSL2 complet depuis le bundle Microsoft Store offline (`Microsoft.WSL_*.msixbundle`) via `Add-AppxPackage`.
3. Mise à jour du noyau WSL2 depuis le MSI bundlé (`wsl_update_x64.msi`) ou, en dernier recours, via `wsl --update`.
4. Définition de WSL2 comme version par défaut (`wsl --set-default-version 2`).
5. L’étape `wsl --install --no-distribution` nest plus utilisée : linstallation est entièrement offline grâce au bundle.
6. Conversion des distributions WSL1 existantes vers WSL2 (`wsl --set-version <nom> 2`) si nécessaire.
- Reprise automatique après redémarrage grâce à `RunOnce` (y compris redémarrage post-installation WSL2).
- Versioning indépendant du wizard (fichier `setup-wizard/VERSION`, affiché dans la fenêtre et la page daccueil).
- Amélioration de linterface : fenêtre agrandie à 800×600, `AutoScaleMode = Dpi`, meilleur rendu sur petits écrans (11 pouces).
- `RunCommand` capture désormais stdout + stderr pour des diagnostics et fallbacks plus fiables.
- Détection des prérequis vérifie que WSL2 (et non WSL1) est installé via `wsl --status`.
- Installation Podman via MSI bundlé.
- Configuration Podman (`machine init` + `machine start`), avec initialisation offline possible depuis une image locale (`podman-machine.*.wsl.tar.zst`).
- Lancement du package Inno Setup agent.
- Mode désinstallation complet.
- Script Inno Setup de base pour lagent.
### 🔄 En cours / À tester
- Compilation et test du wizard sur Windows.
- Packaging final (wizard + MSI Podman + setup agent) en un seul dossier distribuable.
### ⏳ À venir
- Signature de lexécutable pour éviter les alertes SmartScreen.
- Support macOS et Linux.
- Installateur silencieux possible pour déploiement GPO.
## Build du wizard
### Prérequis
- Windows 10/11
- .NET 8 SDK
- Inno Setup 6 (pour générer `studioE5-agent-setup.exe`)
### Fichiers à placer
Dans `setup-wizard/Resources/` :
```text
podman-installer-windows-amd64.msi
studioE5-agent-setup.exe
Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle # package WSL2 complet (offline)
podman-machine.x86_64.wsl.tar.zst # image Podman machine pour WSL (offline)
docker-compose-windows-x86_64.exe # Docker Compose standalone (offline)
wsl_update_x64.msi # optionnel, fallback noyau WSL2
```
### Commande
```powershell
cd setup-wizard
dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true
```
### Sortie
```text
setup-wizard\bin\Release\net8.0-windows\win-x64\publish\StudioE5-SetupWizard.exe
```
## Build du package agent (Inno Setup)
Structure attendue :
```text
agent/
├── studioE5-agent.exe
├── tailscale-bin/
│ └── windows/
│ ├── tailscale.exe
│ ├── tailscaled.exe
│ └── wintun.dll
└── installer/
└── studioE5-agent.iss
```
Ouvrir `studioE5-agent.iss` avec Inno Setup Compiler et compiler (`Ctrl+F9`).
Le fichier généré se trouve dans `installer-output/`.
## Notes importantes
- Le wizard doit être exécuté **en administrateur**.
- Linstallation de WSL2 nécessite un **redémarrage** de lordinateur après lactivation des fonctionnalités Windows.
- Linstallation de WSL2 est découpée en étapes et chaque étape est enregistrée pour permettre la reprise après redémarrage.
- Podman nécessite WSL2 : le wizard vérifie donc que la version par défaut de WSL est 2.
- Le MSI Podman officiel pour Windows est `podman-installer-windows-amd64.msi`.
- Le bundle WSL2 offline est disponible sur <https://github.com/microsoft/WSL/releases> : `Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle`.
- Limage Podman machine offline est disponible sur <https://github.com/containers/podman-machine-os/releases> : `podman-machine.x86_64.wsl.tar.zst`.
- Docker Compose standalone est disponible sur <https://github.com/docker/compose/releases> : `docker-compose-windows-x86_64.exe`.
- Le wizard crée automatiquement un fichier `.wslconfig` allouant **8 Go de RAM et 4 CPU** à WSL2, ce qui est nécessaire pour des applications lourdes comme PrestaShop.
- Pour la désinstallation, le MSI Podman doit être présent dans `Resources/`.
## Liens utiles
- Releases Podman : <https://github.com/containers/podman/releases>
- Inno Setup : <https://jrsoftware.org/isdl.php>
- .NET 8 SDK : <https://dotnet.microsoft.com/download/dotnet/8.0>
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<SatelliteResourceLanguages>fr</SatelliteResourceLanguages>
<RootNamespace>StudioE5.SetupWizard</RootNamespace>
<AssemblyName>StudioE5-SetupWizard</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Management" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<!-- Fichier de version affiché dans le wizard. -->
<Content Include="VERSION">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<!-- Tous les fichiers placés dans Resources/ sont copiés dans le répertoire de sortie. -->
<!-- Attendus : MSI Podman, setup agent, bundle WSL, image Podman machine, MSI noyau WSL (optionnel). -->
<Content Include="Resources\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
+1
View File
@@ -0,0 +1 @@
0.1.1
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="StudioE5.SetupWizard.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- Force l'exécution en tant qu'administrateur -->
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
@@ -0,0 +1,120 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Installe ou répare WSL2 de manière fiable.
.DESCRIPTION
Ce script :
1. Vérifie si WSL2 est déjà prêt.
2. Active les fonctionnalités Windows nécessaires.
3. Définit WSL2 comme version par défaut.
4. Met à jour le noyau WSL2.
5. Installe WSL sans distribution si possible.
Un redémarrage peut être nécessaire après lactivation des fonctionnalités.
#>
$ErrorActionPreference = "Stop"
function Test-Wsl2Ready {
try {
$output = & wsl.exe --status 2>&1
$exitCode = $LASTEXITCODE
Write-Host "[Test] wsl --status exit code: $exitCode" -ForegroundColor Cyan
if ($output) {
Write-Host "[Test] wsl --status output:" -ForegroundColor Cyan
$output | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
}
if ($exitCode -eq 0 -or ($output -match "Version par défaut\s*:\s*2") -or ($output -match "Default Version\s*:\s*2")) {
return $true
}
}
catch {
Write-Host "[Test] wsl --status a échoué : $_" -ForegroundColor Yellow
}
return $false
}
function Enable-WindowsFeatureIfNeeded {
param([string]$FeatureName)
Write-Host "[Feature] Activation de $FeatureName..." -ForegroundColor Cyan
$result = & dism.exe /online /enable-feature /featurename:$FeatureName /all /norestart 2>&1
$exitCode = $LASTEXITCODE
$result | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
if ($exitCode -eq 0) {
Write-Host "[Feature] $FeatureName activé (pas de redémarrage nécessaire)." -ForegroundColor Green
return $false
}
elseif ($exitCode -eq 3010) {
Write-Host "[Feature] $FeatureName activé, mais un redémarrage est nécessaire (code 3010)." -ForegroundColor Yellow
return $true
}
else {
throw "Échec de l'activation de $FeatureName (code $exitCode)."
}
}
function Install-Wsl2 {
Write-Host "[WSL] Tentative d'installation sans distribution..." -ForegroundColor Cyan
try {
& wsl.exe --install --no-distribution 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
if ($LASTEXITCODE -ne 0) { throw "wsl --install --no-distribution a retourné le code $LASTEXITCODE" }
Write-Host "[WSL] Installation sans distribution réussie." -ForegroundColor Green
return
}
catch {
Write-Host "[WSL] Option --no-distribution non supportée ou échec : $_" -ForegroundColor Yellow
}
Write-Host "[WSL] Fallback : installation classique de WSL..." -ForegroundColor Cyan
& wsl.exe --install 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
if ($LASTEXITCODE -ne 0) { throw "wsl --install a retourné le code $LASTEXITCODE" }
Write-Host "[WSL] Installation classique réussie." -ForegroundColor Green
}
# === Début du script ===
Write-Host "=== Installation / réparation WSL2 ===" -ForegroundColor Green
if (Test-Wsl2Ready) {
Write-Host "WSL2 est déjà prêt. Rien à faire." -ForegroundColor Green
exit 0
}
Write-Host "WSL2 n'est pas détecté. Lancement de l'installation..." -ForegroundColor Yellow
$rebootNeeded = $false
$rebootNeeded = (Enable-WindowsFeatureIfNeeded -FeatureName "Microsoft-Windows-Subsystem-Linux") -or $rebootNeeded
$rebootNeeded = (Enable-WindowsFeatureIfNeeded -FeatureName "VirtualMachinePlatform") -or $rebootNeeded
if ($rebootNeeded) {
Write-Host "`nUn redémarrage est nécessaire pour activer les fonctionnalités Windows." -ForegroundColor Yellow
Write-Host "Après le redémarrage, relance ce script pour terminer l'installation de WSL2." -ForegroundColor Yellow
$response = Read-Host "Redémarrer maintenant ? (O/N)"
if ($response -eq "O" -or $response -eq "o") {
Restart-Computer -Force
}
exit 3010
}
Write-Host "[WSL] Définition de WSL2 comme version par défaut..." -ForegroundColor Cyan
& wsl.exe --set-default-version 2 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
if ($LASTEXITCODE -ne 0) { throw "wsl --set-default-version 2 a échoué (code $LASTEXITCODE)." }
Write-Host "[WSL] Mise à jour du noyau WSL2..." -ForegroundColor Cyan
& wsl.exe --update 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
# 3010 = succès mais redémarrage possible
if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 3010) { throw "wsl --update a échoué (code $LASTEXITCODE)." }
Install-Wsl2
if (Test-Wsl2Ready) {
Write-Host "`nWSL2 est maintenant prêt." -ForegroundColor Green
exit 0
}
else {
Write-Host "`nWSL2 ne semble toujours pas prêt. Essayez de redémarrer et de relancer le script." -ForegroundColor Red
exit 1
}
+199
View File
@@ -0,0 +1,199 @@
; studioE5 Agent Installer (Inno Setup)
; Build with Inno Setup Compiler (ISCC) on Windows.
; This installer bundles the agent and Tailscale binaries. It checks
; prerequisites and guides the user through installing missing system
; components (WSL2 + Podman) before installing studioE5.
#define MyAppName "studioE5 Agent"
#define MyAppVersion "0.3.17"
#define MyAppPublisher "studioE5"
#define MyAppURL "https://studioe5.edudeploy.com"
#define MyAppExeName "studioE5-agent.exe"
[Setup]
AppId={{studioE5-agent-ondemand}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\studioE5-agent
DisableProgramGroupPage=yes
OutputDir=..\..\installer-output
OutputBaseFilename=studioE5-agent-{#MyAppVersion}-setup
Compression=lzma
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=admin
ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64
[Languages]
Name: "french"; MessagesFile: "compiler:Languages\French.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "..\..\agent\studioE5-agent.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\..\agent\tailscale-bin\windows\tailscale.exe"; DestDir: "{app}\tailscale-bin\windows"; Flags: ignoreversion
Source: "..\..\agent\tailscale-bin\windows\tailscaled.exe"; DestDir: "{app}\tailscale-bin\windows"; Flags: ignoreversion
Source: "..\..\agent\tailscale-bin\windows\wintun.dll"; DestDir: "{app}\tailscale-bin\windows"; Flags: ignoreversion
Source: "..\..\README.md"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{autoprograms}\studioE5 Agent"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\studioE5 Agent"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "Lancer studioE5 Agent"; Flags: nowait postinstall skipifsilent
[UninstallRun]
Filename: "{cmd}"; Parameters: "/c taskkill /f /im studioE5-agent.exe"; Flags: runhidden waituntilterminated
[Code]
var
PrereqPage: TWizardPage;
lblStatus: TLabel;
btnCheck: TButton;
function GetPhysicallyInstalledSystemMemoryKB(var TotalMemoryInKilobytes: Int64): Boolean;
external 'GetPhysicallyInstalledSystemMemory@kernel32.dll stdcall';
function GetTotalPhysicalMemoryMB(): Cardinal;
var
MemKB: Int64;
begin
if GetPhysicallyInstalledSystemMemoryKB(MemKB) then
Result := Cardinal(MemKB div 1024)
else
Result := 0;
end;
function IsWSL2Installed(): Boolean;
var
ResultCode: Integer;
begin
Result := Exec('wsl.exe', '--version', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0);
end;
function IsPodmanReady(): Boolean;
var
ResultCode: Integer;
begin
Result := Exec('podman.exe', 'machine list', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0);
end;
function GetDiskFreeSpaceEx(
lpDirectoryName: string;
var lpFreeBytesAvailableToCaller: Int64;
var lpTotalNumberOfBytes: Int64;
var lpTotalNumberOfFreeBytes: Int64
): Boolean;
external 'GetDiskFreeSpaceExW@kernel32.dll stdcall';
function GetFreeDiskSpaceMB(const Path: string): Cardinal;
var
FreeBytes, TotalBytes: Int64;
Dummy: Int64;
begin
if GetDiskFreeSpaceEx(Path, FreeBytes, TotalBytes, Dummy) then
Result := Cardinal(FreeBytes div (1024 * 1024))
else
Result := 0;
end;
procedure UpdatePrereqStatus();
var
Msg: string;
RamMB, FreeMB: Cardinal;
WSLReady, PodmanReady: Boolean;
begin
RamMB := GetTotalPhysicalMemoryMB();
FreeMB := GetFreeDiskSpaceMB('C:\');
WSLReady := IsWSL2Installed();
PodmanReady := IsPodmanReady();
Msg := 'Vérification des prérequis :' + #13#10#13#10;
if RamMB >= 8192 then
Msg := Msg + '✅ Mémoire vive (RAM) : ' + IntToStr(RamMB div 1024) + ' Go' + #13#10
else
Msg := Msg + '⚠️ Mémoire vive (RAM) : ' + IntToStr(RamMB div 1024) + ' Go (8 Go recommandés)' + #13#10;
if FreeMB >= 10240 then
Msg := Msg + '✅ Espace disque disponible : ' + IntToStr(FreeMB div 1024) + ' Go' + #13#10
else
Msg := Msg + '⚠️ Espace disque disponible : ' + IntToStr(FreeMB div 1024) + ' Go (10 Go recommandés)' + #13#10;
if WSLReady then
Msg := Msg + '✅ Environnement de virtualisation (WSL2) installé' + #13#10
else
Msg := Msg + '❌ Environnement de virtualisation (WSL2) non installé' + #13#10;
if PodmanReady then
Msg := Msg + '✅ Service de conteneurs (Podman) prêt' + #13#10
else
Msg := Msg + '❌ Service de conteneurs (Podman) non prêt' + #13#10;
Msg := Msg + #13#10;
if WSLReady and PodmanReady and (RamMB >= 4096) and (FreeMB >= 5120) then
Msg := Msg + 'Tous les prérequis sont satisfaits. Vous pouvez installer studioE5 Agent.'
else
begin
Msg := Msg + 'Ordre d''installation recommandé :' + #13#10;
if not WSLReady then
Msg := Msg + '1. Installer WSL2 : ouvrir PowerShell en administrateur et exécuter : wsl --install --no-distribution' + #13#10;
if not PodmanReady then
Msg := Msg + '2. Installer Podman : télécharger et exécuter le MSI depuis https://github.com/containers/podman/releases' + #13#10;
if not PodmanReady then
Msg := Msg + '3. Initialiser Podman : podman machine init && podman machine start' + #13#10;
Msg := Msg + #13#10 + 'Après avoir installé les éléments manquants, relancez cet installateur.';
end;
lblStatus.Caption := Msg;
end;
procedure btnCheckClick(Sender: TObject);
begin
UpdatePrereqStatus();
end;
procedure InitializeWizard();
begin
PrereqPage := CreateCustomPage(wpWelcome, 'Vérification des prérequis', 'Assurez-vous que votre poste est prêt avant d''installer studioE5 Agent.');
lblStatus := TLabel.Create(WizardForm);
lblStatus.Parent := PrereqPage.Surface;
lblStatus.Left := 0;
lblStatus.Top := 0;
lblStatus.Width := PrereqPage.SurfaceWidth;
lblStatus.Height := 220;
lblStatus.AutoSize := False;
lblStatus.WordWrap := True;
btnCheck := TButton.Create(WizardForm);
btnCheck.Parent := PrereqPage.Surface;
btnCheck.Left := 0;
btnCheck.Top := lblStatus.Top + lblStatus.Height + 12;
btnCheck.Width := 160;
btnCheck.Height := 25;
btnCheck.Caption := 'Vérifier les prérequis';
btnCheck.OnClick := @btnCheckClick;
UpdatePrereqStatus();
end;
function NextButtonClick(CurPageID: Integer): Boolean;
begin
Result := True;
if CurPageID = PrereqPage.ID then
begin
if not (IsWSL2Installed() and IsPodmanReady()) then
begin
MsgBox('Certains prérequis sont manquants. Veuillez les installer avant de continuer.', mbError, MB_OK);
Result := False;
end;
end;
end;
+34
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -88,6 +89,7 @@ func getInstanceStatus(dataDir, instanceID string) string {
// Try modern JSON format first // Try modern JSON format first
cmd := exec.Command(engine, "compose", "-f", composeFile, "ps", "--format", "json") cmd := exec.Command(engine, "compose", "-f", composeFile, "ps", "--format", "json")
hideWindow(cmd)
out, err := cmd.Output() out, err := cmd.Output()
if err == nil { if err == nil {
outStr := strings.TrimSpace(string(out)) outStr := strings.TrimSpace(string(out))
@@ -119,6 +121,7 @@ func getInstanceStatus(dataDir, instanceID string) string {
// Fallback: use "ps -q" which is supported by all docker-compose versions // Fallback: use "ps -q" which is supported by all docker-compose versions
cmd = exec.Command(engine, "compose", "-f", composeFile, "ps", "-q") cmd = exec.Command(engine, "compose", "-f", composeFile, "ps", "-q")
hideWindow(cmd)
out, err = cmd.Output() out, err = cmd.Output()
if err != nil { if err != nil {
return "error" return "error"
@@ -128,3 +131,34 @@ func getInstanceStatus(dataDir, instanceID string) string {
} }
return "stopped" return "stopped"
} }
// cleanupOrphanInstanceDirs removes instance directories that have no entry in
// instances.json. This typically happens on Windows when a delete operation
// could not fully remove the directory because compose.log was locked.
func cleanupOrphanInstanceDirs(dataDir string) {
instancesDir := filepath.Join(dataDir, "instances")
inst, err := loadInstances(dataDir)
if err != nil {
log.Printf("cleanupOrphanInstanceDirs: loadInstances error: %v", err)
return
}
entries, err := os.ReadDir(instancesDir)
if err != nil {
if !os.IsNotExist(err) {
log.Printf("cleanupOrphanInstanceDirs: ReadDir error: %v", err)
}
return
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if _, ok := inst[entry.Name()]; !ok {
dir := filepath.Join(instancesDir, entry.Name())
log.Printf("cleanupOrphanInstanceDirs: removing orphan directory %s", dir)
if err := os.RemoveAll(dir); err != nil {
log.Printf("cleanupOrphanInstanceDirs: RemoveAll error for %s: %v", dir, err)
}
}
}
}
+94 -8
View File
@@ -2,20 +2,21 @@ package main
import ( import (
"flag" "flag"
"io"
"log" "log"
"os" "os"
"os/signal"
"path/filepath" "path/filepath"
"runtime" "runtime"
"sync"
"syscall"
"time" "time"
) )
// version is injected at build time via -ldflags "-X main.version=X.Y.Z" // version is injected at build time via -ldflags "-X main.version=X.Y.Z"
var version = "dev" var version = "dev"
const ( const APP_NAME = "studioE5"
AGENT_VERSION = "0.3.0"
APP_NAME = "studioE5"
)
var ( var (
dataDir = flag.String("data-dir", "./studioE5-data", "Répertoire de données") dataDir = flag.String("data-dir", "./studioE5-data", "Répertoire de données")
@@ -42,6 +43,14 @@ func main() {
log.Fatalf("Cannot create data-dir: %v", err) log.Fatalf("Cannot create data-dir: %v", err)
} }
// Redirect agent logs to a file so the console can be hidden on Windows.
agentLogPath := filepath.Join(*dataDir, "agent.log")
if agentLogFile, err := os.OpenFile(agentLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
log.SetOutput(io.MultiWriter(agentLogFile, uiLogWriter{}))
} else {
log.Printf("Cannot open agent log file %s: %v", agentLogPath, err)
}
cfg, _, err := loadOrCreateConfig(*dataDir) cfg, _, err := loadOrCreateConfig(*dataDir)
if err != nil { if err != nil {
log.Fatalf("Cannot load config: %v", err) log.Fatalf("Cannot load config: %v", err)
@@ -51,18 +60,71 @@ func main() {
log.Fatalf("Cannot save config: %v", err) log.Fatalf("Cannot save config: %v", err)
} }
log.Printf("[%s Agent] version=%s node=%s data-dir=%s server=%s", APP_NAME, AGENT_VERSION, cfg.NodeID, *dataDir, cfg.Server) log.Printf("[%s Agent] version=%s node=%s data-dir=%s server=%s", APP_NAME, version, cfg.NodeID, *dataDir, cfg.Server)
// Clean up instance directories left behind by failed deletes (common on
// Windows when compose.log is locked during removal).
cleanupOrphanInstanceDirs(*dataDir)
// Ensure Podman machine DNS is configured on Windows/macOS so images can be
// pulled and containers can reach the internet.
ensurePodmanMachineDNS()
if *uiEnabled { if *uiEnabled {
go startUI(*dataDir, cfg.NodeID, cfg.Server) go startUI(*dataDir, cfg.NodeID, cfg.Server)
} }
go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey) go startWebSocket(cfg, cfg.NodeID, *dataDir)
go updateCheckerLoop(cfg, *dataDir)
shutdownCh := make(chan struct{}) shutdownCh := make(chan struct{})
// Capture Ctrl+C / SIGTERM so a console window close or service stop
// triggers the same cleanup path as the tray "Quit" menu.
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh
log.Println("Shutdown signal received")
close(shutdownCh)
}()
var cleanupWg sync.WaitGroup
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...")
// Stop Tailscale so the next agent start does not conflict on the
// same socket/state.
stopTailscale()
// Stop any running instances so containers are not left behind, but keep
// their volumes intact so data survives the next agent start.
if inst, err := loadInstances(*dataDir); err == nil {
for id, info := range inst {
if info.Status == "running" {
log.Printf("Stopping instance %s", id)
_ = dockerComposeStop(*dataDir, id)
inst[id].Status = "stopped"
go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: id})
}
}
_ = saveInstances(*dataDir, inst)
}
log.Println("Cleanup complete")
}()
if *noTray { if *noTray {
log.Printf("[%s Agent] running in console mode (no tray)", APP_NAME) log.Printf("[%s Agent] running in console mode (no tray)", APP_NAME)
<-shutdownCh <-shutdownCh
cleanupWg.Wait()
return return
} }
@@ -75,11 +137,16 @@ func main() {
}() }()
<-shutdownCh <-shutdownCh
cleanupWg.Wait()
} }
func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) { func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
tsDir := filepath.Join(dataDir, "tailscale") defer func() {
ip, err := startTailscale(tsDir, nodeID, headscaleURL, authKey) if r := recover(); r != nil {
log.Printf("PANIC in startTailscaleAndReport: %v", r)
}
}()
ip, err := startTailscale(dataDir, nodeID, headscaleURL, authKey)
if err != nil { if err != nil {
log.Printf("Tailscale error: %v", err) log.Printf("Tailscale error: %v", err)
return return
@@ -95,4 +162,23 @@ func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
log.Printf("Sent tailscale_ip to server: %s", ip) log.Printf("Sent tailscale_ip to server: %s", ip)
break break
} }
// Reconfigure tailscale serve for any instances that were left running
// (e.g. after an agent restart while containers kept running).
if inst, err := loadInstances(dataDir); err == nil {
for id, info := range inst {
if info.Status == "running" {
log.Printf("Reconfiguring tailscale serve for running instance %s on port %d", id, info.Port)
if err := setupTailscaleServe(info.Port); err != nil {
log.Printf("setupTailscaleServe error for %s: %v", id, err)
}
}
}
}
// Notify the local UI that the service status has changed.
broadcastUI(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
})
} }
+65
View File
@@ -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
}
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
const (
ProxyModeDisabled = "disabled"
ProxyModeAuto = "auto"
ProxyModeEnabled = "enabled"
)
// autoProxyLockDuration is the minimum time we stay in proxy mode once the
// agent automatically switched to it. This prevents flip-flopping on short
// network blips.
const autoProxyLockDuration = 5 * time.Minute
// proxyState tracks the runtime proxy decision in "auto" mode. It is guarded
// by proxyMu.
var (
proxyMu sync.RWMutex
proxyActive bool
proxyLockedUntil time.Time
)
// proxyMode normalizes the configured proxy mode.
func proxyMode(cfg *AgentConfig) string {
if cfg == nil {
return ProxyModeDisabled
}
switch strings.ToLower(strings.TrimSpace(cfg.ProxyMode)) {
case ProxyModeEnabled:
return ProxyModeEnabled
case ProxyModeAuto:
return ProxyModeAuto
default:
return ProxyModeDisabled
}
}
// IsProxyActive reports whether outbound requests should currently go through
// the configured proxy. In "enabled" mode it always returns true; in "auto"
// mode it reflects the last automatic decision.
func IsProxyActive() bool {
proxyMu.RLock()
defer proxyMu.RUnlock()
return proxyActive
}
// setProxyActive updates the runtime proxy decision and, in auto mode, locks
// the decision for autoProxyLockDuration to avoid flip-flopping.
func setProxyActive(active bool) bool {
proxyMu.Lock()
defer proxyMu.Unlock()
changed := proxyActive != active
proxyActive = active
if active {
proxyLockedUntil = time.Now().Add(autoProxyLockDuration)
}
return changed
}
// resetProxyState disables the automatic proxy decision. Call this when the
// configuration changes.
func resetProxyState() {
proxyMu.Lock()
proxyActive = false
proxyLockedUntil = time.Time{}
proxyMu.Unlock()
}
// canRetryDirect reports whether enough time has passed to try a direct
// connection again while in auto-proxy mode.
func canRetryDirect() bool {
proxyMu.RLock()
defer proxyMu.RUnlock()
return time.Now().After(proxyLockedUntil)
}
// proxyURL parses and validates the configured proxy URL.
func proxyURL(cfg *AgentConfig) *url.URL {
if cfg == nil || cfg.ProxyURL == "" {
return nil
}
u, err := url.Parse(cfg.ProxyURL)
if err != nil {
return nil
}
return u
}
// proxyFunc returns a proxy selection function for http.Transport. It returns
// nil when the proxy should not be used.
func proxyFunc(cfg *AgentConfig) func(*http.Request) (*url.URL, error) {
mode := proxyMode(cfg)
u := proxyURL(cfg)
switch mode {
case ProxyModeEnabled:
if u == nil {
return nil
}
return func(*http.Request) (*url.URL, error) { return u, nil }
case ProxyModeAuto:
if u == nil {
return nil
}
if !IsProxyActive() {
return nil
}
return func(*http.Request) (*url.URL, error) { return u, nil }
default:
return nil
}
}
// websocketDialer returns a websocket.Dialer configured for the current proxy
// mode and state.
func websocketDialer(cfg *AgentConfig) *websocket.Dialer {
d := websocket.DefaultDialer
fn := proxyFunc(cfg)
if fn == nil {
return d
}
return &websocket.Dialer{
Proxy: fn,
HandshakeTimeout: d.HandshakeTimeout,
ReadBufferSize: d.ReadBufferSize,
WriteBufferSize: d.WriteBufferSize,
EnableCompression: d.EnableCompression,
}
}
// httpClientWithProxy returns an http.Client configured for the current proxy
// mode and state.
func httpClientWithProxy(cfg *AgentConfig) *http.Client {
fn := proxyFunc(cfg)
if fn == nil {
return http.DefaultClient
}
return &http.Client{
Transport: &http.Transport{Proxy: fn},
}
}
Binary file not shown.
+3 -1
View File
@@ -80,7 +80,9 @@ func openBrowser(url string) {
args = []string{url} args = []string{url}
} }
if err := exec.Command(cmd, args...).Start(); err != nil { openCmd := exec.Command(cmd, args...)
hideWindow(openCmd)
if err := openCmd.Start(); err != nil {
log.Printf("Failed to open browser: %v", err) log.Printf("Failed to open browser: %v", err)
} }
} }
+145 -17
View File
@@ -9,6 +9,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@@ -61,40 +62,69 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro
if err := os.MkdirAll(tsDataDir, 0700); err != nil { if err := os.MkdirAll(tsDataDir, 0700); err != nil {
return "", fmt.Errorf("create tailscale dir: %w", err) return "", fmt.Errorf("create tailscale dir: %w", err)
} }
tsSocket = filepath.Join(tsDataDir, "tailscaled.sock") // Make sure a previous tailscaled (e.g. left behind after a crash or
// force-kill) does not block the new daemon on the same socket/state.
killStaleTailscaled(tsDataDir)
if runtime.GOOS == "windows" {
// Windows uses named pipes for tailscaled IPC, not Unix sockets.
tsSocket = `\\.\pipe\studioe5-tailscaled`
} else {
tsSocket = filepath.Join(tsDataDir, "tailscaled.sock")
}
stateFile := filepath.Join(tsDataDir, "tailscaled.state") stateFile := filepath.Join(tsDataDir, "tailscaled.state")
log.Printf("Starting tailscaled for node %s", nodeID) log.Printf("Starting tailscaled for node %s (socket=%s)", nodeID, tsSocket)
tsCmd = exec.Command(tailscaleBin("tailscaled"), tsCmd = exec.Command(tailscaleBin("tailscaled"),
"--state="+stateFile, "--state="+stateFile,
"--socket="+tsSocket, "--socket="+tsSocket,
"--tun=userspace-networking", "--tun=userspace-networking",
) )
tsCmd.Stdout = os.Stdout hideWindow(tsCmd)
tsCmd.Stderr = os.Stderr // Redirect tailscaled output to a dedicated log file.
tsLogPath := filepath.Join(tsDataDir, "tailscaled.log")
if tsLogFile, err := os.OpenFile(tsLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
tsCmd.Stdout = tsLogFile
tsCmd.Stderr = tsLogFile
} else {
log.Printf("Cannot open tailscaled log file %s: %v", tsLogPath, err)
}
if err := tsCmd.Start(); err != nil { if err := tsCmd.Start(); err != nil {
tsCmd = nil tsCmd = nil
return "", fmt.Errorf("start tailscaled: %w", err) return "", fmt.Errorf("start tailscaled: %w", err)
} }
if err := os.WriteFile(filepath.Join(tsDataDir, "tailscaled.pid"), []byte(strconv.Itoa(tsCmd.Process.Pid)), 0644); err != nil {
log.Printf("Cannot write tailscaled pid file: %v", err)
}
// Give tailscaled a moment to start listening. // Give tailscaled a moment to start listening.
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
// Bring the interface up with the auth key. // Bring the interface up with the auth key.
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel() defer cancel()
upCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"), upArgs := []string{
"--socket="+tsSocket, "--socket=" + tsSocket,
"up", "up",
"--authkey="+authKey, "--login-server=" + headscaleURL,
"--login-server="+headscaleURL, "--hostname=" + nodeID,
"--hostname="+nodeID,
"--accept-dns=false", "--accept-dns=false",
"--operator=root", }
) // The auth key is omitted on reconnect: Tailscale reuses the existing state.
upCmd.Stdout = os.Stdout if authKey != "" {
upCmd.Stderr = os.Stderr upArgs = append(upArgs, "--authkey="+authKey)
}
if runtime.GOOS == "windows" {
// On Windows, keep the VPN up even after the tailscale.exe CLI client disconnects.
upArgs = append(upArgs, "--unattended")
} else {
// --operator is only meaningful on Unix systems.
upArgs = append(upArgs, "--operator=root")
}
upCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"), upArgs...)
hideWindow(upCmd)
upCmd.Stdout = log.Writer()
upCmd.Stderr = log.Writer()
if err := upCmd.Run(); err != nil { if err := upCmd.Run(); err != nil {
_ = tsCmd.Process.Kill() _ = tsCmd.Process.Kill()
_ = tsCmd.Wait() _ = tsCmd.Wait()
@@ -104,10 +134,12 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro
// Wait for an IP address. // Wait for an IP address.
for { for {
out, err := exec.CommandContext(ctx, tailscaleBin("tailscale"), statusCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"),
"--socket="+tsSocket, "--socket="+tsSocket,
"status", "--json", "status", "--json",
).Output() )
hideWindow(statusCmd)
out, err := statusCmd.Output()
if err != nil { if err != nil {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -151,12 +183,17 @@ func stopTailscaleLocked() {
return return
} }
if tsSocket != "" { if tsSocket != "" {
_ = exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "down").Run() downCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "down")
hideWindow(downCmd)
_ = downCmd.Run()
} }
_ = tsCmd.Process.Kill() _ = tsCmd.Process.Kill()
_ = tsCmd.Wait() _ = tsCmd.Wait()
tsCmd = nil tsCmd = nil
tsIP = "" tsIP = ""
if tsDataDir != "" {
_ = os.Remove(filepath.Join(tsDataDir, "tailscaled.pid"))
}
log.Printf("Tailscale stopped") log.Printf("Tailscale stopped")
} }
@@ -176,4 +213,95 @@ func getTailscaleIP() string {
return tsIP return tsIP
} }
// isTailscaleReady reports whether tailscaled is running and has successfully
// joined the tailnet (i.e. it has a Tailscale IP). It does not rely on
// isTailscaleRunning because tailscaled may have been started by a previous
// agent run or externally; the important thing is that the socket responds.
func isTailscaleReady() bool {
tsCmdMu.Lock()
socket := tsSocket
tsCmdMu.Unlock()
if socket == "" {
return false
}
statusCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+socket, "status", "--json")
hideWindow(statusCmd)
out, err := statusCmd.Output()
if err != nil {
return false
}
var st tailscaleStatus
if err := json.Unmarshal(out, &st); err != nil {
return false
}
return len(st.Self.TailscaleIPs) > 0
}
// setupTailscaleServe configures Tailscale to proxy inbound Tailnet traffic
// on the given TCP port to localhost:<port>. This is required on Windows
// because userspace networking does not forward incoming connections to
// loopback by default.
func setupTailscaleServe(port int) error {
tsCmdMu.Lock()
defer tsCmdMu.Unlock()
if tsSocket == "" {
return fmt.Errorf("tailscale socket not initialized")
}
portStr := strconv.Itoa(port)
// Clean up any stale config for this port first.
offCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "off")
hideWindow(offCmd)
_ = offCmd.Run()
serveCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "tcp://localhost:"+portStr)
hideWindow(serveCmd)
out, err := serveCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("tailscale serve: %w: %s", err, string(out))
}
log.Printf("Tailscale serve configured for port %s", portStr)
return nil
}
// removeTailscaleServe removes the Tailscale serve proxy for a port when an
// instance is stopped or deleted.
func removeTailscaleServe(port int) {
tsCmdMu.Lock()
defer tsCmdMu.Unlock()
if tsSocket == "" {
return
}
portStr := strconv.Itoa(port)
offCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "off")
hideWindow(offCmd)
_ = offCmd.Run()
log.Printf("Tailscale serve removed for port %s", portStr)
}
// killStaleTailscaled terminates a previously started tailscaled process that
// may have been left running after the agent was force-killed.
func killStaleTailscaled(tsDataDir string) {
pidFile := filepath.Join(tsDataDir, "tailscaled.pid")
data, err := os.ReadFile(pidFile)
if err != nil {
return
}
var pid int
if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil {
return
}
proc, err := os.FindProcess(pid)
if err != nil {
return
}
if err := proc.Signal(syscall.Signal(0)); err == nil {
log.Printf("Killing stale tailscaled process %d", pid)
_ = proc.Kill()
_, _ = proc.Wait()
}
_ = os.Remove(pidFile)
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"os"
"path/filepath"
)
const nodeTokenFileName = "node.token"
func nodeTokenPath(dataDir string) string {
return filepath.Join(dataDir, nodeTokenFileName)
}
// loadNodeToken reads the persisted node authentication token, if any.
func loadNodeToken(dataDir string) (string, error) {
path := nodeTokenPath(dataDir)
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
// saveNodeToken persists the node authentication token with restrictive permissions.
func saveNodeToken(dataDir string, token string) error {
if err := os.MkdirAll(dataDir, 0755); err != nil {
return err
}
path := nodeTokenPath(dataDir)
return os.WriteFile(path, []byte(token), 0600)
}
+360 -8
View File
@@ -8,6 +8,10 @@ import (
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -17,6 +21,25 @@ var uiHTML string
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
// uiConnections holds active WebSocket connections from local UI clients.
var (
uiConnections = make(map[*websocket.Conn]bool)
uiConnectionsMu sync.RWMutex
)
// uiLogWriter intercepts log output and forwards it to connected UI clients.
type uiLogWriter struct{}
func (w uiLogWriter) Write(p []byte) (n int, err error) {
line := strings.TrimSpace(string(p))
if line != "" {
sendUILog(line)
}
return len(p), nil
}
func startUI(dataDir, nodeID, serverAddr string) { func startUI(dataDir, nodeID, serverAddr string) {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
@@ -32,8 +55,22 @@ func startUI(dataDir, nodeID, serverAddr string) {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
// Do not expose the auth key in plain GET unless requested; for local UI it is fine. // Expose a merged view with the agent version for the UI.
json.NewEncoder(w).Encode(cfg) serverVersion := getServerAgentVersion()
updateAvailable := serverVersion != "" && serverVersion != version
response := map[string]interface{}{
"server": cfg.Server,
"headscale_url": cfg.HeadscaleURL,
"headscale_auth_key": cfg.HeadscaleAuthKey,
"node_id": cfg.NodeID,
"data_dir": cfg.DataDir,
"proxy_url": cfg.ProxyURL,
"proxy_mode": cfg.ProxyMode,
"version": version,
"server_version": serverVersion,
"update_available": updateAvailable,
}
json.NewEncoder(w).Encode(response)
case http.MethodPost: case http.MethodPost:
var cfg AgentConfig var cfg AgentConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
@@ -61,6 +98,7 @@ func startUI(dataDir, nodeID, serverAddr string) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
go func() { go func() {
cmd := exec.Command(os.Args[0], os.Args[1:]...) cmd := exec.Command(os.Args[0], os.Args[1:]...)
hideWindow(cmd)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
@@ -72,6 +110,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(cfg, dataDir); 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 {
@@ -79,23 +145,33 @@ func startUI(dataDir, nodeID, serverAddr string) {
return return
} }
defer conn.Close() defer conn.Close()
uiConnectionsMu.Lock()
uiConnections[conn] = true
uiConnectionsMu.Unlock()
log.Printf("UI client connected from %s", r.RemoteAddr) log.Printf("UI client connected from %s", r.RemoteAddr)
// Send current status immediately.
sendUIStatus(conn, dataDir)
// Register notifier to forward activation results from main WS to this UI connection // Register notifier to forward activation results from main WS to this UI connection
notifierID := registerUINotifier(func(msg map[string]interface{}) { notifierID := registerUINotifier(func(msg map[string]interface{}) {
log.Printf("UI notifier forwarding to browser: %+v", msg)
if err := conn.WriteJSON(msg); err != nil { if err := conn.WriteJSON(msg); err != nil {
log.Printf("UI notify error: %v", err) log.Printf("UI notify error: %v", err)
} else {
log.Printf("UI notifier sent successfully")
} }
}) })
defer unregisterUINotifier(notifierID) defer func() {
unregisterUINotifier(notifierID)
uiConnectionsMu.Lock()
delete(uiConnections, conn)
uiConnectionsMu.Unlock()
log.Printf("UI client disconnected")
}()
for { for {
var msg map[string]interface{} var msg map[string]interface{}
if err := conn.ReadJSON(&msg); err != nil { if err := conn.ReadJSON(&msg); err != nil {
log.Printf("UI client disconnected: %v", err) log.Printf("UI client read error: %v", err)
break break
} }
action, _ := msg["action"].(string) action, _ := msg["action"].(string)
@@ -119,6 +195,42 @@ func startUI(dataDir, nodeID, serverAddr string) {
} }
case "instances": case "instances":
listInstances(dataDir, conn) listInstances(dataDir, conn)
case "get_status":
sendUIStatus(conn, dataDir)
case "run_diagnostic":
sendUIStatus(conn, dataDir)
conn.WriteJSON(map[string]interface{}{
"action": "diagnostic_result",
"status": buildUIStatus(dataDir),
"message": "Diagnostic terminé",
})
case "get_logs":
// Logs are streamed as they are produced; no persistent buffer yet.
conn.WriteJSON(map[string]interface{}{
"action": "log",
"message": "Console prête. Les nouveaux logs apparaîtront ici.",
"level": "info",
})
case "start_instance":
instanceID, _ := msg["instanceId"].(string)
if instanceID != "" {
go uiStartInstance(dataDir, nodeID, instanceID)
}
case "stop_instance":
instanceID, _ := msg["instanceId"].(string)
if instanceID != "" {
go uiStopInstance(dataDir, instanceID)
}
case "delete_instance":
instanceID, _ := msg["instanceId"].(string)
if instanceID != "" {
go uiDeleteInstance(dataDir, instanceID)
}
case "reset_instance":
instanceID, _ := msg["instanceId"].(string)
if instanceID != "" {
go uiResetInstance(dataDir, nodeID, instanceID)
}
} }
} }
}) })
@@ -138,7 +250,7 @@ func listInstances(dataDir string, conn *websocket.Conn) {
return return
} }
var list []map[string]interface{} list := []map[string]interface{}{}
for _, inst := range instances { for _, inst := range instances {
status := getInstanceStatus(dataDir, inst.ID) status := getInstanceStatus(dataDir, inst.ID)
if status != inst.Status { if status != inst.Status {
@@ -148,6 +260,7 @@ func listInstances(dataDir string, conn *websocket.Conn) {
list = append(list, map[string]interface{}{ list = append(list, map[string]interface{}{
"id": inst.ID, "id": inst.ID,
"templateName": inst.TemplateName, "templateName": inst.TemplateName,
"type": inst.TemplateName,
"port": inst.Port, "port": inst.Port,
"status": inst.Status, "status": inst.Status,
"url": instanceURL(inst), "url": instanceURL(inst),
@@ -156,3 +269,242 @@ func listInstances(dataDir string, conn *websocket.Conn) {
conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": list}) conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": list})
} }
// sendUILog broadcasts a log line to all connected UI clients.
func sendUILog(message string) {
uiConnectionsMu.RLock()
conns := make([]*websocket.Conn, 0, len(uiConnections))
for conn := range uiConnections {
conns = append(conns, conn)
}
uiConnectionsMu.RUnlock()
msg := map[string]interface{}{
"action": "log",
"message": message,
"level": "info",
}
for _, conn := range conns {
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)
}
}
// sendInstanceProgress broadcasts a progress update for a specific instance.
func sendInstanceProgress(instanceID, step, percent, message string) {
broadcastUI(map[string]interface{}{
"action": "progress",
"instanceId": instanceID,
"step": step,
"percent": percent,
"message": message,
})
}
// broadcastUI sends a message to all connected UI clients.
func broadcastUI(msg map[string]interface{}) {
uiConnectionsMu.RLock()
conns := make([]*websocket.Conn, 0, len(uiConnections))
for conn := range uiConnections {
conns = append(conns, conn)
}
uiConnectionsMu.RUnlock()
for _, conn := range conns {
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)
}
}
// sendUIStatus sends the current services status to a single UI connection.
func sendUIStatus(conn *websocket.Conn, dataDir string) {
if err := conn.WriteJSON(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
}); err != nil {
log.Printf("sendUIStatus error: %v", err)
}
}
// buildUIStatus constructs a user-friendly status snapshot.
func buildUIStatus(dataDir string) map[string]interface{} {
// Connection to the school server.
connectionState := "pending"
connectionDetail := "Connexion en cours..."
mainConnMu.Lock()
connected := mainConn != nil
mainConnMu.Unlock()
if connected {
connectionState = "ok"
connectionDetail = "Connecté au serveur de l'établissement"
} else {
connectionState = "error"
connectionDetail = "Non connecté au serveur de l'établissement"
}
// Application service (Docker/Podman + VPN).
appServiceState := "pending"
appServiceDetail := "Vérification du service d'applications..."
engine := getContainerEngine()
if engineAvailable(engine) {
if isTailscaleReady() {
appServiceState = "ok"
appServiceDetail = "Service d'applications prêt"
} else if isTailscaleRunning() {
appServiceState = "warn"
appServiceDetail = "Service d'applications disponible, connexion sécurisée en cours"
} else {
appServiceState = "warn"
appServiceDetail = "Service d'applications disponible, connexion sécurisée inactive"
}
} else {
appServiceState = "error"
appServiceDetail = "Service d'applications non disponible"
}
// Applications ready.
applicationsState := "pending"
applicationsDetail := "Vérification des applications..."
if instances, err := loadInstances(dataDir); err == nil {
ready := 0
total := len(instances)
for _, inst := range instances {
if getInstanceStatus(dataDir, inst.ID) == "running" {
ready++
}
}
if total == 0 {
applicationsState = "ok"
applicationsDetail = "Aucune application assignée"
} else if ready == total {
applicationsState = "ok"
applicationsDetail = fmt.Sprintf("%d application%s prête%s", ready, plural(ready), plural(ready))
} else if ready > 0 {
applicationsState = "warn"
applicationsDetail = fmt.Sprintf("%d / %d application%s prête%s", ready, total, plural(ready), plural(ready))
} else {
applicationsState = "pending"
applicationsDetail = fmt.Sprintf("%d application%s en cours de démarrage", total, plural(total))
}
}
return map[string]interface{}{
"connection": connectionState,
"connectionDetail": connectionDetail,
"appService": appServiceState,
"appServiceDetail": appServiceDetail,
"applications": applicationsState,
"applicationsDetail": applicationsDetail,
}
}
func engineAvailable(engine string) bool {
_, err := exec.LookPath(engine)
return err == nil
}
func plural(n int) string {
if n > 1 {
return "s"
}
return ""
}
// uiStartInstance starts a stopped instance without recreating its containers,
// so volumes and data are preserved.
func uiStartInstance(dataDir, nodeID, instanceID string) {
inst, err := loadInstances(dataDir)
if err != nil || inst[instanceID] == nil {
log.Printf("uiStartInstance: instance %s not found", instanceID)
return
}
info := inst[instanceID]
if instanceContainersExist(dataDir, instanceID) {
if err := dockerComposeStart(dataDir, instanceID); err != nil {
log.Printf("uiStartInstance: start failed for %s: %v", instanceID, err)
return
}
} else {
if err := dockerComposeUp(dataDir, instanceID); err != nil {
log.Printf("uiStartInstance: up failed for %s: %v", instanceID, err)
return
}
}
time.Sleep(2 * time.Second)
if err := setupTailscaleServe(info.Port); err != nil {
log.Printf("uiStartInstance: setupTailscaleServe failed for %s: %v", instanceID, err)
}
status := getInstanceStatus(dataDir, instanceID)
info.Status = status
_ = upsertInstance(dataDir, info)
_ = sendMessage(WSMessage{Action: "instance_started", InstanceID: instanceID, Port: info.Port})
notifyUI(map[string]interface{}{"action": "instances_updated"})
}
// uiStopInstance stops a running instance without removing its containers or volumes.
func uiStopInstance(dataDir, instanceID string) {
_ = dockerComposeStop(dataDir, instanceID)
if inst, err := loadInstances(dataDir); err == nil && inst[instanceID] != nil {
inst[instanceID].Status = "stopped"
_ = saveInstances(dataDir, inst)
}
go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: instanceID})
notifyUI(map[string]interface{}{"action": "instances_updated"})
}
// uiDeleteInstance removes an instance and its data (volumes included).
func uiDeleteInstance(dataDir, instanceID string) {
if inst, err := loadInstances(dataDir); err == nil && inst[instanceID] != nil {
removeTailscaleServe(inst[instanceID].Port)
}
dockerComposeRm(dataDir, instanceID)
removeInstance(dataDir, instanceID)
go sendMessage(WSMessage{Action: "instance_deleted", InstanceID: instanceID})
notifyUI(map[string]interface{}{"action": "instances_updated"})
}
// uiResetInstance stops, removes volumes and recreates an instance from scratch.
func uiResetInstance(dataDir, nodeID, instanceID string) {
inst, err := loadInstances(dataDir)
if err != nil || inst[instanceID] == nil {
log.Printf("uiResetInstance: instance %s not found", instanceID)
return
}
info := inst[instanceID]
composePath := filepath.Join(instanceDir(dataDir, instanceID), "docker-compose.yml")
composeBytes, err := os.ReadFile(composePath)
if err != nil {
log.Printf("uiResetInstance: cannot read compose for %s: %v", instanceID, err)
return
}
dockerComposeRm(dataDir, instanceID)
handleStartInstance(dataDir, nodeID, instanceID, info.TemplateName, string(composeBytes), "", info.Port)
}
// instanceContainersExist returns true if compose containers already exist for this instance.
func instanceContainersExist(dataDir, instanceID string) bool {
dir := instanceDir(dataDir, instanceID)
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "ps", "-q")
configureEngineCmd(cmd, dir)
out, err := cmd.Output()
return err == nil && strings.TrimSpace(string(out)) != ""
}
+990 -141
View File
File diff suppressed because it is too large Load Diff
+268
View File
@@ -0,0 +1,268 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"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"`
}
// httpBaseURL converts a WebSocket server URL into the corresponding HTTP(S)
// base URL, stripping the /api/websocket path if present.
func httpBaseURL(serverURL string) string {
u := serverURL
switch {
case strings.HasPrefix(u, "wss://"):
u = "https://" + strings.TrimPrefix(u, "wss://")
case strings.HasPrefix(u, "ws://"):
u = "http://" + strings.TrimPrefix(u, "ws://")
}
u = strings.TrimSuffix(u, "/api/websocket/")
u = strings.TrimSuffix(u, "/api/websocket")
return strings.TrimSuffix(u, "/")
}
// checkForUpdate fetches the latest agent version from the server and compares
// it with the running binary's version.
func checkForUpdate(cfg *AgentConfig) (*AgentVersionInfo, bool, error) {
if cfg == nil || cfg.Server == "" {
return nil, false, fmt.Errorf("no server URL configured")
}
url := httpBaseURL(cfg.Server) + "/api/agent/version"
client := httpClientWithProxy(cfg)
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(cfg *AgentConfig, 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)
client := httpClientWithProxy(cfg)
resp, err := client.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
}
// formatArgsForShell returns the given arguments as a safely quoted string
// suitable for embedding in shell/PowerShell scripts.
func formatArgsForShell(args []string) string {
if len(args) == 0 {
return ""
}
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = strconv.Quote(a)
}
return strings.Join(quoted, " ")
}
// applyUpdate replaces the running binary with the downloaded one using an
// external helper script, then exits the current process. The new process is
// started with the same arguments as the current one so that tray/console mode
// is preserved.
func applyUpdate(currentPath, newPath, dataDir string) error {
pid := os.Getpid()
restartArgs := os.Args[1:]
switch runtime.GOOS {
case "windows":
return applyUpdateWindows(currentPath, newPath, dataDir, pid, restartArgs)
default:
return applyUpdateUnix(currentPath, newPath, dataDir, pid, restartArgs)
}
}
func applyUpdateWindows(currentPath, newPath, dataDir string, pid int, restartArgs []string) error {
scriptPath := filepath.Join(dataDir, "update", "apply-update.ps1")
argsList := formatArgsForShell(restartArgs)
if argsList == "" {
argsList = ""
} else {
argsList = "$startArgs = @(" + argsList + ")"
}
script := fmt.Sprintf(`$old = "%s"
$new = "%s"
$targetPid = %d
%s
Wait-Process -Id $targetPid -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
Move-Item -Path $new -Destination $old -Force
if ($startArgs) {
Start-Process -FilePath $old -ArgumentList $startArgs -WindowStyle Hidden
} else {
Start-Process -FilePath $old -WindowStyle Hidden
}
`, currentPath, newPath, pid, argsList)
if err := os.WriteFile(scriptPath, []byte(script), 0644); err != nil {
return err
}
cmd := exec.Command("powershell.exe", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File", scriptPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
hideWindow(cmd)
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, restartArgs []string) error {
scriptPath := filepath.Join(dataDir, "update", "apply-update.sh")
argsList := formatArgsForShell(restartArgs)
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" %s >/dev/null 2>&1 &
`, currentPath, newPath, pid, argsList)
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(cfg *AgentConfig, dataDir string) error {
info, available, err := checkForUpdate(cfg)
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(cfg, 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(cfg *AgentConfig, dataDir string) {
for {
info, available, err := checkForUpdate(cfg)
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)
}
}
+21
View File
@@ -0,0 +1,21 @@
package main
import "testing"
func TestHTTPBaseURL(t *testing.T) {
cases := []struct {
in, want string
}{
{"wss://studioe5.edudeploy.com/api/websocket", "https://studioe5.edudeploy.com"},
{"ws://localhost:3000/api/websocket", "http://localhost:3000"},
{"https://studioe5.edudeploy.com/api/websocket", "https://studioe5.edudeploy.com"},
{"https://studioe5.edudeploy.com", "https://studioe5.edudeploy.com"},
{"wss://example.com/api/websocket/", "https://example.com"},
}
for _, c := range cases {
got := httpBaseURL(c.in)
if got != c.want {
t.Errorf("httpBaseURL(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+403 -97
View File
@@ -3,26 +3,43 @@ package main
import ( import (
"fmt" "fmt"
"log" "log"
"net/http"
"net/url"
"sync" "sync"
"time" "time"
"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"`
StudentId string `json:"studentId,omitempty"` InitScript string `json:"initScript,omitempty"`
StudentName string `json:"studentName,omitempty"` StudentId string `json:"studentId,omitempty"`
Error string `json:"error,omitempty"` StudentName string `json:"studentName,omitempty"`
TailscaleIP string `json:"tailscaleIp,omitempty"` Error string `json:"error,omitempty"`
HeadscaleURL string `json:"headscaleUrl,omitempty"` TailscaleIP string `json:"tailscaleIp,omitempty"`
HeadscaleAuthKey string `json:"headscaleAuthKey,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 ( var (
@@ -51,6 +68,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()
@@ -60,9 +96,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{})
@@ -99,22 +159,134 @@ func notifyUI(msg map[string]interface{}) {
log.Printf("notifyUI: broadcasting to %d UI clients", len(notifiers)) log.Printf("notifyUI: broadcasting to %d UI clients", len(notifiers))
for _, fn := range 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)
} }
} }
func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey string) { // directDialer returns a websocket.Dialer that never uses a proxy.
setHeadscaleConfig(headscaleURL, headscaleAuthKey) func directDialer() *websocket.Dialer {
d := websocket.DefaultDialer
return &websocket.Dialer{
Proxy: nil,
HandshakeTimeout: d.HandshakeTimeout,
ReadBufferSize: d.ReadBufferSize,
WriteBufferSize: d.WriteBufferSize,
EnableCompression: d.EnableCompression,
}
}
// proxyOnlyDialer returns a websocket.Dialer that always uses the configured
// proxy URL, ignoring the current auto-proxy state.
func proxyOnlyDialer(cfg *AgentConfig) *websocket.Dialer {
d := websocket.DefaultDialer
u := proxyURL(cfg)
if u == nil {
return d
}
return &websocket.Dialer{
Proxy: func(*http.Request) (*url.URL, error) { return u, nil },
HandshakeTimeout: d.HandshakeTimeout,
ReadBufferSize: d.ReadBufferSize,
WriteBufferSize: d.WriteBufferSize,
EnableCompression: d.EnableCompression,
}
}
// dialServerWithFallback attempts to connect to the WebSocket server according
// to the configured proxy mode. In auto mode it tries direct connections first
// and falls back to the proxy after a few failures.
func dialServerWithFallback(cfg *AgentConfig, serverAddr string, headers http.Header) (*websocket.Conn, error) {
mode := proxyMode(cfg)
switch mode {
case ProxyModeDisabled:
conn, _, err := directDialer().Dial(serverAddr, headers)
return conn, err
case ProxyModeEnabled:
conn, _, err := websocketDialer(cfg).Dial(serverAddr, headers)
return conn, err
}
// Auto mode.
u := proxyURL(cfg)
if u == nil {
conn, _, err := directDialer().Dial(serverAddr, headers)
return conn, err
}
// If we are currently in auto-proxy mode, try direct again only after the
// lock duration has expired. Otherwise stay on the proxy.
if IsProxyActive() {
if canRetryDirect() {
log.Println("Auto proxy: retrying direct connection after lock period")
conn, _, err := directDialer().Dial(serverAddr, headers)
if err == nil {
if setProxyActive(false) {
log.Println("Auto proxy: switched back to direct connection")
}
return conn, nil
}
log.Printf("Auto proxy: direct retry failed (%v), keeping proxy", err)
}
conn, _, err := proxyOnlyDialer(cfg).Dial(serverAddr, headers)
if err != nil {
// Proxy failed too: clear the active flag so next round restarts the
// direct-first fallback sequence.
setProxyActive(false)
}
return conn, err
}
// Not currently in proxy mode: try direct up to 3 times, then proxy.
for i := 0; i < 3; i++ {
conn, _, err := directDialer().Dial(serverAddr, headers)
if err == nil {
return conn, nil
}
log.Printf("Auto proxy: direct attempt %d/%d failed: %v", i+1, 3, err)
if i < 2 {
time.Sleep(3 * time.Second)
}
}
log.Println("Auto proxy: falling back to proxy")
conn, _, err := proxyOnlyDialer(cfg).Dial(serverAddr, headers)
if err == nil {
if setProxyActive(true) {
log.Println("Auto proxy: switched to proxy")
}
} else {
log.Printf("Auto proxy: proxy fallback failed: %v", err)
}
return conn, err
}
func startWebSocket(cfg *AgentConfig, nodeID, dataDir string) {
setHeadscaleConfig(cfg.HeadscaleURL, cfg.HeadscaleAuthKey)
serverAddr := cfg.Server
for { for {
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil) token, _ := loadNodeToken(dataDir)
headers := http.Header{}
if token != "" {
headers.Set("Authorization", "Bearer "+token)
}
conn, err := dialServerWithFallback(cfg, serverAddr, headers)
if err != nil { if err != nil {
log.Printf("WS connect error: %v, retrying in 5s...", err) log.Printf("WS connect error: %v, retrying in 5s...", err)
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
continue continue
} }
log.Printf("WS connected to %s", serverAddr) log.Printf("WS connected to %s (token=%v, proxy=%v)", serverAddr, token != "", IsProxyActive())
mainConnMu.Lock() mainConnMu.Lock()
mainConn = conn mainConn = conn
@@ -136,9 +308,11 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
log.Println("Waiting for activation...") log.Println("Waiting for activation...")
} else { } else {
log.Printf("Already activated as %s", act.StudentName) log.Printf("Already activated as %s", act.StudentName)
// If already activated and we have credentials, ensure VPN is up. // If already activated, ensure VPN is up. The pre-auth key is
// one-time only, so on restart we rely on the persisted tailscaled
// state; tailscale up without an authkey reuses existing state.
hsURL, hsKey := getHeadscaleConfig() hsURL, hsKey := getHeadscaleConfig()
if hsURL != "" && hsKey != "" { if hsURL != "" {
go startTailscaleAndReport(dataDir, nodeID, hsURL, hsKey) go startTailscaleAndReport(dataDir, nodeID, hsURL, hsKey)
} }
} }
@@ -182,9 +356,30 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
} }
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) { 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 { switch msg.Action {
case "set_token":
if msg.Token != "" {
if err := saveNodeToken(dataDir, msg.Token); err != nil {
log.Printf("saveNodeToken error: %v", err)
} else {
log.Printf("Node token saved")
}
}
case "activated": case "activated":
log.Printf("handleMessage: activated received, student=%s", msg.StudentName) log.Printf("handleMessage: activated received, student=%s", msg.StudentName)
if msg.Token != "" {
if err := saveNodeToken(dataDir, msg.Token); err != nil {
log.Printf("saveNodeToken error: %v", err)
} else {
log.Printf("Node token saved on activation")
}
}
if msg.StudentName != "" { if msg.StudentName != "" {
act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code} act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code}
if err := saveActivation(dataDir, act); err != nil { if err := saveActivation(dataDir, act); err != nil {
@@ -194,7 +389,9 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
} }
} }
// The server also sends Headscale credentials on activation. // The server sends Headscale credentials on activation.
// The pre-auth key is ephemeral and must be used immediately;
// it is intentionally NOT persisted to the config file.
if msg.HeadscaleURL != "" && msg.HeadscaleAuthKey != "" { if msg.HeadscaleURL != "" && msg.HeadscaleAuthKey != "" {
setHeadscaleConfig(msg.HeadscaleURL, msg.HeadscaleAuthKey) setHeadscaleConfig(msg.HeadscaleURL, msg.HeadscaleAuthKey)
cfg, _, err := loadOrCreateConfig(dataDir) cfg, _, err := loadOrCreateConfig(dataDir)
@@ -202,11 +399,11 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
log.Printf("loadOrCreateConfig error: %v", err) log.Printf("loadOrCreateConfig error: %v", err)
} else { } else {
cfg.HeadscaleURL = msg.HeadscaleURL cfg.HeadscaleURL = msg.HeadscaleURL
cfg.HeadscaleAuthKey = msg.HeadscaleAuthKey // Intentionally do not save HeadscaleAuthKey: it is one-time only.
if err := saveConfig(dataDir, cfg); err != nil { if err := saveConfig(dataDir, cfg); err != nil {
log.Printf("saveConfig error: %v", err) log.Printf("saveConfig error: %v", err)
} else { } else {
log.Printf("Saved Headscale config received from server") log.Printf("Saved Headscale URL received from server (auth key not persisted)")
} }
} }
go startTailscaleAndReport(dataDir, nodeID, msg.HeadscaleURL, msg.HeadscaleAuthKey) go startTailscaleAndReport(dataDir, nodeID, msg.HeadscaleURL, msg.HeadscaleAuthKey)
@@ -217,21 +414,38 @@ 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")
hsURL, hsKey := getHeadscaleConfig() hsURL, hsKey := getHeadscaleConfig()
if hsURL == "" || hsKey == "" { if hsURL == "" || hsKey == "" {
log.Printf("Cannot start VPN: headscale config missing") 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 return
} }
go func() { 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) ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
if err != nil { if err != nil {
log.Printf("start_vpn error: %v", err) log.Printf("start_vpn error: %v", err)
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()}) sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()})
broadcastUI(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
})
return return
} }
for { for {
@@ -243,11 +457,22 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
log.Printf("Sent tailscale_ip to server: %s", ip) log.Printf("Sent tailscale_ip to server: %s", ip)
break break
} }
broadcastUI(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
})
}() }()
case "stop_vpn": case "stop_vpn":
log.Printf("Server requested VPN stop") log.Printf("Server requested VPN stop")
stopTailscale() 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": case "activation_failed":
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error) log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
notifyUI(map[string]interface{}{ notifyUI(map[string]interface{}{
@@ -256,93 +481,166 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
}) })
case "start": case "start":
log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port) log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port)
if err := upsertInstance(dataDir, &InstanceInfo{
ID: msg.InstanceID,
TemplateName: msg.Type,
Port: msg.Port,
Status: "starting",
}); err != nil {
log.Printf("upsertInstance error: %v", err)
}
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); err != nil {
log.Printf("writeCompose error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return
}
if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil {
log.Printf("dockerComposeUp error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return
}
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
// so the studioE5 mu-plugin can compute the public URL from the Host header.
go func() { go func() {
// Give the container a moment to be ready before touching wp-config.php defer func() {
time.Sleep(2 * time.Second) if r := recover(); r != nil {
if err := stripWordPressHardcodedURLs(dataDir, msg.InstanceID); err != nil { log.Printf("PANIC in start goroutine instance=%s: %v", msg.InstanceID, r)
log.Printf("stripWordPressHardcodedURLs error: %v", err) }
} }()
handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.InitScript, msg.Port)
}() }()
// Ensure Tailscale is running so the server can reach the node
go ensureTailscale(dataDir, nodeID, msg.Port)
status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
notifyUI(map[string]interface{}{"action": "instances_updated"})
case "stop": case "stop":
log.Printf("Stop instance %s", msg.InstanceID) log.Printf("Stop instance %s", msg.InstanceID)
if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil { go func() {
log.Printf("dockerComposeDown error: %v", err) defer func() {
} if r := recover(); r != nil {
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil { log.Printf("PANIC in stop goroutine instance=%s: %v", msg.InstanceID, r)
inst[msg.InstanceID].Status = "stopped" }
_ = saveInstances(dataDir, inst) }()
} if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
notifyUI(map[string]interface{}{"action": "instances_updated"}) 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": case "delete":
log.Printf("Delete instance %s", msg.InstanceID) log.Printf("Delete instance %s", msg.InstanceID)
dockerComposeRm(dataDir, msg.InstanceID) go func() {
removeInstance(dataDir, msg.InstanceID) defer func() {
notifyUI(map[string]interface{}{"action": "instances_updated"}) 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": case "reset":
log.Printf("Reset instance %s", msg.InstanceID) log.Printf("Reset instance %s", msg.InstanceID)
dockerComposeRm(dataDir, msg.InstanceID) dockerComposeRm(dataDir, msg.InstanceID)
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); err != nil {
log.Printf("writeCompose error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return
}
if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil {
log.Printf("dockerComposeUp error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return
}
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
// so the studioE5 mu-plugin can compute the public URL from the Host header.
go func() { go func() {
// Give the container a moment to be ready before touching wp-config.php defer func() {
time.Sleep(2 * time.Second) if r := recover(); r != nil {
if err := stripWordPressHardcodedURLs(dataDir, msg.InstanceID); err != nil { log.Printf("PANIC in reset goroutine instance=%s: %v", msg.InstanceID, r)
log.Printf("stripWordPressHardcodedURLs error: %v", err) }
} }()
handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.InitScript, msg.Port)
}() }()
// Ensure Tailscale is running so the server can reach the node
go ensureTailscale(dataDir, nodeID, msg.Port)
status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
notifyUI(map[string]interface{}{"action": "instances_updated"})
default: default:
log.Printf("Unknown action: %s", msg.Action) log.Printf("Unknown action: %s", msg.Action)
} }
} }
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)
}
_ = upsertInstance(dataDir, &InstanceInfo{
ID: instanceID,
TemplateName: instanceType,
Port: port,
Status: "starting",
})
notifyUI(map[string]interface{}{"action": "instances_updated"})
notifyInstanceProgress("10", "Préparation de l'application...")
if err := writeCompose(dataDir, instanceID, composeConfig, port); err != nil {
log.Printf("writeCompose error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: instanceID, Error: err.Error()})
notifyInstanceProgress("0", "Erreur de préparation")
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 {
log.Printf("dockerComposeUp error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: instanceID, Error: err.Error()})
notifyInstanceProgress("0", "Erreur de démarrage")
notifyUI(map[string]interface{}{"action": "instances_updated"})
return
}
notifyInstanceProgress("60", "Application en cours de démarrage...")
ensureTailscale(dataDir, nodeID, port)
if err := setupTailscaleServe(port); err != nil {
log.Printf("setupTailscaleServe error: %v", err)
// Non-fatal: the instance may still work on Linux or if Windows
// userspace forwarding happens to function.
}
notifyInstanceProgress("80", "Connexion sécurisée active...")
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
// so the studioE5 mu-plugin can compute the public URL from the Host header.
time.Sleep(2 * time.Second)
if err := stripWordPressHardcodedURLs(dataDir, instanceID); err != nil {
log.Printf("stripWordPressHardcodedURLs error: %v", err)
}
notifyInstanceProgress("90", "Finalisation de l'installation...")
status := getInstanceStatus(dataDir, instanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: status})
sendMessage(WSMessage{Action: "instance_started", InstanceID: instanceID, Port: port})
notifyInstanceProgress("100", "Application prête")
notifyUI(map[string]interface{}{"action": "instances_updated"})
}
func ensureTailscale(dataDir, nodeID string, port int) { func ensureTailscale(dataDir, nodeID string, port int) {
hsURL, hsKey := getHeadscaleConfig() hsURL, hsKey := getHeadscaleConfig()
if hsURL == "" || hsKey == "" { if hsURL == "" || hsKey == "" {
@@ -356,6 +654,10 @@ func ensureTailscale(dataDir, nodeID string, port int) {
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey) ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
if err != nil { if err != nil {
log.Printf("ensureTailscale start error: %v", err) log.Printf("ensureTailscale start error: %v", err)
broadcastUI(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
})
return return
} }
for { for {
@@ -367,4 +669,8 @@ func ensureTailscale(dataDir, nodeID string, port int) {
log.Printf("Sent tailscale_ip to server: %s", ip) log.Printf("Sent tailscale_ip to server: %s", ip)
break break
} }
broadcastUI(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
})
} }
+4 -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}
@@ -34,6 +35,8 @@ services:
MAIN_DOMAIN: ${MAIN_DOMAIN} MAIN_DOMAIN: ${MAIN_DOMAIN}
HEADSCALE_URL: ${HEADSCALE_URL} HEADSCALE_URL: ${HEADSCALE_URL}
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY} HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
HEADSCALE_API_KEY: ${HEADSCALE_API_KEY}
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -66,7 +69,7 @@ services:
devices: devices:
- /dev/net/tun:/dev/net/tun - /dev/net/tun:/dev/net/tun
environment: environment:
TS_AUTHKEY: ${HEADSCALE_AUTH_KEY} TS_AUTHKEY: ${HEADSCALE_RESOLVER_AUTH_KEY}
TS_LOGIN_SERVER: ${HEADSCALE_URL} TS_LOGIN_SERVER: ${HEADSCALE_URL}
TS_EXTRA_ARGS: --login-server=${HEADSCALE_URL} TS_EXTRA_ARGS: --login-server=${HEADSCALE_URL}
TS_STATE_DIR: /var/lib/tailscale TS_STATE_DIR: /var/lib/tailscale
+549
View File
@@ -0,0 +1,549 @@
# Deployeur studioE5 — Onboarding dun nouvel établissement
## Objectif
Ce document décrit le fonctionnement du **deployeur studioE5**, cest-à-dire lapplication / loutil qui provisionne et configure un nouvel environnement client (un établissement) prêt à accueillir lapplication studioE5.
Lapplication studioE5 elle-même (agent, VPN on-demand, resolver, Caddy, Headscale, etc.) est documentée dans `SUIVI_VPN_ONDEMAND.md`. Le deployeur est loutil qui **déploie** cette application sur un VPS dédié au client.
---
## Public cible
- Équipe produit / développement du deployeur
- Équipe ops / déploiement
- Référents techniques du client A
---
## Glossaire
| Terme | Définition |
|-------|------------|
| **Deployeur** | Application qui provisionne un VPS, déploie la stack studioE5 et configure le DNS/certificats pour un nouvel établissement. |
| **Hub central** | Dashboard superadmin studioE5 qui orchestre les déploiements et la gestion multi-clients. |
| **Établissement** | Entité client (école, lycée, université, entreprise). |
| **Tag établissement** | Slug unique et court identifiant l’établissement dans les URLs et le DNS. |
| **Domaine géré** | Sous-domaine fourni par studioE5 : `*.tag.edudeploy.com`. |
| **Domaine propre** | Domaine détenu par l’établissement : `*.tag.monetablissement.fr`. |
| **Application studioE5** | Stack complète déployée sur le VPS : `server`, `resolver`, `resolver-vpn`, `caddy`, `headscale`, `postgres`, etc. |
| **Agent générique** | Binaire agent unique, capable de se connecter à nimporte quel serveur studioE5 via résolution dURL à lactivation. |
---
## Architecture : deployeur vs application studioE5
```
┌─────────────────────────────────────────────────────────────┐
│ Hub central studioE5 │
│ (superadmin, gestion des établissements, monitoring) │
└───────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Deployeur studioE5 │
│ (provisionning VPS, DNS, certificats, déploiement stack) │
└───────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Application studioE5 (un par client) │
│ Caddy — Resolver — Resolver-VPN — Headscale — Server — DB │
│ ▲ │
│ │ WebSocket / VPN on-demand │
│ ▼ │
│ Agent élève (Windows/Linux) │
└─────────────────────────────────────────────────────────────┘
```
---
## Flux donboarding par le deployeur (vue densemble)
```
Création de l’établissement dans le hub
Choix du domaine (géré ou propre)
Génération du tag établissement
Provisionning du VPS
Configuration DNS wildcard
Génération du certificat wildcard
Déploiement de la stack studioE5 (Docker Compose)
Initialisation de Headscale et création des clés
Création du compte administrateur de l’établissement
Génération des codes dactivation
Build et mise à disposition de lagent dédié
Activation de lagent par un élève
Création dune première instance (validation du déploiement)
```
---
## 1. Création de l’établissement dans le hub
Le superadmin crée un nouvel établissement dans le hub central.
Données minimales :
- Nom officiel
- Type d’établissement (école, lycée, université, entreprise)
- Pays / fuseau horaire
- Contact administrateur
- Choix du mode de domaine (`managed` ou `custom`)
---
## 2. Choix du domaine
### Option A — Domaine géré par studioE5 (MVP)
Le deployeur crée automatiquement un sous-domaine du domaine maître :
```
*.tag.edudeploy.com
```
Le hub gère le DNS chez le registrar studioE5 (actuellement Infomaniak).
### Option B — Domaine propre de l’établissement (évolution)
L’établissement fournit son propre domaine :
```
*.tag.monetablissement.fr
```
Prérequis :
- Le client pointe son DNS wildcard vers lIP du VPS provisionné.
- Le deployeur dispose dun token API du registrar du client pour le challenge DNS-01.
---
## 3. Génération du tag établissement
Le tag est un slug court, unique au niveau du hub, utilisé dans les URLs et le DNS.
### Règles
- Uniquement `[a-z0-9-]`
- Pas de tiret au début ni à la fin
- Longueur conseillée : 2 à 20 caractères
- Vérification dunicité en base
### Exemples
| Nom d’établissement | Tag |
|---------------------|-----|
| Lycée Jules Ferry | `ljf` |
| Institut Supérieur du Digital | `isd` |
| École Notre-Dame | `end` |
### Gestion des collisions
- `ljf``ljf-2`, `ljf-3`, etc.
---
## 4. Provisionning du VPS
Le deployeur provisionne un VPS dédié pour l’établissement.
### Prérequis sur le VPS vierge
- OS Linux (Ubuntu LTS recommandé)
- Docker + Docker Compose installés
- Accès SSH avec clé
- Ports ouverts : 22, 80, 443
### Actions automatisées par le deployeur
1. Installation de Docker et Docker Compose si absent.
2. Création de la structure de dossiers (`/opt/studioe5-<tag>/`).
3. Génération des secrets (`.env`) :
- `INTERNAL_API_KEY`
- `HEADSCALE_API_KEY`
- `HEADSCALE_AUTH_KEY` (réutilisable, taguée `tag:student-agent`)
- `HEADSCALE_RESOLVER_AUTH_KEY`
- `INFOMANIAK_API_TOKEN` (si domaine géré)
- `NEXTAUTH_SECRET`, `DATABASE_URL`, etc.
4. Récupération des images Docker depuis le registry privé (ou build sur place en attendant).
5. Génération des fichiers de configuration (`Caddyfile`, `docker-compose.yml`, `headscale/config.yaml`, `headscale/acl_policy.hujson`).
---
## 5. Configuration DNS wildcard
### Domaine géré
Le deployeur appelle lAPI du registrar pour créer :
```dns
*.tag.edudeploy.com A <IP_DU_VPS>
```
### Domaine propre
Le deployeur vérifie que lenregistrement existe :
```dns
*.tag.monetablissement.fr A <IP_DU_VPS>
```
---
## 6. Certificat wildcard
### Principe
Un seul certificat wildcard couvre toutes les instances futures de l’établissement.
### Mise en œuvre avec Caddy
Le deployeur génère le `Caddyfile` :
```caddy
*.tag.edudeploy.com {
tls {
dns infomaniak {env.INFOMANIAK_API_TOKEN}
}
reverse_proxy resolver:2020 {
header_up Host {host}
}
}
```
Pour un domaine propre, le provider DNS est celui du client.
### Renouvellement
Géré automatiquement par Caddy.
---
## 7. Déploiement de la stack studioE5
Le deployeur lance la stack Docker Compose complète :
```bash
cd /opt/studioe5-<tag>
docker compose up -d
```
Services déployés :
- `server` : API + WebSocket + UI Next.js
- `resolver` : reverse proxy interne vers les instances
- `resolver-vpn` : sidecar Tailscale dans le netns du resolver
- `caddy` : reverse proxy public + TLS
- `headscale` : contrôleur Tailscale
- `postgres` : base de données
---
## 8. Initialisation de Headscale
Le deployeur initialise Headscale et crée les clés nécessaires :
```bash
# Création de lutilisateur dédié au resolver
docker compose exec headscale headscale users create resolver
# Création de la clé pré-auth réutilisable pour les agents
docker compose exec headscale headscale preauthkeys create \
--user studioe5 \
--reusable \
--tags tag:student-agent \
-e 87600h
# Création de la clé pré-auth pour le resolver
docker compose exec headscale headscale preauthkeys create \
--user resolver \
--tags tag:resolver \
-e 87600h
# Création dune clé API Headscale valable 10 ans
docker compose exec headscale headscale apikeys create -e 87600h
```
Ces secrets sont stockés dans le `.env` du serveur.
---
## 9. Création du compte administrateur de l’établissement
Une fois la stack déployée, le deployeur (ou le hub) crée le premier compte administrateur de l’établissement via lAPI du serveur nouvellement déployé.
Rôles :
- `admin` : gestion des élèves, instances, agents.
- `teacher` : gestion limitée à certaines classes/groupes.
- `superadmin` (studioE5) : accès transverse.
Ladministrateur reçoit un lien dactivation sécurisé.
---
## 10. Génération des codes dactivation
Le deployeur configure le serveur pour permettre la génération de codes dactivation.
### Règles de sécurité (implémentées côté application studioE5)
- Génération avec `crypto.randomBytes`
- Alphabet sans ambiguïté : `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`
- 6 caractères
- Expiration après 60 minutes
- Invalidation après usage
- Rate-limiting : 5 tentatives par code / 5 tentatives par `nodeId` sur 15 minutes
### Flux
1. Ladministrateur génère un code pour un élève.
2. L’élève saisit le code dans lagent.
3. Le serveur valide et renvoie :
- lidentité de l’élève
- lURL Headscale
- une clé pré-auth Headscale éphémère
4. Lagent démarre automatiquement le VPN.
---
## 11. Build et mise à disposition de lagent
### Principe
Lagent est un binaire générique, mais il doit être capable de se connecter au bon serveur. Le deployeur génère un agent pré-configuré ou un installeur qui embarque lURL du serveur de l’établissement.
### Build
```bash
cd /opt/studioe5-<tag>/agent
./download-tailscale-bins.sh 1.98.4
./build.sh
```
Artifacts générés :
- `studioE5-agent-vX.Y.Z-windows.zip`
- `studioE5-agent-vX.Y.Z.exe`
- `studioE5-agent-vX.Y.Z` (Linux)
### Mise à disposition
Les fichiers sont servis par Caddy depuis `server/public/agent/` :
```
https://tag.edudeploy.com/studioE5-agent-vX.Y.Z-windows.zip
```
---
## 12. Activation de lagent
### Activation zéro-config
1. L’élève télécharge lagent depuis lURL de l’établissement.
2. Il extrait larchive et lance `studioE5-agent.exe`.
3. Il ouvre `http://localhost:7070`.
4. Il saisit le code dactivation à 6 caractères.
5. Lagent contacte le serveur, récupère la configuration et démarre le VPN.
> Les détails techniques du VPN on-demand (named pipes Windows, logs, ACL, tokens, clés éphémères) sont documentés dans `SUIVI_VPN_ONDEMAND.md`.
---
## 13. Création dune instance et construction de lURL (validation)
Le deployeur ou ladministrateur crée une première instance pour valider le déploiement.
### Format dURL
```
<appli>-<initiales><id-court>.<tag>.<domaine>
```
Exemple :
```
wp-jd47.ljf.edudeploy.com
```
Avec :
- `wp` : type dapplication
- `jd` : initiales de l’élève
- `47` : identifiant court unique
- `ljf` : tag de l’établissement
- `edudeploy.com` : domaine de base
### Mapping type dapplication → préfixe
| Application | Préfixe |
|-------------|---------|
| WordPress | `wp` |
| PrestaShop | `ps` |
| Moodle | `mdl` |
| Nextcloud | `nc` |
### Protection de lidentité
- LURL ne contient pas le nom complet de l’élève.
- Seules les initiales + un identifiant court opaque sont exposées.
---
## 14. Modèles de données du deployeur
### Table / modèle `Organization` (établissement dans le hub)
```json
{
"id": "uuid",
"name": "Lycée Jules Ferry",
"tag": "ljf",
"domainMode": "managed",
"baseDomain": "edudeploy.com",
"adminEmail": "admin@ljf.fr",
"status": "active",
"createdAt": "2026-06-25T17:28:07Z"
}
```
### Table / modèle `Deployment` (déploiement sur un VPS)
```json
{
"id": "uuid",
"organizationId": "uuid",
"serverIp": "203.0.113.10",
"serverHostname": "ljf.studioe5.edudeploy.com",
"wildcardDnsConfigured": true,
"wildcardCertificateReady": true,
"dnsProvider": "infomaniak",
"dnsProviderTokenRef": "env:INFOMANIAK_TOKEN_LJF",
"headscaleApiKeyRef": "env:HEADSCALE_API_KEY_LJF",
"status": "ready",
"deployedAt": "2026-06-25T17:28:07Z"
}
```
### Table / modèle `Student` (dans lapplication studioE5 déployée)
```json
{
"id": "uuid",
"organizationId": "uuid",
"firstName": "Jean",
"lastName": "Dupont",
"initials": "jd",
"activationCode": "AB3D9F",
"activationCodeExpiresAt": "2026-06-25T18:28:07Z",
"nodeId": "vps-8fc665eb",
"nodeToken": "..."
}
```
### Table / modèle `Instance` (dans lapplication studioE5 déployée)
```json
{
"id": "cmqqgrur20001lw67t2bdgzkg",
"organizationId": "uuid",
"studentId": "uuid",
"nodeId": "vps-8fc665eb",
"templateId": "wordpress-wordpress-latest",
"applicationPrefix": "wp",
"shortId": "47",
"subdomain": "wp-jd47",
"fqdn": "wp-jd47.ljf.edudeploy.com",
"port": 8001,
"status": "running"
}
```
---
## 15. Sécurité et RGPD
### Protection de lidentité de l’élève
- LURL publique ne contient pas le nom complet de l’élève.
- Seules les initiales + un identifiant court opaque sont exposées.
### Isolation réseau
- Les agents élèves ne peuvent pas communiquer entre eux (ACL Headscale).
- Le resolver est le seul service autorisé à joindre les agents sur leurs ports dinstance.
### Authentification
- Token unique par agent (`node.token`).
- Clé API interne pour les endpoints serveur → agent.
- Sessions NextAuth sur les routes API métier.
### Clés pré-auth Headscale
- Éphémères, à usage unique, 15 minutes dexpiration.
- Non persistées côté agent.
---
## 16. Checklist de validation du deployeur
À lissue dun onboarding, les points suivants doivent être validés :
- [ ] L’établissement est créé dans le hub avec un tag unique.
- [ ] Le VPS est provisionné et accessible en SSH.
- [ ] Docker et Docker Compose sont installés.
- [ ] Le DNS wildcard est résolu (`*.tag.edudeploy.com` → IP du VPS).
- [ ] Le certificat wildcard est obtenu et valide.
- [ ] La stack studioE5 est démarrée (`docker compose ps`).
- [ ] Headscale est initialisé avec les utilisateurs et clés nécessaires.
- [ ] Le compte administrateur de l’établissement est créé.
- [ ] Un code dactivation peut être généré pour un élève.
- [ ] Lagent est buildé et téléchargeable depuis le serveur de l’établissement.
- [ ] Lagent sactive avec le code zéro-config.
- [ ] Une instance peut être créée et son URL est accessible en HTTPS.
- [ ] Deux instances différentes reçoivent des URL uniques.
- [ ] Le flux HTTPS complet retourne bien HTTP 200.
---
## 17. Roadmap du deployeur
### Court terme (MVP)
- Déploiement manuel ou semi-automatisé dun nouvel établissement sur un VPS.
- Domaine géré par studioE5 uniquement.
- Build des images sur le VPS cible.
- Agent avec URL serveur hardcodée ou fournie à lactivation.
### Moyen terme
- **Agent générique** : déterminer lURL serveur cible à lactivation (code structuré, hub de résolution, ou champ URL).
- **Script de provisionning** : installation Docker, déploiement stack, génération secrets, DNS wildcard.
- **Registry dimages privé** : builder une fois, déployer partout.
- Support de domaines propres à l’établissement.
- Support multi-registrar DNS.
### Long terme
- **Hub central multi-clients** : dashboard superadmin, gestion des versions, logs distants.
- **Mises à jour à distance** : pousser une nouvelle version du serveur et de lagent sur tous les déploiements.
- **Monitoring / support** : alertes serveur down, certificat expiré, agent hors ligne.
- **Tests automatisés** : validation du flux activation → VPN → instance → HTTPS public à chaque déploiement.
- **Console/log intégré et barre de progression** dans lagent.
- Génération automatique de codes dactivation par import CSV.
+18
View File
@@ -0,0 +1,18 @@
{
"tagOwners": {
"tag:student-agent": ["studioe5@studioe5.local"],
"tag:resolver": ["resolver@studioe5.local"]
},
"acls": [
{
"action": "accept",
"src": ["tag:resolver"],
"dst": ["tag:student-agent:*"]
},
{
"action": "accept",
"src": ["tag:student-agent"],
"dst": ["tag:resolver:2020"]
}
]
}
+4
View File
@@ -38,6 +38,10 @@ database:
sqlite: sqlite:
path: /etc/headscale/db.sqlite path: /etc/headscale/db.sqlite
policy:
path: /etc/headscale/acl_policy.hujson
mode: file
log: log:
format: text format: text
level: info level: info
+3 -3
View File
@@ -30,9 +30,9 @@ docker build -t edubox-prestashop:9 .
```bash ```bash
docker tag edubox-prestashop:9 \ docker tag edubox-prestashop:9 \
151.80.60.98:3001/yacine/edubox/edubox-prestashop:9-edubox-9 gitea.alfrednobel.edudeploy.com/yacine/edubox/edubox-prestashop:9-edubox-9
docker push \ docker push \
151.80.60.98:3001/yacine/edubox/edubox-prestashop:9-edubox-9 gitea.alfrednobel.edudeploy.com/yacine/edubox/edubox-prestashop:9-edubox-9
``` ```
## Patches appliqués ## Patches appliqués
@@ -75,7 +75,7 @@ Le template PrestaShop 9 dans `server/prisma/seed.ts` utilise cette image :
```yaml ```yaml
app: app:
image: 151.80.60.98:3001/yacine/edubox/edubox-prestashop:9-edubox-8 image: gitea.alfrednobel.edudeploy.com/yacine/edubox/edubox-prestashop:9-edubox-8
``` ```
## Mise à jour vers une nouvelle version de PrestaShop ## Mise à jour vers une nouvelle version de PrestaShop
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version";
export async function GET(request: Request) {
const baseUrl = getBaseUrlFromRequest(request);
return NextResponse.json(getAgentVersionInfo(baseUrl));
}
+22 -4
View File
@@ -1,13 +1,20 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireAuth, requireRole, getScopedEstablishmentId, forbidden } from "@/lib/api-auth";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const establishmentId = searchParams.get("establishmentId"); const requestedId = searchParams.get("establishmentId");
if (!establishmentId) return NextResponse.json({ error: "Missing establishmentId" }, { status: 400 }); const establishmentId = getScopedEstablishmentId(user, requestedId);
if (establishmentId instanceof NextResponse) return establishmentId;
const where = establishmentId ? { establishmentId } : {};
const classes = await prisma.class.findMany({ const classes = await prisma.class.findMany({
where: { establishmentId }, where,
include: { _count: { select: { students: true } } }, include: { _count: { select: { students: true } } },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
@@ -15,8 +22,19 @@ export async function GET(req: NextRequest) {
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const denied = requireRole(user, "superadmin", "admin");
if (denied) return denied;
const body = await req.json(); const body = await req.json();
const { establishmentId, name, level } = body; const requestedId = body.establishmentId;
const establishmentId = getScopedEstablishmentId(user, requestedId);
if (establishmentId instanceof NextResponse) return establishmentId;
if (!establishmentId) return forbidden();
const { name, level } = body;
const cls = await prisma.class.create({ const cls = await prisma.class.create({
data: { establishmentId, name, level }, data: { establishmentId, name, level },
}); });
+8 -8
View File
@@ -1,13 +1,13 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version";
const AGENT_VERSION = "0.3.0"; export async function GET(request: Request) {
const AGENT_BIN_NAME = "studioE5-agent"; const baseUrl = getBaseUrlFromRequest(request);
const info = getAgentVersionInfo(baseUrl);
export async function GET() {
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,
}); });
} }
+12
View File
@@ -1,9 +1,15 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { hashPassword } from "@/lib/auth"; import { hashPassword } from "@/lib/auth";
import { requireAuth, requireRole } from "@/lib/api-auth";
export async function GET() { export async function GET() {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const where = user.role === "superadmin" ? {} : { id: user.establishmentId };
const establishments = await prisma.establishment.findMany({ const establishments = await prisma.establishment.findMany({
where,
include: { subscription: true, _count: { select: { users: true, classes: true } } }, include: { subscription: true, _count: { select: { users: true, classes: true } } },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
@@ -11,6 +17,12 @@ export async function GET() {
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const denied = requireRole(user, "superadmin");
if (denied) return denied;
const body = await req.json(); const body = await req.json();
const { name, slug, adminEmail, adminPassword } = body; const { name, slug, adminEmail, adminPassword } = body;
+111 -18
View File
@@ -1,16 +1,54 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { sendToNode } from "@/lib/websocket"; import { sendToNode } from "@/lib/websocket";
import { authOptions } from "@/lib/auth-config";
async function requireAuth() {
const session = await getServerSession(authOptions);
if (!session?.user) return null;
return session.user as { id: string; email: string; role: string; establishmentId?: string };
}
function userCanAccessNode(user: { role: string; establishmentId?: string }, node: any) {
if (user.role === "superadmin") return true;
const establishmentId = node?.student?.class?.establishmentId;
return establishmentId && establishmentId === user.establishmentId;
}
function userCanAccessInstance(user: { role: string; establishmentId?: string }, instance: any) {
if (user.role === "superadmin") return true;
const establishmentId = instance?.node?.student?.class?.establishmentId;
return establishmentId && establishmentId === user.establishmentId;
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await requireAuth();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const nodeId = searchParams.get("nodeId"); const nodeId = searchParams.get("nodeId");
const establishmentId = searchParams.get("establishmentId"); const establishmentIdParam = searchParams.get("establishmentId");
let where: any = {}; let where: any = {};
if (nodeId) where.nodeId = nodeId; if (nodeId) where.nodeId = nodeId;
if (establishmentId) {
const classes = await prisma.class.findMany({ where: { establishmentId }, select: { id: true } }); if (user.role !== "superadmin") {
const classes = await prisma.class.findMany({
where: { establishmentId: user.establishmentId },
select: { id: true },
});
const students = await prisma.student.findMany({
where: { classId: { in: classes.map((c) => c.id) } },
select: { id: true },
});
const nodes = await prisma.node.findMany({
where: { studentId: { in: students.map((s) => s.id) } },
select: { id: true },
});
where.nodeId = { in: nodes.map((n) => n.id) };
} else if (establishmentIdParam) {
const classes = await prisma.class.findMany({ where: { establishmentId: establishmentIdParam }, select: { id: true } });
const students = await prisma.student.findMany({ where: { classId: { in: classes.map((c) => c.id) } }, select: { id: true } }); const students = await prisma.student.findMany({ where: { classId: { in: classes.map((c) => c.id) } }, select: { id: true } });
const nodes = await prisma.node.findMany({ where: { studentId: { in: students.map((s) => s.id) } }, select: { id: true } }); const nodes = await prisma.node.findMany({ where: { studentId: { in: students.map((s) => s.id) } }, select: { id: true } });
where.nodeId = { in: nodes.map((n) => n.id) }; where.nodeId = { in: nodes.map((n) => n.id) };
@@ -39,12 +77,8 @@ export async function GET(req: NextRequest) {
const enriched = instances.map((inst) => { const enriched = instances.map((inst) => {
const domain = inst.node.student?.class.establishment?.domain; const domain = inst.node.student?.class.establishment?.domain;
const publicUrl = domain const publicUrl = domain ? `https://${inst.id}.${domain}` : null;
? `https://${inst.id}.${domain}` const localUrl = inst.node.tailscaleIp ? `http://${inst.node.tailscaleIp}:${inst.port}` : null;
: null;
const localUrl = inst.node.tailscaleIp
? `http://${inst.node.tailscaleIp}:${inst.port}`
: null;
return { return {
...inst, ...inst,
publicUrl, publicUrl,
@@ -56,22 +90,32 @@ export async function GET(req: NextRequest) {
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const user = await requireAuth();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json(); const body = await req.json();
const { nodeId, templateId, port } = body; const { nodeId, templateId, port } = body;
if (!nodeId || !templateId) {
return NextResponse.json({ error: "Missing nodeId or templateId" }, { status: 400 });
}
const template = await prisma.template.findUnique({ where: { id: templateId } }); const template = await prisma.template.findUnique({ where: { id: templateId } });
if (!template) return NextResponse.json({ error: "Template not found" }, { status: 404 }); if (!template) return NextResponse.json({ error: "Template not found" }, { status: 404 });
const instance = await prisma.instance.create({
data: { nodeId, templateId, port: port || 8080, status: "stopped" },
});
const node = await prisma.node.findUnique({ const node = await prisma.node.findUnique({
where: { id: nodeId }, where: { id: nodeId },
include: { student: { include: { class: { include: { establishment: true } } } } }, include: { student: { include: { class: { include: { establishment: true } } } } },
}); });
if (!node) return NextResponse.json({ error: "Node not found" }, { status: 404 });
if (!userCanAccessNode(user, node)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const domain = node?.student?.class.establishment?.domain; const instance = await prisma.instance.create({
data: { nodeId, templateId, port: port || 8080, status: "stopped" },
});
const domain = node.student?.class.establishment?.domain;
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost"; const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
const publicUrl = domain ? `https://${publicDomain}` : null; const publicUrl = domain ? `https://${publicDomain}` : null;
const sent = sendToNode(nodeId, { const sent = sendToNode(nodeId, {
@@ -84,6 +128,13 @@ export async function POST(req: NextRequest) {
.replace(/{INSTANCE_ID}/g, instance.id) .replace(/{INSTANCE_ID}/g, instance.id)
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`) .replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
.replace(/{PUBLIC_DOMAIN}/g, "localhost"), .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) { if (!sent) {
@@ -94,17 +145,31 @@ export async function POST(req: NextRequest) {
} }
export async function PATCH(req: NextRequest) { export async function PATCH(req: NextRequest) {
const user = await requireAuth();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json(); const body = await req.json();
const { id, action } = body; const { id, action } = body;
const instance = await prisma.instance.findUnique({ where: { id }, include: { template: true, node: { include: { student: { include: { class: { include: { establishment: true } } } } } } } }); if (!id || !action) {
return NextResponse.json({ error: "Missing id or action" }, { status: 400 });
}
const instance = await prisma.instance.findUnique({
where: { id },
include: { template: true, node: { include: { student: { include: { class: { include: { establishment: true } } } } } } },
});
if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 }); if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!userCanAccessInstance(user, instance)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const domain = instance.node.student?.class.establishment?.domain; const domain = instance.node.student?.class.establishment?.domain;
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost"; const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
const publicUrl = domain ? `https://${publicDomain}` : null; const publicUrl = domain ? `https://${publicDomain}` : null;
if (action === "stop") { if (action === "stop") {
sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id }); const sent = sendToNode(instance.nodeId, { action: "stop", instanceId: instance.id });
await prisma.instance.update({ where: { id }, data: { status: "stopped" } }); if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
} else if (action === "start") { } else if (action === "start") {
const sent = sendToNode(instance.nodeId, { const sent = sendToNode(instance.nodeId, {
action: "start", action: "start",
@@ -116,6 +181,13 @@ export async function PATCH(req: NextRequest) {
.replace(/{INSTANCE_ID}/g, instance.id) .replace(/{INSTANCE_ID}/g, instance.id)
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`) .replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
.replace(/{PUBLIC_DOMAIN}/g, "localhost"), .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" } }); if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
} else if (action === "reset") { } else if (action === "reset") {
@@ -129,18 +201,39 @@ export async function PATCH(req: NextRequest) {
.replace(/{INSTANCE_ID}/g, instance.id) .replace(/{INSTANCE_ID}/g, instance.id)
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`) .replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
.replace(/{PUBLIC_DOMAIN}/g, "localhost"), .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" } }); if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
} else {
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
} }
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
export async function DELETE(req: NextRequest) { export async function DELETE(req: NextRequest) {
const user = await requireAuth();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const id = searchParams.get("id"); const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 }); if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
const instance = await prisma.instance.findUnique({ where: { id } });
const instance = await prisma.instance.findUnique({
where: { id },
include: { node: { include: { student: { include: { class: { include: { establishment: true } } } } } } },
});
if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!userCanAccessInstance(user, instance)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (instance) sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id }); if (instance) sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
await prisma.instance.delete({ where: { id } }); await prisma.instance.delete({ where: { id } });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
@@ -1,7 +1,22 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { sendToNode } from "@/lib/websocket"; import { sendToNode } from "@/lib/websocket";
function getBearerToken(req: NextRequest): string | null {
const auth = req.headers.get("authorization") || "";
const match = auth.match(/^Bearer\s+(\S+)$/i);
return match ? match[1] : null;
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const apiKey = process.env.INTERNAL_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "Internal API key not configured" }, { status: 500 });
}
const token = getBearerToken(req);
if (!token || token !== apiKey) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json(); const body = await req.json();
const { nodeId, message } = body; const { nodeId, message } = body;
if (!nodeId || !message) { if (!nodeId || !message) {
+7 -1
View File
@@ -1,9 +1,15 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireAuth, getScopedEstablishmentId, forbidden } from "@/lib/api-auth";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const establishmentId = searchParams.get("establishmentId"); const requestedId = searchParams.get("establishmentId");
const establishmentId = getScopedEstablishmentId(user, requestedId);
if (establishmentId instanceof NextResponse) return establishmentId;
let where: any = {}; let where: any = {};
if (establishmentId) { if (establishmentId) {
+15
View File
@@ -1,7 +1,22 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
function getBearerToken(req: NextRequest): string | null {
const auth = req.headers.get("authorization") || "";
const match = auth.match(/^Bearer\s+(\S+)$/i);
return match ? match[1] : null;
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const apiKey = process.env.INTERNAL_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "Internal API key not configured" }, { status: 500 });
}
const token = getBearerToken(req);
if (!token || token !== apiKey) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const subdomain = searchParams.get("subdomain"); const subdomain = searchParams.get("subdomain");
+4 -8
View File
@@ -1,12 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { generateUniqueActivationCode } from "@/lib/activation";
function generateCode(length = 6) {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < length; i++) code += chars.charAt(Math.floor(Math.random() * chars.length));
return code;
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
@@ -31,13 +25,15 @@ export async function GET(req: NextRequest) {
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const body = await req.json(); const body = await req.json();
const { classId, firstName, lastName, email } = body; const { classId, firstName, lastName, email } = body;
const { code, expiresAt } = await generateUniqueActivationCode();
const student = await prisma.student.create({ const student = await prisma.student.create({
data: { data: {
classId, classId,
firstName, firstName,
lastName, lastName,
email, email,
activationCode: generateCode(), activationCode: code,
activationCodeExpiresAt: expiresAt,
}, },
}); });
return NextResponse.json(student, { status: 201 }); return NextResponse.json(student, { status: 201 });
+63 -8
View File
@@ -1,25 +1,57 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireAuth, requireRole, forbidden } from "@/lib/api-auth";
function templateAccessWhere(user: { role: string; establishmentId?: string }, establishmentId?: string | null) {
if (user.role === "superadmin" && establishmentId) {
return { OR: [{ isPublic: true }, { establishmentId }] };
}
if (user.establishmentId) {
return { OR: [{ isPublic: true }, { establishmentId: user.establishmentId }] };
}
return { isPublic: true };
}
async function canManageTemplate(user: { role: string; establishmentId?: string }, id: string) {
if (user.role === "superadmin") return true;
const template = await prisma.template.findUnique({ where: { id } });
if (!template) return false;
return template.establishmentId === user.establishmentId;
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const establishmentId = searchParams.get("establishmentId"); const requestedEst = searchParams.get("establishmentId");
const where = user.role === "superadmin" && !requestedEst ? {} : templateAccessWhere(user, requestedEst);
const templates = await prisma.template.findMany({ const templates = await prisma.template.findMany({
where: { where,
OR: [
{ isPublic: true },
...(establishmentId ? [{ establishmentId }] : []),
],
},
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
return NextResponse.json(templates); return NextResponse.json(templates);
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const denied = requireRole(user, "superadmin", "admin");
if (denied) return denied;
const body = await req.json(); const body = await req.json();
const { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy } = body; let { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy } = body;
if (user.role !== "superadmin") {
if (establishmentId && establishmentId !== user.establishmentId) {
return forbidden();
}
establishmentId = user.establishmentId;
}
const template = await prisma.template.create({ const template = await prisma.template.create({
data: { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy }, data: { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy },
}); });
@@ -27,16 +59,39 @@ export async function POST(req: NextRequest) {
} }
export async function PUT(req: NextRequest) { export async function PUT(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const denied = requireRole(user, "superadmin", "admin");
if (denied) return denied;
const body = await req.json(); const body = await req.json();
const { id, ...data } = body; const { id, ...data } = body;
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
if (!(await canManageTemplate(user, id))) return forbidden();
if (user.role !== "superadmin" && data.establishmentId && data.establishmentId !== user.establishmentId) {
return forbidden();
}
const template = await prisma.template.update({ where: { id }, data }); const template = await prisma.template.update({ where: { id }, data });
return NextResponse.json(template); return NextResponse.json(template);
} }
export async function DELETE(req: NextRequest) { export async function DELETE(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const denied = requireRole(user, "superadmin", "admin");
if (denied) return denied;
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const id = searchParams.get("id"); const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 }); if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
if (!(await canManageTemplate(user, id))) return forbidden();
await prisma.template.delete({ where: { id } }); await prisma.template.delete({ where: { id } });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
+47 -3
View File
@@ -1,14 +1,25 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { hashPassword } from "@/lib/auth"; import { hashPassword } from "@/lib/auth";
import { requireAuth, requireRole, forbidden } from "@/lib/api-auth";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const establishmentId = searchParams.get("establishmentId"); const establishmentId = searchParams.get("establishmentId");
const role = searchParams.get("role"); const role = searchParams.get("role");
if (user.role !== "superadmin") {
if (establishmentId && establishmentId !== user.establishmentId) {
return forbidden();
}
}
const where: any = {}; const where: any = {};
if (establishmentId) where.establishmentId = establishmentId; if (establishmentId) where.establishmentId = establishmentId;
else if (user.role !== "superadmin") where.establishmentId = user.establishmentId;
if (role) where.role = role; if (role) where.role = role;
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
@@ -19,23 +30,56 @@ export async function GET(req: NextRequest) {
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const denied = requireRole(user, "superadmin", "admin");
if (denied) return denied;
const body = await req.json(); const body = await req.json();
const { email, password, role, establishmentId } = body; const { email, password, role, establishmentId } = body;
const user = await prisma.user.create({
if (!email || !password || !role) {
return NextResponse.json({ error: "Missing email, password or role" }, { status: 400 });
}
if (user.role === "admin") {
if (role === "superadmin") return forbidden();
if (establishmentId && establishmentId !== user.establishmentId) return forbidden();
}
const finalEstablishmentId = user.role === "superadmin" ? establishmentId : user.establishmentId;
const newUser = await prisma.user.create({
data: { data: {
email, email,
password: await hashPassword(password), password: await hashPassword(password),
role, role,
establishmentId, establishmentId: finalEstablishmentId,
}, },
}); });
return NextResponse.json(user, { status: 201 }); return NextResponse.json(newUser, { status: 201 });
} }
export async function DELETE(req: NextRequest) { export async function DELETE(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const denied = requireRole(user, "superadmin", "admin");
if (denied) return denied;
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const id = searchParams.get("id"); const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 }); if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (user.role === "admin") {
if (target.role === "superadmin") return forbidden();
if (target.establishmentId !== user.establishmentId) return forbidden();
}
await prisma.user.delete({ where: { id } }); await prisma.user.delete({ where: { id } });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
+34 -8
View File
@@ -1,23 +1,49 @@
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version";
const AGENT_VERSION = "0.3.0"; import { headers } from "next/headers";
const AGENT_BIN_NAME = "studioE5-agent";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default function DownloadPage() { export default async function DownloadPage() {
const h = await headers();
const proto = h.get("x-forwarded-proto") ?? "https";
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "";
const baseUrl = host ? `${proto}://${host}` : undefined;
const info = getAgentVersionInfo(baseUrl);
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>
<CardTitle>Windows</CardTitle> <CardTitle>Windows (.exe)</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Windows (64 bits)</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>
</Card>
<Card>
<CardHeader>
<CardTitle>Windows (archive)</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">Archive complète incluant l&apos;agent, Tailscale et le README Windows.</p>
<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>
</Card>
<Card>
<CardHeader>
<CardTitle>Linux</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Linux (64 bits).</p>
<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>
+3 -10
View File
@@ -3,17 +3,9 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth/next"; import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth-config"; import { authOptions } from "@/lib/auth-config";
import { generateUniqueActivationCode } from "@/lib/activation";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
function generateCode(): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
export async function deleteStudent(formData: FormData) { export async function deleteStudent(formData: FormData) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login"); if (!session?.user) redirect("/login");
@@ -54,9 +46,10 @@ export async function generateActivationCodeAction(formData: FormData) {
if (!student) return; if (!student) return;
const { code, expiresAt } = await generateUniqueActivationCode();
await prisma.student.update({ await prisma.student.update({
where: { id }, where: { id },
data: { activationCode: generateCode() }, data: { activationCode: code, activationCodeExpiresAt: expiresAt },
}); });
redirect(`/dashboard/students/${id}`); redirect(`/dashboard/students/${id}`);
+4 -10
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth/next"; import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth-config"; import { authOptions } from "@/lib/auth-config";
import { generateUniqueActivationCode } from "@/lib/activation";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -17,15 +18,6 @@ const schema = z.object({
classId: z.string().min(1), classId: z.string().min(1),
}); });
function generateActivationCode(): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
async function createStudent(formData: FormData) { async function createStudent(formData: FormData) {
"use server"; "use server";
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
@@ -35,13 +27,15 @@ async function createStudent(formData: FormData) {
const parsed = schema.safeParse(Object.fromEntries(formData)); const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return; if (!parsed.success) return;
const { code, expiresAt } = await generateUniqueActivationCode();
await prisma.student.create({ await prisma.student.create({
data: { data: {
firstName: parsed.data.firstName, firstName: parsed.data.firstName,
lastName: parsed.data.lastName, lastName: parsed.data.lastName,
email: parsed.data.email, email: parsed.data.email,
classId: parsed.data.classId, classId: parsed.data.classId,
activationCode: generateActivationCode(), activationCode: code,
activationCodeExpiresAt: expiresAt,
}, },
}); });
+25
View File
@@ -0,0 +1,25 @@
import { randomBytes } from "crypto";
import { prisma } from "./prisma";
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const CODE_LENGTH = 6;
const CODE_TTL_MINUTES = 60;
export function generateActivationCode(): { code: string; expiresAt: Date } {
let code = "";
const bytes = randomBytes(CODE_LENGTH);
for (let i = 0; i < CODE_LENGTH; i++) {
code += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
}
const expiresAt = new Date(Date.now() + CODE_TTL_MINUTES * 60 * 1000);
return { code, expiresAt };
}
export async function generateUniqueActivationCode(retries = 5): Promise<{ code: string; expiresAt: Date }> {
for (let i = 0; i < retries; i++) {
const { code, expiresAt } = generateActivationCode();
const existing = await prisma.student.findUnique({ where: { activationCode: code } });
if (!existing) return { code, expiresAt };
}
throw new Error("Failed to generate a unique activation code");
}
+74
View File
@@ -0,0 +1,74 @@
import fs from "fs";
import path from "path";
const BIN_NAME = "studioE5-agent";
// Build the public base URL from an incoming request, respecting common
// reverse-proxy headers (X-Forwarded-Proto / X-Forwarded-Host).
export function getBaseUrlFromRequest(req: Request): string {
const headers = req.headers;
const forwardedProto = headers.get("x-forwarded-proto");
const forwardedHost = headers.get("x-forwarded-host");
if (forwardedProto && forwardedHost) {
return `${forwardedProto}://${forwardedHost}`;
}
const url = new URL(req.url);
return `${url.protocol}//${url.host}`;
}
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,
baseUrl?: string
): AgentDownloadUrls {
const prefix = baseUrl ? baseUrl.replace(/\/$/, "") : "";
return {
windows: `${prefix}/${BIN_NAME}-v${version}.exe`,
windowsZip: `${prefix}/${BIN_NAME}-v${version}-windows.zip`,
linux: `${prefix}/${BIN_NAME}-v${version}`,
mac: `${prefix}/${BIN_NAME}-v${version}-mac`,
};
}
export function getAgentVersionInfo(baseUrl?: string) {
const version = getAgentVersion();
return {
version,
downloadUrls: getAgentDownloadUrls(version, baseUrl),
};
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "./auth-config";
export type ApiUser = {
id: string;
email: string;
role: "superadmin" | "admin" | "teacher";
establishmentId?: string;
};
export async function requireAuth(): Promise<ApiUser | NextResponse> {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return session.user as ApiUser;
}
export function requireRole(user: ApiUser, ...allowed: string[]): NextResponse | null {
if (!allowed.includes(user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
export function forbidden(): NextResponse {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
export function getScopedEstablishmentId(user: ApiUser, requested?: string | null): string | undefined | NextResponse {
if (user.role === "superadmin") {
return requested ?? undefined;
}
if (requested && requested !== user.establishmentId) {
return forbidden();
}
return user.establishmentId;
}
+75
View File
@@ -0,0 +1,75 @@
interface HeadscaleUser {
id: string;
name: string;
}
interface HeadscalePreAuthKey {
key: string;
expiration: string;
aclTags: string[];
}
export async function getHeadscaleUserId(
baseUrl: string,
apiKey: string,
userName: string
): Promise<string> {
const res = await fetch(
`${baseUrl}/api/v1/user?name=${encodeURIComponent(userName)}`,
{
headers: { Authorization: `Bearer ${apiKey}` },
}
);
if (!res.ok) {
throw new Error(
`Headscale list users failed: ${res.status} ${await res.text()}`
);
}
const data = (await res.json()) as { users: HeadscaleUser[] };
const user = data.users.find((u) => u.name === userName);
if (!user) {
throw new Error(`Headscale user not found: ${userName}`);
}
return user.id;
}
export async function createEphemeralPreAuthKey(
baseUrl: string,
apiKey: string,
userId: string,
options: {
expirationMinutes?: number;
aclTags?: string[];
} = {}
): Promise<string> {
const expirationMinutes = options.expirationMinutes ?? 15;
const aclTags = options.aclTags ?? [];
const expiration = new Date(
Date.now() + expirationMinutes * 60 * 1000
).toISOString();
const res = await fetch(`${baseUrl}/api/v1/preauthkey`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
user: userId,
reusable: false,
ephemeral: false,
expiration,
aclTags,
}),
});
if (!res.ok) {
throw new Error(
`Headscale create preauthkey failed: ${res.status} ${await res.text()}`
);
}
const data = (await res.json()) as { preAuthKey: HeadscalePreAuthKey };
return data.preAuthKey.key;
}
+280 -21
View File
@@ -1,5 +1,9 @@
import { WebSocketServer, WebSocket } from "ws"; import { WebSocketServer, WebSocket } from "ws";
import { randomBytes } from "crypto";
import type { IncomingMessage } from "http";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { createEphemeralPreAuthKey, getHeadscaleUserId } from "./headscale";
import { getAgentVersion } from "./agent-version";
interface NodeMessage { interface NodeMessage {
action: string; action: string;
@@ -9,17 +13,77 @@ 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;
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>();
interface AttemptWindow {
count: number;
firstAttempt: number;
}
const activationAttemptsByCode = new Map<string, AttemptWindow>();
const activationAttemptsByNode = new Map<string, AttemptWindow>();
const MAX_ACTIVATION_ATTEMPTS = 5;
const ACTIVATION_WINDOW_MS = 15 * 60 * 1000;
const HEADSCALE_USER = "studioe5";
const HEADSCALE_AGENT_TAG = "tag:student-agent";
const HEADSCALE_KEY_EXPIRATION_MINUTES = 15;
let headscaleUserIdCache: string | null = null;
function recordActivationAttempt(map: Map<string, AttemptWindow>, key: string): boolean {
const now = Date.now();
const win = map.get(key);
if (!win || now - win.firstAttempt > ACTIVATION_WINDOW_MS) {
map.set(key, { count: 1, firstAttempt: now });
return true;
}
win.count++;
return win.count <= MAX_ACTIVATION_ATTEMPTS;
}
function clearActivationAttempts(code: string, nodeId: string) {
activationAttemptsByCode.delete(code);
activationAttemptsByNode.delete(nodeId);
}
function generateNodeToken(): string {
return randomBytes(32).toString("hex");
}
function getBearerToken(req: IncomingMessage): string | null {
const auth = req.headers.authorization || "";
const match = auth.match(/^Bearer\s+(\S+)$/i);
return match ? match[1] : null;
}
function close(ws: WebSocket, code: number, reason: string) {
try {
ws.close(code, reason);
} catch {
// ignore
}
}
export function initWebSocketServer(wss: WebSocketServer) { export function initWebSocketServer(wss: WebSocketServer) {
wss.on("connection", (ws: WebSocket) => { wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
let nodeId: string | null = null; let nodeId: string | null = null;
console.log("[WS] New connection"); let authenticated = false;
const token = getBearerToken(req);
console.log("[WS] New connection", token ? "(token provided)" : "(no token)");
ws.on("message", async (raw) => { ws.on("message", async (raw) => {
try { try {
@@ -27,19 +91,69 @@ export function initWebSocketServer(wss: WebSocketServer) {
console.log("[WS] Received:", msg.action, "from", msg.nodeId || nodeId); console.log("[WS] Received:", msg.action, "from", msg.nodeId || nodeId);
if (msg.action === "register" && msg.nodeId) { if (msg.action === "register" && msg.nodeId) {
nodeId = msg.nodeId; const id = msg.nodeId;
nodes.set(nodeId, ws); const existing = await prisma.node.findUnique({ where: { id } });
await prisma.node.upsert({
where: { id: nodeId }, if (token) {
update: { status: "online", lastSeen: new Date() }, // Token supplied: it must match the stored token for this node.
create: { id: nodeId, status: "online", lastSeen: new Date() }, if (!existing || existing.token !== token) {
}); console.log("[WS] Invalid token for node", id);
ws.send(JSON.stringify({ action: "registered" })); close(ws, 1008, "invalid token");
return;
}
authenticated = true;
} else if (existing && existing.token) {
// Existing node has a token but none was supplied.
console.log("[WS] Missing token for node", id);
close(ws, 1008, "missing token");
return;
} else if (existing) {
// Migration path: existing node without a token gets one on first register.
const newToken = generateNodeToken();
await prisma.node.update({
where: { id },
data: { token: newToken, status: "online", lastSeen: new Date() },
});
ws.send(JSON.stringify({ action: "set_token", token: newToken }));
authenticated = true;
}
// If the node does not exist yet, we stay unauthenticated until activation.
nodeId = id;
if (authenticated) {
const existing = nodes.get(id);
if (existing && existing !== ws && existing.readyState === WebSocket.OPEN) {
console.log("[WS] Superseding previous connection for", id);
existing.close(1008, "superseded");
}
nodes.set(id, ws);
await prisma.node.upsert({
where: { id },
update: { status: "online", lastSeen: new Date() },
create: { id, status: "online", lastSeen: new Date() },
});
}
ws.send(JSON.stringify({ action: "registered", serverVersion: getAgentVersion() }));
return; return;
} }
if (msg.action === "activate" && msg.code && msg.nodeId) { if (msg.action === "activate" && msg.code && msg.nodeId) {
nodeId = msg.nodeId; const id = msg.nodeId;
nodeId = id;
if (!recordActivationAttempt(activationAttemptsByCode, msg.code) ||
!recordActivationAttempt(activationAttemptsByNode, id)) {
console.log("[WS] Too many activation attempts for code/node", msg.code, id);
ws.send(JSON.stringify({ action: "activation_failed", error: "Too many attempts" }));
return;
}
const existing = await prisma.node.findUnique({ where: { id } });
if (existing && existing.token && (!authenticated || nodeId !== id)) {
console.log("[WS] Node already activated and not authenticated:", id);
ws.send(JSON.stringify({ action: "activation_failed", error: "Node already activated" }));
return;
}
const student = await prisma.student.findUnique({ const student = await prisma.student.findUnique({
where: { activationCode: msg.code }, where: { activationCode: msg.code },
}); });
@@ -48,23 +162,97 @@ export function initWebSocketServer(wss: WebSocketServer) {
ws.send(JSON.stringify({ action: "activation_failed", error: "Invalid code" })); ws.send(JSON.stringify({ action: "activation_failed", error: "Invalid code" }));
return; return;
} }
if (!student.activationCodeExpiresAt || student.activationCodeExpiresAt < new Date()) {
console.log("[WS] Expired code:", msg.code);
ws.send(JSON.stringify({ action: "activation_failed", error: "Code expired" }));
return;
}
const newToken = generateNodeToken();
await prisma.node.upsert({ await prisma.node.upsert({
where: { id: nodeId }, where: { id },
update: { studentId: student.id, status: "online", lastSeen: new Date() }, update: {
create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() }, studentId: student.id,
status: "online",
lastSeen: new Date(),
token: newToken,
},
create: {
id,
studentId: student.id,
status: "online",
lastSeen: new Date(),
token: newToken,
},
}); });
console.log("[WS] Activated:", student.firstName, student.lastName, "on", nodeId);
// Invalidate the activation code so it cannot be reused.
await prisma.student.update({
where: { id: student.id },
data: { activationCode: null, activationCodeExpiresAt: null },
});
clearActivationAttempts(msg.code, id);
authenticated = true;
const previous = nodes.get(id);
if (previous && previous !== ws && previous.readyState === WebSocket.OPEN) {
console.log("[WS] Superseding previous connection for", id);
previous.close(1008, "superseded");
}
nodes.set(id, ws);
const headscaleUrl = process.env.HEADSCALE_URL;
const headscaleApiKey = process.env.HEADSCALE_API_KEY;
const reusableAuthKey = process.env.HEADSCALE_AUTH_KEY;
if (!headscaleUrl) {
console.log("[WS] HEADSCALE_URL missing");
ws.send(JSON.stringify({ action: "activation_failed", error: "Server misconfiguration" }));
return;
}
let headscaleAuthKey: string;
try {
if (headscaleApiKey) {
if (!headscaleUserIdCache) {
headscaleUserIdCache = await getHeadscaleUserId(headscaleUrl, headscaleApiKey, HEADSCALE_USER);
}
headscaleAuthKey = await createEphemeralPreAuthKey(headscaleUrl, headscaleApiKey, headscaleUserIdCache, {
expirationMinutes: HEADSCALE_KEY_EXPIRATION_MINUTES,
aclTags: [HEADSCALE_AGENT_TAG],
});
console.log("[WS] Generated ephemeral Headscale key for", id);
} else if (reusableAuthKey) {
console.log("[WS] HEADSCALE_API_KEY not set, falling back to reusable HEADSCALE_AUTH_KEY");
headscaleAuthKey = reusableAuthKey;
} else {
console.log("[WS] No Headscale key available");
ws.send(JSON.stringify({ action: "activation_failed", error: "Server misconfiguration" }));
return;
}
} catch (err) {
console.error("[WS] Failed to create ephemeral Headscale key:", err);
ws.send(JSON.stringify({ action: "activation_failed", error: "Failed to create VPN key" }));
return;
}
console.log("[WS] Activated:", student.firstName, student.lastName, "on", id);
ws.send(JSON.stringify({ ws.send(JSON.stringify({
action: "activated", action: "activated",
studentId: student.id, studentId: student.id,
studentName: `${student.firstName} ${student.lastName}`, studentName: `${student.firstName} ${student.lastName}`,
headscaleUrl: process.env.HEADSCALE_URL, headscaleUrl,
headscaleAuthKey: process.env.HEADSCALE_AUTH_KEY, headscaleAuthKey,
token: newToken,
})); }));
return; return;
} }
if (msg.action === "heartbeat" && nodeId) { if (!authenticated || !nodeId) {
console.log("[WS] Unauthenticated message", msg.action, "ignored");
return;
}
if (msg.action === "heartbeat") {
await prisma.node.upsert({ await prisma.node.upsert({
where: { id: nodeId }, where: { id: nodeId },
update: { lastSeen: new Date() }, update: { lastSeen: new Date() },
@@ -73,7 +261,7 @@ export function initWebSocketServer(wss: WebSocketServer) {
return; return;
} }
if (msg.action === "tailscale_ip" && nodeId && msg.tailscaleIp) { if (msg.action === "tailscale_ip" && msg.tailscaleIp) {
await prisma.node.update({ await prisma.node.update({
where: { id: nodeId }, where: { id: nodeId },
data: { tailscaleIp: msg.tailscaleIp }, data: { tailscaleIp: msg.tailscaleIp },
@@ -82,19 +270,90 @@ 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) {
await prisma.instance.update({ const { count } = await prisma.instance.updateMany({
where: { id: msg.instanceId }, where: { id: msg.instanceId },
data: { status: "running" }, data: { status: "running" },
}); });
if (count) console.log("[WS] Instance started:", msg.instanceId);
return;
}
if (msg.action === "instance_stopped" && msg.instanceId) {
const { count } = await prisma.instance.updateMany({
where: { id: msg.instanceId },
data: { status: "stopped" },
});
if (count) console.log("[WS] Instance stopped:", msg.instanceId);
return;
}
if (msg.action === "instance_deleted" && msg.instanceId) {
const { count } = await prisma.instance.deleteMany({
where: { id: msg.instanceId },
});
if (count) console.log("[WS] Instance deleted:", msg.instanceId);
return; return;
} }
if (msg.action === "instance_error" && msg.instanceId) { if (msg.action === "instance_error" && msg.instanceId) {
await prisma.instance.update({ const { count } = await prisma.instance.updateMany({
where: { id: msg.instanceId }, where: { id: msg.instanceId },
data: { status: "error" }, data: { status: "error" },
}); });
if (count) console.log("[WS] Instance error:", msg.instanceId);
return; return;
} }
} catch (err) { } catch (err) {
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "initScript" TEXT;
+12 -9
View File
@@ -50,21 +50,23 @@ model Class {
} }
model Student { model Student {
id String @id @default(cuid()) id String @id @default(cuid())
classId String classId String
class Class @relation(fields: [classId], references: [id], onDelete: Cascade) class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
firstName String firstName String
lastName String lastName String
email String email String
activationCode String? @unique activationCode String? @unique
createdAt DateTime @default(now()) activationCodeExpiresAt DateTime?
nodes Node[] createdAt DateTime @default(now())
nodes Node[]
} }
model Node { model Node {
id String @id id String @id
studentId String? studentId String?
student Student? @relation(fields: [studentId], references: [id], onDelete: Cascade) student Student? @relation(fields: [studentId], references: [id], onDelete: Cascade)
token String? @unique
tailscaleIp String? tailscaleIp String?
status String @default("offline") status String @default("offline")
lastSeen DateTime? lastSeen DateTime?
@@ -89,6 +91,7 @@ model Template {
type String type String
dockerImage String dockerImage String
composeConfig String composeConfig String
initScript String?
isPublic Boolean @default(true) isPublic Boolean @default(true)
establishmentId String? establishmentId String?
establishment Establishment? @relation(fields: [establishmentId], references: [id], onDelete: Cascade) establishment Establishment? @relation(fields: [establishmentId], references: [id], onDelete: Cascade)
+67 -3
View File
@@ -1,5 +1,7 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import fs from "fs";
import path from "path";
const prisma = new PrismaClient(); 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 = [ const templates = [
{ {
name: "WordPress latest vierge", name: "WordPress latest vierge",
@@ -50,10 +57,34 @@ async function main() {
dbPassword: "wordpress", dbPassword: "wordpress",
dbRootPassword: "rootpassword", 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)", name: "PrestaShop 9 vierge (edubox)",
type: "prestashop", type: "prestashop",
dockerImage: "151.80.60.98:3001/yacine/edubox/edubox-prestashop:9-edubox-9", dockerImage: "gitea.alfrednobel.edudeploy.com/yacine/edubox/edubox-prestashop:9-edubox-9",
dbImage: "mariadb:10.11", dbImage: "mariadb:10.11",
dbName: "prestashop", dbName: "prestashop",
dbUser: "prestashop", dbUser: "prestashop",
@@ -66,6 +97,7 @@ async function main() {
const dbHost = "db"; const dbHost = "db";
const dbPort = "3306"; const dbPort = "3306";
const isPrestaShop = t.type === "prestashop"; const isPrestaShop = t.type === "prestashop";
const isWordPressReady = (t as any).ready === true;
const appEnv = isPrestaShop const appEnv = isPrestaShop
? ` DB_SERVER: ${dbHost} ? ` DB_SERVER: ${dbHost}
@@ -93,6 +125,12 @@ async function main() {
WORDPRESS_DB_PREFIX: wp_ WORDPRESS_DB_PREFIX: wp_
# No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header`; # 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 const appVolumes = isPrestaShop
? ` volumes: ? ` volumes:
- app_data:/var/www/html` - app_data:/var/www/html`
@@ -100,6 +138,28 @@ async function main() {
- app_data:/var/www/html - app_data:/var/www/html
- {MU_PLUGINS_DIR}/edubox-public-url.php:/var/www/html/wp-content/mu-plugins/edubox-public-url.php:ro`; - {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: const composeConfig = `services:
db: db:
image: ${t.dbImage} image: ${t.dbImage}
@@ -123,24 +183,28 @@ async function main() {
environment: environment:
${appEnv} ${appEnv}
INSTANCE_ID: {INSTANCE_ID} INSTANCE_ID: {INSTANCE_ID}
${appDNS}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
${appVolumes} ${appVolumes}
restart: unless-stopped restart: unless-stopped
volumes: ${wpInitService}volumes:
db_data: db_data:
app_data: app_data:
`; `;
const initScript = isWordPressReady ? wpReadyInitScript : null;
await prisma.template.upsert({ await prisma.template.upsert({
where: { id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}` }, where: { id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}` },
update: { composeConfig }, update: { composeConfig, initScript },
create: { create: {
id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}`, id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}`,
name: t.name, name: t.name,
type: t.type, type: t.type,
dockerImage: t.dockerImage, dockerImage: t.dockerImage,
composeConfig, composeConfig,
initScript,
isPublic: true, isPublic: true,
createdBy: "system", createdBy: "system",
}, },
@@ -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."
File diff suppressed because one or more lines are too long