Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc61404271 | |||
| 3c519629d2 | |||
| 0f07a2d2a3 | |||
| d2c3edea2f | |||
| 41929be34c | |||
| adab165274 | |||
| 33d89c66c0 | |||
| e946b22a42 | |||
| cf8b66340a | |||
| a414f03a59 | |||
| 331187e9b5 | |||
| 281c7c9a19 | |||
| 3a3e3ed202 | |||
| d090f67bff | |||
| 03b2f1267d |
@@ -5,6 +5,11 @@ NEXTAUTH_URL=http://localhost
|
||||
SUPERADMIN_EMAIL=admin@edudeploy.fr
|
||||
SUPERADMIN_PASSWORD=CHANGE_ME
|
||||
HEADSCALE_URL=http://headscale:8080
|
||||
# Legacy reusable pre-auth key (kept for manual/debug setups).
|
||||
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_TOKEN=CHANGE_ME
|
||||
|
||||
@@ -26,3 +26,6 @@ headscale/*.key
|
||||
headscale/*.state
|
||||
agent/resolv.conf
|
||||
agent/tailscale-bin/
|
||||
agent/studioE5-agent-test
|
||||
agent/.cache-go/
|
||||
server/tsconfig.tsbuildinfo
|
||||
|
||||
@@ -26,6 +26,11 @@ headscale.studioe5.edudeploy.com:443 {
|
||||
reverse_proxy headscale:8080
|
||||
}
|
||||
|
||||
gitea.alfrednobel.edudeploy.com {
|
||||
tls admin@edudeploy.com
|
||||
reverse_proxy 151.80.60.98:3001
|
||||
}
|
||||
|
||||
studioe5.edudeploy.com:443 {
|
||||
route /studioE5-agent* {
|
||||
file_server {
|
||||
|
||||
+674
-15
@@ -64,6 +64,68 @@ HTTP/2 200
|
||||
- Certificat Let’s Encrypt obtenu automatiquement par Caddy (`tls { on_demand }`).
|
||||
- Le resolver réécrit les en-têtes `Location` et le contenu HTML pour passer de `http://` à `https://`.
|
||||
|
||||
## 📝 Template WordPress prêt à l’emploi
|
||||
|
||||
Un nouveau template `wordpress-ready-wordpress-latest` a été créé et validé le 2026-06-26. Il fournit un WordPress déjà initialisé en français, prêt à l’usage en classe ou en examen.
|
||||
|
||||
### Contenu du template
|
||||
|
||||
| Élément | Valeur / État |
|
||||
|---|---|
|
||||
| Langue | **Français** (`fr_FR`) |
|
||||
| Titre du site | **Mon site wordpress** |
|
||||
| Compte administrateur | **admin / admin** |
|
||||
| Thème actif | **Astra** |
|
||||
| Spectra | installé et **actif** |
|
||||
| Yoast SEO | installé mais **inactif** |
|
||||
| Mises à jour automatiques | **désactivées** (core, plugins, thèmes) |
|
||||
| DNS conteneur | `8.8.8.8` + `1.1.1.1` pour permettre l’accès à `api.wordpress.org` |
|
||||
|
||||
### Architecture technique
|
||||
|
||||
- Le modèle `Template` de Prisma dispose d’un nouveau champ `initScript` (`TEXT?`).
|
||||
- Le seed génère le template avec :
|
||||
- une section `dns` dans le service `app` du `docker-compose.yml` ;
|
||||
- un service sidecar `wp-init` (image `wordpress:cli`) exécutant le script d’initialisation.
|
||||
- L’agent écrit le script `wp-init.sh` dans le dossier de l’instance au démarrage.
|
||||
- Le conteneur `wp-init` attend que WordPress soit prêt, puis exécute WP-CLI en tant que `www-data`.
|
||||
- Un fichier flag `.studioe5-init-done` évite de réinitialiser l’instance à chaque redémarrage.
|
||||
|
||||
### Fichiers modifiés / ajoutés
|
||||
|
||||
- `server/prisma/schema.prisma` – champ `initScript` sur `Template`.
|
||||
- `server/prisma/seed.ts` – génération du template `wordpress-ready-wordpress-latest`.
|
||||
- `server/templates/wordpress-ready/wp-init.sh` – script d’initialisation WP-CLI.
|
||||
- `server/app/api/instances/route.ts` – envoi de `initScript` à l’agent avec remplacement des placeholders.
|
||||
- `agent/websocket.go` – réception et transmission de `InitScript`.
|
||||
- `agent/docker.go` – écriture du script dans le dossier instance (`writeInitScript`).
|
||||
|
||||
### Validation
|
||||
|
||||
Instance de test créée via l’API (`cmqv03a6v0001vg8zrpe8zqfy`) :
|
||||
|
||||
```bash
|
||||
$ curl -sS -I -L https://cmqv03a6v0001vg8zrpe8zqfy.studioe5.edudeploy.com/
|
||||
HTTP/2 200
|
||||
```
|
||||
|
||||
- Page d’accueil en français, titre **« Mon site wordpress »**.
|
||||
- Connexion admin `/wp-login.php` avec **admin / admin** fonctionnelle.
|
||||
- Tableau de bord en français.
|
||||
- Plugins : Spectra actif, Yoast SEO inactif.
|
||||
- `wp-config.php` contient les constantes de désactivation des mises à jour automatiques.
|
||||
|
||||
Les instances de test ont été nettoyées après validation.
|
||||
|
||||
### Template versionné WordPress 7.0.0
|
||||
|
||||
Un second template `wordpress-ready-wordpress-7.0.0-php8.3` a été ajouté pour figer WordPress sur la version **7.0.0** (PHP 8.3) au lieu de `latest`. Cela garantit que tous les postes déploient exactement la même version, sans dépendre du cache local de `wordpress:latest`.
|
||||
|
||||
| Template | Image Docker |
|
||||
|---|---|
|
||||
| `wordpress-ready-wordpress-latest` | `wordpress:latest` |
|
||||
| `wordpress-ready-wordpress-7.0.0-php8.3` | `wordpress:7.0.0-php8.3` |
|
||||
|
||||
## 📁 Fichiers modifiés (non exhaustif)
|
||||
|
||||
- `agent/tailscale.go` – lancement `tailscaled` + `tailscale up`, gestion start/stop/status.
|
||||
@@ -79,7 +141,7 @@ Agent de test lancé en arrière-plan :
|
||||
- data-dir : `/tmp/studioe5-test-clienta`
|
||||
- node-id : `vps-8fc665eb`
|
||||
- tailnet IP actuelle : `100.64.0.8`
|
||||
- PID : `3151830` (lancé le 2026-06-23 09:36 UTC)
|
||||
- PID : voir `/tmp/studioe5-test-clienta/agent.pid` (relancé le 2026-06-26 11:53 UTC avec l’agent v0.3.5 corrigé)
|
||||
|
||||
Instance de test créée :
|
||||
- ID : `test-wp-001`
|
||||
@@ -88,6 +150,204 @@ Instance de test créée :
|
||||
- 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/`**.
|
||||
|
||||
## 🪟 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 :
|
||||
- L’agent 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 l’agent 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
|
||||
|
||||
L’agent 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 d’instance | `serve --bg --tcp=<port> tcp://localhost:<port>` |
|
||||
| Arrêt d’instance | `serve --bg --tcp=<port> off` |
|
||||
| Suppression d’instance | `serve --bg --tcp=<port> off` |
|
||||
| Redémarrage de l’agent | 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 d’action 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 l’agent, les instances en cours sont arrêtées proprement (`stop`) et le serveur est notifié (`instance_stopped`).
|
||||
|
||||
### Démarrage du VPN après activation
|
||||
|
||||
L’agent 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 d’instance
|
||||
|
||||
### Problème
|
||||
|
||||
Lors de la création d’une instance depuis le dashboard vers certains agents (notamment Windows), l’agent s’arrêtait brutalement. Le `recover()` présent dans `handleMessage` ne capturait pas le panic car celui-ci survenait dans les goroutines lancées par `go handleStartInstance(...)`.
|
||||
|
||||
### Corrections apportées
|
||||
|
||||
- Ajout d’un `defer recover()` dans `handleStartInstance` ; en cas de panic, l’instance passe en statut `error` et un message `instance_error` est envoyé au serveur.
|
||||
- Ajout d’un `defer recover()` dans toutes les goroutines critiques du WebSocket :
|
||||
- `start_vpn`
|
||||
- `stop_vpn`
|
||||
- `start`
|
||||
- `reset`
|
||||
- `startTailscaleAndReport`
|
||||
- cleanup au shutdown
|
||||
- Ajout de logs de traçage au début de `handleStartInstance` (`instance`, `type`, `port`, `dataDir`, `initScriptLen`).
|
||||
|
||||
### Téléchargement
|
||||
|
||||
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.6-windows.zip`
|
||||
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.6.exe`
|
||||
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.6`
|
||||
|
||||
### Redeploiement
|
||||
|
||||
- Agent rebuildé en v0.3.6 pour Windows et Linux.
|
||||
- Binaires versionnés copiés dans `server/public/`.
|
||||
- Page `/dashboard/download` mise à jour vers la v0.3.6.
|
||||
- Serveur rebuildé et redémarré.
|
||||
|
||||
## 🪟 Agent v0.3.7 – recover() dans les notifications UI
|
||||
|
||||
### Problème
|
||||
|
||||
L’agent continuait de s’arrêter brutalement lors de la création d’une instance depuis le dashboard. Le crash survenait juste après les logs `Start instance ...` et `notifyUI: broadcasting to ...`, sans laisser de trace de panic. Cela pointait vers une panique dans les goroutines de notification UI ou dans l’écriture des logs vers les clients UI locaux.
|
||||
|
||||
### Corrections apportées
|
||||
|
||||
- Ajout d’un `defer recover()` dans `notifyUI` pour chaque goroutine de notification.
|
||||
- Ajout d’un `defer recover()` dans `sendUILog` (logs diffusés aux clients UI).
|
||||
- Ajout d’un `defer recover()` dans `broadcastUI` (messages diffusés aux clients UI).
|
||||
|
||||
### Téléchargement
|
||||
|
||||
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.7-windows.zip`
|
||||
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.7.exe`
|
||||
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.7`
|
||||
|
||||
## 🪟 Agent v0.3.8 – DNS automatique pour Podman machine (Windows/macOS)
|
||||
|
||||
### Problème
|
||||
|
||||
Après correction du crash, l’agent Windows avec Podman échouait au `docker compose up` avec :
|
||||
```text
|
||||
lookup registry-1.docker.io: Temporary failure in name resolution
|
||||
```
|
||||
La VM Podman machine n’avait pas de DNS fonctionnel, ce qui empêchait le téléchargement des images Docker. Le DNS des conteneurs (`dns: 8.8.8.8` dans le compose) résout le problème à l’intérieur des conteneurs, mais pas pour le pull d’images par Podman machine.
|
||||
|
||||
### Solution
|
||||
|
||||
L’agent configure automatiquement le DNS des machines Podman en cours d’exécution au démarrage :
|
||||
- Détection de Podman sur Windows/macOS.
|
||||
- Liste des machines Podman (`podman machine list --format json`).
|
||||
- Pour chaque machine `running`, exécution de :
|
||||
```bash
|
||||
podman machine ssh <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 l’agent :
|
||||
- Après un **Arrêter** lancé depuis le dashboard, l’instance restait affichée comme elle l’était avant, ou disparaissait avec perte des données.
|
||||
- Après une **Suppression**, l’instance n’était pas retirée de la liste.
|
||||
|
||||
### Causes racines
|
||||
|
||||
1. **Action `stop` du dashboard envoyée comme `delete` à l’agent** (`server/app/api/instances/route.ts`).
|
||||
L’agent exécutait alors `docker compose down -v` + suppression des fichiers, c’est-à-dire une suppression réelle, tout en marquant l’instance `stopped` en base.
|
||||
2. **L’agent ne confirmait pas les actions serveur** (`agent/websocket.go`).
|
||||
Les handlers `stop` et `delete` ne renvoyaient jamais les messages `instance_stopped` / `instance_deleted` au serveur ; seule l’UI locale le faisait.
|
||||
3. **Le handler `stop` de l’agent utilisait `dockerComposeDown`** au lieu de `dockerComposeStop`, ne respectant pas le cycle de vie documenté (arrêt = conteneurs et volumes conservés).
|
||||
|
||||
### Corrections apportées
|
||||
|
||||
| Fichier | Changement |
|
||||
|---------|------------|
|
||||
| `server/app/api/instances/route.ts` | L’action dashboard `stop` envoie désormais `action: "stop"` à l’agent (et non plus `"delete"`). |
|
||||
| `agent/websocket.go` | Le cas `stop` utilise `dockerComposeStop`, puis envoie `instance_stopped` au serveur. Le cas `delete` envoie `instance_deleted` au serveur. |
|
||||
| `server/lib/websocket.ts` | Utilisation de `updateMany`/`deleteMany` pour ignorer silencieusement les messages d’instances déjà absentes/supprimées (évite les erreurs Prisma en double suppression). |
|
||||
|
||||
### Résultat
|
||||
|
||||
Le dashboard reflète désormais l’état réel après une action serveur-initiée, dès le rechargement de la page. Le cycle de vie respecte la sémantique attendue :
|
||||
- **Arrêter** : `docker compose stop` → statut `stopped`.
|
||||
- **Démarrer** : `docker compose up -d` → statut `running`.
|
||||
- **Redémarrer** : `docker compose down -v` + recréation.
|
||||
- **Supprimer** : `docker compose down -v` + suppression fichiers.
|
||||
|
||||
### Redeploiement effectué le 2026-06-26
|
||||
|
||||
- **Agent rebuildé** en v0.3.5 (`agent/studioE5-agent`, `.exe`, `.zip` et `server/public/` mis à jour).
|
||||
- **Serveur rebuildé et redémarré** (`docker compose up -d --build server`) pour intégrer les corrections TypeScript.
|
||||
- **Page `/dashboard/download` mise à jour** : passage à la version 0.3.5 et ajout des liens Windows (.exe, .zip) et Linux.
|
||||
- **Corrections défensives agent** après signalement d’arrêt brutal lors d’actions dashboard :
|
||||
- `sendMessage` exécuté de manière asynchrone (`go`) dans les handlers `stop`, `delete`, `stop_vpn` et cleanup, pour ne pas bloquer la boucle de lecture WebSocket.
|
||||
- Ajout d’un `recover` dans `handleMessage` pour capturer d’éventuels panics sans tuer l’agent.
|
||||
- Correction du cleanup `main.go` : modification de `inst[id].Status` (et non de la copie locale `info`).
|
||||
- **Agent de test Linux relancé** (PID dans `/tmp/studioe5-test-clienta/agent.pid`).
|
||||
- **Agents clients** : il faut redémarrer l’agent sur chaque poste, ou télécharger à nouveau le binaire v0.3.5 depuis le dashboard pour Windows.
|
||||
|
||||
## 🛠️ Commandes utiles pour reprendre
|
||||
|
||||
### Voir l’agent de test
|
||||
@@ -166,11 +426,11 @@ L’agent est servi par Caddy depuis le dossier `agent/` monté dans le conteneu
|
||||
|
||||
### Binaires disponibles
|
||||
|
||||
- **Windows (archive complète)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.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`.
|
||||
- **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 d’avoir installé Tailscale Windows séparément ou d’avoir les binaires dans `tailscale-bin/windows/`.
|
||||
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.0`
|
||||
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.10`
|
||||
|
||||
### Builder / préparer les binaires
|
||||
|
||||
@@ -184,13 +444,13 @@ cd /opt/studioe5-client-a/agent
|
||||
./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 d’activation zéro-config (modèle commercialisable)
|
||||
|
||||
L’élève/employé n’a **aucune configuration technique** à saisir :
|
||||
|
||||
1. **Télécharger** l’agent Windows (`studioE5-agent-v0.3.0-windows.zip`).
|
||||
1. **Télécharger** l’agent Windows (`studioE5-agent-v0.3.10-windows.zip`).
|
||||
2. **Extraire** et **lancer** `studioE5-agent.exe`.
|
||||
3. **Entrer le code d’activation** à 6 caractères fourni par l’établissement (affiché dans l’UI locale `http://localhost:7070`).
|
||||
4. L’agent contacte le serveur, le serveur vérifie le code et renvoie **automatiquement** :
|
||||
@@ -221,17 +481,416 @@ Lancement :
|
||||
.\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data
|
||||
```
|
||||
|
||||
## 🔒 Durcissement du code d’activation
|
||||
|
||||
### 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 d’activation 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 d’instance ;
|
||||
- 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 l’utilisateur `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 d’authentification par nœud
|
||||
|
||||
- Le modèle `Node` dispose d’un champ `token` unique.
|
||||
- L’agent envoie son token dans l’en-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 l’activation, le serveur génère un token aléatoire (32 octets hex) et le renvoie dans le message `activated` ; l’agent 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 l’envoie via `set_token`.
|
||||
|
||||
### Endpoint `/api/internal/send-to-node`
|
||||
|
||||
- Protégé par la variable d’environnement `INTERNAL_API_KEY`.
|
||||
- Requiert l’en-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 l’en-tête `Authorization: Bearer <INTERNAL_API_KEY>`.
|
||||
- Le resolver (`resolver:2020`) ne l’utilise 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
|
||||
|
||||
À l’activation zero-config, le serveur génère désormais une **clé pré-auth unique et à usage unique** pour chaque agent, au lieu d’envoyer la clé réutilisable `HEADSCALE_AUTH_KEY`.
|
||||
|
||||
Avantages :
|
||||
- une clé compromise ne permet pas d’enregistrer d’autres nœuds ;
|
||||
- traçabilité directe entre une activation et une clé Headscale ;
|
||||
- expiration courte (15 min) ;
|
||||
- la clé n’est **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 l’utilisateur `studioe5`. Fallback sur `HEADSCALE_AUTH_KEY` si `HEADSCALE_API_KEY` n’est pas configurée. |
|
||||
| `agent/websocket.go` | La clé reçue est utilisée immédiatement mais **n’est 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 l’ajouter 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 l’ancienne 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 l’application 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 l’OS des VPS et des postes agents.
|
||||
- Mécanisme de mise à jour automatique ou notification de l’agent.
|
||||
|
||||
### Logs d’audit
|
||||
|
||||
- Tracer la création / suppression d’instances.
|
||||
- Tracer la génération et l’usage des codes d’activation.
|
||||
- Tracer les actions admin (connexion, création d’élève, démarrage/arrêt VPN).
|
||||
- Conservation et consultation des logs d’audit.
|
||||
|
||||
### Backups et reprise d’activité
|
||||
|
||||
- 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 l’agent
|
||||
|
||||
- Vérifier l’intégrité des binaires Tailscale téléchargés (checksum / signature).
|
||||
- Signer l’exécutable Windows `studioE5-agent.exe` pour éviter les alertes Defender.
|
||||
- Fournir un hash SHA256 des archives d’agent.
|
||||
|
||||
### RGPD et données personnelles
|
||||
|
||||
- Justifier la conservation des noms/prénoms des élèves.
|
||||
- Gérer les droits d’accès, la suppression de compte et l’export de données.
|
||||
- Définir la durée de conservation des logs et historiques.
|
||||
|
||||
### Sécurité réseau complémentaire
|
||||
|
||||
- Restreindre l’accès à `/api/internal/send-to-node` par IP source si possible.
|
||||
- Vérifier l’exposition 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 d’instance).
|
||||
- Limitation du nombre d’instances par élève et par établissement.
|
||||
- Protection contre les abus sur la génération de codes d’activation.
|
||||
|
||||
### Tests de sécurité
|
||||
|
||||
- Tests d’intrusion légers (agent → agent, accès aux endpoints internes sans clé, accès à une instance d’un autre élève).
|
||||
- Tests automatisés du flux complet avant chaque release.
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Installateur agent professionnel
|
||||
|
||||
### Objectif
|
||||
|
||||
Créer un package d’installation unique et professionnel par OS, incluant l’agent studioE5, Tailscale et **Podman CLI**, afin de ne plus dépendre d’installations manuelles préalables par l’utilisateur.
|
||||
|
||||
### Choix des outils
|
||||
|
||||
| OS | Outil | Format | Justification |
|
||||
|---|---|---|---|
|
||||
| **Windows** | **Inno Setup** | `.exe` | Gratuit, open source, très répandu, personnalisable, exécution de scripts PowerShell/silencieux. |
|
||||
| **macOS** | **`pkgbuild`** | `.pkg` | Outil natif Apple, gratuit, format professionnel pour la distribution macOS. |
|
||||
| **Linux** | **Script shell** (+ `.deb`/`.rpm` optionnels) | `.sh` | Universel, détecte le package manager, simple à maintenir. |
|
||||
|
||||
### Contenu du package par OS
|
||||
|
||||
- **Windows** (Inno Setup) :
|
||||
- Installer l’agent dans `C:\Program Files\studioE5-agent\`.
|
||||
- Extraire Tailscale dans `C:\Program Files\studioE5-agent\tailscale-bin\windows\`.
|
||||
- Installer Podman CLI via le MSI officiel en mode silencieux.
|
||||
- Exécuter `podman machine init` puis `podman machine start`.
|
||||
- Créer un raccourci de démarrage et/ou un service Windows.
|
||||
|
||||
- **macOS** (`pkgbuild`) :
|
||||
- Installer l’agent dans `/Applications/studioE5-agent/`.
|
||||
- Installer Podman CLI.
|
||||
- Exécuter `podman machine init` puis `podman machine start`.
|
||||
- Optionnellement créer un LaunchAgent pour démarrer l’agent au login.
|
||||
|
||||
- **Linux** (script shell) :
|
||||
- Détecter le package manager (`apt`, `dnf`, `pacman`, etc.).
|
||||
- Installer Podman et Podman Compose.
|
||||
- Copier l’agent dans `/opt/studioe5-agent/`.
|
||||
- Créer le service systemd `studioe5-agent.service`.
|
||||
- Activer et démarrer le service.
|
||||
|
||||
### Adaptations nécessaires dans l’agent
|
||||
|
||||
- Détecter si Podman est utilisé et si une machine est requise (Windows/macOS).
|
||||
- Vérifier au démarrage que la machine Podman est démarrée, et lancer `podman machine start` si besoin.
|
||||
- Gérer proprement l’arrêt de la machine à la fermeture de l’agent (optionnel).
|
||||
|
||||
### Mise à jour de l’agent vs dépendances système
|
||||
|
||||
- **L’agent peut se mettre à jour lui-même** (binaire + fichiers) depuis la v0.3.10.
|
||||
- **Podman / Docker / Tailscale restent gérés par l’installateur** : l’agent vérifie leur présence et alertera l’utilisateur si une dépendance est manquante ou trop ancienne, mais ne les met pas à jour automatiquement (droits élevés, risque de casser les machines Podman, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 📋 Prochaines étapes à faire
|
||||
|
||||
- [x] ~~Attendre la fin du rate limit Let’s Encrypt~~ (levé le 2026-06-23).
|
||||
- [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] ~~Tester le flux complet depuis l’interface web~~ → **OK** via l’API authentifiée (`POST /api/instances`), instance `cmqqgrur20001lw67t2bdgzkg` accessible en HTTPS public.
|
||||
- [ ] **Obtenir un certificat wildcard** pour `*.studioe5.edudeploy.com` (voir étude ci-dessous).
|
||||
- [ ] **Nettoyer les instances/agent de test** une fois le wildcard en place et le push effectué.
|
||||
- [x] ~~Packager les binaires Tailscale pour Windows~~ → **OK**, `download-tailscale-bins.sh` + `studioE5-agent-v0.3.0-windows.zip` prêt.
|
||||
- [ ] **Nettoyer les anciens nodes/volumes Headscale** créés pendant les tests.
|
||||
- [ ] **Documenter la procédure de mise en production** pour le client A (config agent, clés Headscale, ports, etc.).
|
||||
### ✅ Terminé
|
||||
|
||||
- [x] Rate limit Let’s Encrypt levé.
|
||||
- [x] Flux HTTPS public validé (`test-wp-001.studioe5.edudeploy.com`).
|
||||
- [x] Branche `feat/studioe5-vpn-ondemand` créée, commit `124543d`.
|
||||
- [x] **Pousser la branche** vers Gitea : la branche est synchronisée sur `origin/feat/studioe5-vpn-ondemand` (commit `cf8b663`).
|
||||
- [x] Flux complet UI → API → WebSocket → agent → Docker → VPN → Caddy validé.
|
||||
- [x] Packager les binaires Tailscale pour Windows (`studioE5-agent-v0.3.5-windows.zip`).
|
||||
- [x] **Sécurité – authentification du canal serveur → agent** (token par nœud, clé API interne, sessions NextAuth sur les routes API).
|
||||
- [x] **Sécurité – durcissement du code d’activation** (`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 d’instance).
|
||||
- [x] **Agent v0.3.5 – cycle de vie des instances** (`stop`/`start` préservent les volumes, `reset`/`delete` effacent).
|
||||
- [x] **Agent v0.3.5 – cleanup au shutdown** (arrêt propre de Tailscale et des instances, notification serveur).
|
||||
- [x] **Synchronisation dashboard** (messages `instance_stopped` / `instance_deleted` traités côté serveur, et agent renvoyant correctement ces messages après un ordre serveur `stop`/`delete`).
|
||||
- [x] **Template WordPress prêt à l’emploi** (`wordpress-ready-wordpress-latest`) : WordPress en français, titre « Mon site wordpress », compte admin/admin, thème Astra, Spectra actif, Yoast SEO inactif, mises à jour automatiques désactivées, DNS `8.8.8.8`/`1.1.1.1` pour `api.wordpress.org`.
|
||||
- [x] **Nettoyer les instances/agent de test** (2026-06-27) : agent de test arrêté (`vps-8fc665eb`), `tailscaled` associé arrêté, data-dir `/tmp/studioe5-test-clienta` supprimé ; **13 instances de test supprimées de la base PostgreSQL** (`vps-8fc665eb` + `OMEGA-GAMER-60d7f87c`).
|
||||
- [x] **Nettoyer les anciens nodes/volumes Headscale de test** (2026-06-27) : nœuds `edubox`, `prof`, `invalid-*`, anciens `vps-8fc665eb`, anciens `studioe5-resolver` et `test-node-b` supprimés ; volume Docker anonyme orphelin supprimé.
|
||||
- [x] **Centralisation de la version agent** : fichier unique `agent/VERSION`, API `GET /api/agent/version`, dashboard et route `/api/download` alignés.
|
||||
- [x] **Agent v0.3.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 l’UI locale** : version de l’agent, nodeId, version attendue par le serveur, notification de mise à jour.
|
||||
- [x] **Agent v0.3.10 – mise à jour automatique de l’agent** : détection de nouvelle version, téléchargement, remplacement du binaire via script helper et redémarrage.
|
||||
- [x] **Agent v0.3.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 d’installation unique incluant l’agent studioE5, Tailscale et **Podman CLI**. Voir la section « 🖥️ Installateur agent professionnel » ci-dessous pour le détail des outils (Inno Setup, pkgbuild, script shell) et du contenu par OS.
|
||||
- [ ] **Template WordPress prêt à l’emploi (usage examen/classe)** :
|
||||
- Forcer le DNS (`8.8.8.8`, `1.1.1.1`) dans le `docker-compose.yml` pour permettre l’accès à la bibliothèque de plugins/mises à jour depuis le conteneur.
|
||||
- Pré-installer WordPress en **français** via WP-CLI avec le titre **“Mon site wordpress”** et le compte **admin / admin**.
|
||||
- Désactiver les **mises à jour automatiques** (core, plugins, thèmes) pour figer l’environnement.
|
||||
- Installer et activer le **thème Astra**.
|
||||
- Installer **Yoast SEO** (inactif) et **Spectra** (actif).
|
||||
- [ ] **Barre de progression basée sur les logs d’installation** : enrichir la barre de progression agent/dashboard en lisant les logs Podman/Docker (`podman logs -f`) pendant le premier démarrage d’une instance. Définir des patterns de logs par template (ex. `Installation successful` pour PrestaShop) et relayer les étapes réelles au dashboard via WebSocket.
|
||||
- [ ] **Étude – interface de déploiement multi-clients** : outil de provisionning d’un nouveau serveur client + agent générique (option A : URL serveur déterminée à l’activation).
|
||||
- [ ] **Sécurité – gestion et rotation des secrets** (`INTERNAL_API_KEY`, `HEADSCALE_API_KEY`, `NEXTAUTH_SECRET`, `DATABASE_URL`).
|
||||
- [ ] **Sécurité – durcissement des conteneurs** (`cap_add`, utilisateurs non-root, scans CVE).
|
||||
- [ ] **Sécurité – mises à jour de sécurité** (Tailscale, images Docker, OS agents).
|
||||
- [ ] **Sécurité – logs d’audit** (instances, codes d’activation, actions admin).
|
||||
- [ ] **Sécurité – backups et reprise d’activité** (DB, state Headscale, states agents).
|
||||
- [ ] **Sécurité – intégrité et signature de l’agent** (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 l’agent (v0.3.5)
|
||||
|
||||
Les logs de l’agent sont redirigés vers `<data-dir>/agent.log` et diffusés en temps réel dans l’UI locale (`http://localhost:7070`) via le WebSocket existant.
|
||||
|
||||
### ✅ Barre de progression (v0.3.5)
|
||||
|
||||
L’agent envoie des messages `progress` au frontend pendant le démarrage d’une instance :
|
||||
|
||||
| Étape | Poids |
|
||||
|-------|-------|
|
||||
| Préparation de l’application | 10 % |
|
||||
| Configuration de l’application | 30 % |
|
||||
| Application en cours de démarrage | 60 % |
|
||||
| Connexion sécurisée active | 80 % |
|
||||
| Finalisation de l’installation | 90 % |
|
||||
| Application prête | 100 % |
|
||||
|
||||
### Boutons d’action par instance (v0.3.5)
|
||||
|
||||
L’UI 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. L’URL du serveur cible est déterminée au moment de l’activation, pas hardcodée dans l’agent.
|
||||
- Pistes : code d’activation résolu par un hub central, code structuré contenant l’identifiant du serveur, ou champ URL serveur saisi dans l’UI 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`). L’agent doit pouvoir déterminer l’URL serveur cible à l’activation (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 d’images** | ⏳ À 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 l’agent 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. L’architecture actuelle (un serveur par client + agent zero-config) est déjà compatible avec cette vision, mais le code n’est pas encore industrialisé pour un déploiement à grande échelle.
|
||||
|
||||
## 🔒 Étude certificat wildcard `*.studioe5.edudeploy.com`
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
0.3.18
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
VERSION="0.3.0"
|
||||
VERSION="$(cat "$(dirname "$0")/VERSION")"
|
||||
APP_NAME="studioE5"
|
||||
BIN_NAME="studioE5-agent"
|
||||
LDFLAGS="-X main.version=${VERSION}"
|
||||
|
||||
@@ -16,6 +16,16 @@ type AgentConfig struct {
|
||||
HeadscaleAuthKey string `json:"headscale_auth_key"`
|
||||
NodeID string `json:"node_id"`
|
||||
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"
|
||||
|
||||
+45
-15
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -39,31 +40,64 @@ func writeCompose(dataDir, instanceID, compose string, port int) error {
|
||||
return os.WriteFile(f, []byte(compose), 0644)
|
||||
}
|
||||
|
||||
func writeInitScript(dataDir, instanceID, script string) error {
|
||||
dir := instanceDir(dataDir, instanceID)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f := filepath.Join(dir, "wp-init.sh")
|
||||
return os.WriteFile(f, []byte(script), 0755)
|
||||
}
|
||||
|
||||
func configureEngineCmd(cmd *exec.Cmd, dir string) {
|
||||
hideWindow(cmd)
|
||||
logPath := filepath.Join(dir, "compose.log")
|
||||
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 {
|
||||
dir := instanceDir(dataDir, instanceID)
|
||||
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "up", "-d")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
configureEngineCmd(cmd, dir)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func dockerComposeDown(dataDir, instanceID string) error {
|
||||
dir := instanceDir(dataDir, instanceID)
|
||||
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
configureEngineCmd(cmd, dir)
|
||||
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()
|
||||
}
|
||||
|
||||
func dockerComposeRm(dataDir, instanceID string) error {
|
||||
dir := instanceDir(dataDir, instanceID)
|
||||
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
configureEngineCmd(cmd, dir)
|
||||
if err := cmd.Run(); err != nil {
|
||||
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.
|
||||
@@ -104,15 +138,13 @@ fi
|
||||
defer os.Remove(scriptPath)
|
||||
|
||||
cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/update-wp-urls.sh")
|
||||
cpCmd.Stdout = os.Stdout
|
||||
cpCmd.Stderr = os.Stderr
|
||||
configureEngineCmd(cpCmd, dir)
|
||||
if err := cpCmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/update-wp-urls.sh")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
configureEngineCmd(execCmd, dir)
|
||||
return execCmd.Run()
|
||||
}
|
||||
|
||||
@@ -141,14 +173,12 @@ fi
|
||||
defer os.Remove(scriptPath)
|
||||
|
||||
cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/strip-wp-urls.sh")
|
||||
cpCmd.Stdout = os.Stdout
|
||||
cpCmd.Stderr = os.Stderr
|
||||
configureEngineCmd(cpCmd, dir)
|
||||
if err := cpCmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/strip-wp-urls.sh")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
configureEngineCmd(execCmd, dir)
|
||||
return execCmd.Run()
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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` n’est plus utilisée : l’installation 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 d’accueil).
|
||||
- Amélioration de l’interface : 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 l’agent.
|
||||
|
||||
### 🔄 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 l’exé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**.
|
||||
- L’installation de WSL2 nécessite un **redémarrage** de l’ordinateur après l’activation des fonctionnalités Windows.
|
||||
- L’installation 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`.
|
||||
- L’image 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 l’info utile est affichée
|
||||
// (par exemple si aucune distribution n’est 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# StudioE5 Setup Wizard
|
||||
|
||||
Assistant d’installation graphique Windows pour studioE5 Agent.
|
||||
|
||||
## Rôle
|
||||
|
||||
Ce wizard guide l’utilisateur 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
|
||||
```
|
||||
|
||||
L’exé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 l’agent (`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 l’image 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**.
|
||||
- L’installation de WSL2 nécessite un **redémarrage** de l’ordinateur. Le wizard s’enregistre dans `RunOnce` pour se relancer automatiquement.
|
||||
- Le wizard configure WSL2 avec **8 Go de RAM et 4 CPU** via le fichier `.wslconfig` de l’utilisateur.
|
||||
- Le MSI Podman doit correspondre à l’architecture `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` n’est plus utilisée : l’installation 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 d’accueil).
|
||||
- Amélioration de l’interface : 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 l’agent.
|
||||
|
||||
### 🔄 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 l’exé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**.
|
||||
- L’installation de WSL2 nécessite un **redémarrage** de l’ordinateur après l’activation des fonctionnalités Windows.
|
||||
- L’installation 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`.
|
||||
- L’image 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>
|
||||
@@ -0,0 +1 @@
|
||||
0.1.1
|
||||
@@ -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 l’activation 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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -88,6 +89,7 @@ func getInstanceStatus(dataDir, instanceID string) string {
|
||||
|
||||
// Try modern JSON format first
|
||||
cmd := exec.Command(engine, "compose", "-f", composeFile, "ps", "--format", "json")
|
||||
hideWindow(cmd)
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
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
|
||||
cmd = exec.Command(engine, "compose", "-f", composeFile, "ps", "-q")
|
||||
hideWindow(cmd)
|
||||
out, err = cmd.Output()
|
||||
if err != nil {
|
||||
return "error"
|
||||
@@ -128,3 +131,34 @@ func getInstanceStatus(dataDir, instanceID string) string {
|
||||
}
|
||||
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
@@ -2,20 +2,21 @@ package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// version is injected at build time via -ldflags "-X main.version=X.Y.Z"
|
||||
var version = "dev"
|
||||
|
||||
const (
|
||||
AGENT_VERSION = "0.3.0"
|
||||
APP_NAME = "studioE5"
|
||||
)
|
||||
const APP_NAME = "studioE5"
|
||||
|
||||
var (
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot load config: %v", err)
|
||||
@@ -51,18 +60,71 @@ func main() {
|
||||
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 {
|
||||
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{})
|
||||
|
||||
// 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 {
|
||||
log.Printf("[%s Agent] running in console mode (no tray)", APP_NAME)
|
||||
<-shutdownCh
|
||||
cleanupWg.Wait()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -75,11 +137,16 @@ func main() {
|
||||
}()
|
||||
|
||||
<-shutdownCh
|
||||
cleanupWg.Wait()
|
||||
}
|
||||
|
||||
func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
|
||||
tsDir := filepath.Join(dataDir, "tailscale")
|
||||
ip, err := startTailscale(tsDir, nodeID, headscaleURL, authKey)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("PANIC in startTailscaleAndReport: %v", r)
|
||||
}
|
||||
}()
|
||||
ip, err := startTailscale(dataDir, nodeID, headscaleURL, authKey)
|
||||
if err != nil {
|
||||
log.Printf("Tailscale error: %v", err)
|
||||
return
|
||||
@@ -95,4 +162,23 @@ func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
|
||||
log.Printf("Sent tailscale_ip to server: %s", ip)
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -80,7 +80,9 @@ func openBrowser(url string) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+141
-13
@@ -9,6 +9,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -61,40 +62,69 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro
|
||||
if err := os.MkdirAll(tsDataDir, 0700); err != nil {
|
||||
return "", fmt.Errorf("create tailscale dir: %w", err)
|
||||
}
|
||||
// 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")
|
||||
|
||||
log.Printf("Starting tailscaled for node %s", nodeID)
|
||||
log.Printf("Starting tailscaled for node %s (socket=%s)", nodeID, tsSocket)
|
||||
tsCmd = exec.Command(tailscaleBin("tailscaled"),
|
||||
"--state="+stateFile,
|
||||
"--socket="+tsSocket,
|
||||
"--tun=userspace-networking",
|
||||
)
|
||||
tsCmd.Stdout = os.Stdout
|
||||
tsCmd.Stderr = os.Stderr
|
||||
hideWindow(tsCmd)
|
||||
// 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 {
|
||||
tsCmd = nil
|
||||
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.
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// 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()
|
||||
|
||||
upCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"),
|
||||
upArgs := []string{
|
||||
"--socket=" + tsSocket,
|
||||
"up",
|
||||
"--authkey="+authKey,
|
||||
"--login-server=" + headscaleURL,
|
||||
"--hostname=" + nodeID,
|
||||
"--accept-dns=false",
|
||||
"--operator=root",
|
||||
)
|
||||
upCmd.Stdout = os.Stdout
|
||||
upCmd.Stderr = os.Stderr
|
||||
}
|
||||
// The auth key is omitted on reconnect: Tailscale reuses the existing state.
|
||||
if authKey != "" {
|
||||
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 {
|
||||
_ = tsCmd.Process.Kill()
|
||||
_ = tsCmd.Wait()
|
||||
@@ -104,10 +134,12 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro
|
||||
|
||||
// Wait for an IP address.
|
||||
for {
|
||||
out, err := exec.CommandContext(ctx, tailscaleBin("tailscale"),
|
||||
statusCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"),
|
||||
"--socket="+tsSocket,
|
||||
"status", "--json",
|
||||
).Output()
|
||||
)
|
||||
hideWindow(statusCmd)
|
||||
out, err := statusCmd.Output()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -151,12 +183,17 @@ func stopTailscaleLocked() {
|
||||
return
|
||||
}
|
||||
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.Wait()
|
||||
tsCmd = nil
|
||||
tsIP = ""
|
||||
if tsDataDir != "" {
|
||||
_ = os.Remove(filepath.Join(tsDataDir, "tailscaled.pid"))
|
||||
}
|
||||
log.Printf("Tailscale stopped")
|
||||
}
|
||||
|
||||
@@ -176,4 +213,95 @@ func getTailscaleIP() string {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -8,6 +8,10 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
@@ -17,6 +21,25 @@ var uiHTML string
|
||||
|
||||
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) {
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
@@ -32,8 +55,22 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Do not expose the auth key in plain GET unless requested; for local UI it is fine.
|
||||
json.NewEncoder(w).Encode(cfg)
|
||||
// Expose a merged view with the agent version for the UI.
|
||||
serverVersion := getServerAgentVersion()
|
||||
updateAvailable := serverVersion != "" && serverVersion != version
|
||||
response := map[string]interface{}{
|
||||
"server": cfg.Server,
|
||||
"headscale_url": cfg.HeadscaleURL,
|
||||
"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:
|
||||
var cfg AgentConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||
@@ -61,6 +98,7 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
go func() {
|
||||
cmd := exec.Command(os.Args[0], os.Args[1:]...)
|
||||
hideWindow(cmd)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
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) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
@@ -79,23 +145,33 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
uiConnectionsMu.Lock()
|
||||
uiConnections[conn] = true
|
||||
uiConnectionsMu.Unlock()
|
||||
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
|
||||
notifierID := registerUINotifier(func(msg map[string]interface{}) {
|
||||
log.Printf("UI notifier forwarding to browser: %+v", msg)
|
||||
if err := conn.WriteJSON(msg); err != nil {
|
||||
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 {
|
||||
var msg map[string]interface{}
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
log.Printf("UI client disconnected: %v", err)
|
||||
log.Printf("UI client read error: %v", err)
|
||||
break
|
||||
}
|
||||
action, _ := msg["action"].(string)
|
||||
@@ -119,6 +195,42 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
||||
}
|
||||
case "instances":
|
||||
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
|
||||
}
|
||||
|
||||
var list []map[string]interface{}
|
||||
list := []map[string]interface{}{}
|
||||
for _, inst := range instances {
|
||||
status := getInstanceStatus(dataDir, inst.ID)
|
||||
if status != inst.Status {
|
||||
@@ -148,6 +260,7 @@ func listInstances(dataDir string, conn *websocket.Conn) {
|
||||
list = append(list, map[string]interface{}{
|
||||
"id": inst.ID,
|
||||
"templateName": inst.TemplateName,
|
||||
"type": inst.TemplateName,
|
||||
"port": inst.Port,
|
||||
"status": inst.Status,
|
||||
"url": instanceURL(inst),
|
||||
@@ -156,3 +269,242 @@ func listInstances(dataDir string, conn *websocket.Conn) {
|
||||
|
||||
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)) != ""
|
||||
}
|
||||
|
||||
+963
-114
File diff suppressed because it is too large
Load Diff
+268
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+378
-72
@@ -3,12 +3,22 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type SyncInstanceInfo struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Port int `json:"port"`
|
||||
ComposeConfig string `json:"composeConfig,omitempty"`
|
||||
InitScript string `json:"initScript,omitempty"`
|
||||
}
|
||||
|
||||
type WSMessage struct {
|
||||
Action string `json:"action"`
|
||||
NodeID string `json:"nodeId,omitempty"`
|
||||
@@ -17,12 +27,19 @@ type WSMessage struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
ComposeConfig string `json:"composeConfig,omitempty"`
|
||||
InitScript string `json:"initScript,omitempty"`
|
||||
StudentId string `json:"studentId,omitempty"`
|
||||
StudentName string `json:"studentName,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
TailscaleIP string `json:"tailscaleIp,omitempty"`
|
||||
HeadscaleURL string `json:"headscaleUrl,omitempty"`
|
||||
HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
ServerVersion string `json:"serverVersion,omitempty"`
|
||||
Instances []InstanceInfo `json:"instances"`
|
||||
ToStart []SyncInstanceInfo `json:"toStart"`
|
||||
ToDelete []string `json:"toDelete"`
|
||||
ToStop []string `json:"toStop"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -51,6 +68,25 @@ func getHeadscaleConfig() (string, string) {
|
||||
return currentHeadscaleURL, currentHeadscaleAuthKey
|
||||
}
|
||||
|
||||
// serverAgentVersion holds the agent version expected by the server. It is used
|
||||
// to notify the user when an update is available.
|
||||
var (
|
||||
serverAgentVersion string
|
||||
serverAgentVersionMu sync.RWMutex
|
||||
)
|
||||
|
||||
func setServerAgentVersion(v string) {
|
||||
serverAgentVersionMu.Lock()
|
||||
serverAgentVersion = v
|
||||
serverAgentVersionMu.Unlock()
|
||||
}
|
||||
|
||||
func getServerAgentVersion() string {
|
||||
serverAgentVersionMu.RLock()
|
||||
defer serverAgentVersionMu.RUnlock()
|
||||
return serverAgentVersion
|
||||
}
|
||||
|
||||
func sendMessage(msg WSMessage) error {
|
||||
mainConnMu.Lock()
|
||||
defer mainConnMu.Unlock()
|
||||
@@ -60,9 +96,33 @@ func sendMessage(msg WSMessage) error {
|
||||
if msg.Action != "heartbeat" {
|
||||
log.Printf("sendMessage: sending %+v", msg)
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("PANIC in sendMessage: %v", r)
|
||||
}
|
||||
}()
|
||||
return mainConn.WriteJSON(msg)
|
||||
}
|
||||
|
||||
// sendSyncMessage sends the local instance list to the server so it can
|
||||
// reconcile any differences (instances created/deleted while offline).
|
||||
func sendSyncMessage(dataDir, nodeID string) {
|
||||
inst, err := loadInstances(dataDir)
|
||||
if err != nil {
|
||||
log.Printf("sendSyncMessage: loadInstances error: %v", err)
|
||||
return
|
||||
}
|
||||
list := make([]InstanceInfo, 0, len(inst))
|
||||
for _, info := range inst {
|
||||
list = append(list, *info)
|
||||
}
|
||||
if err := sendMessage(WSMessage{Action: "sync", NodeID: nodeID, Instances: list}); err != nil {
|
||||
log.Printf("sendSyncMessage error: %v", err)
|
||||
} else {
|
||||
log.Printf("sendSyncMessage: sent %d local instances", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// UI notifier system: broadcast activation results to all connected UI clients
|
||||
type uiNotifier func(msg map[string]interface{})
|
||||
|
||||
@@ -99,22 +159,134 @@ func notifyUI(msg map[string]interface{}) {
|
||||
|
||||
log.Printf("notifyUI: broadcasting to %d UI clients", len(notifiers))
|
||||
for _, fn := range notifiers {
|
||||
go fn(msg)
|
||||
go func(notify uiNotifier) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("PANIC in notifyUI goroutine: %v", r)
|
||||
}
|
||||
}()
|
||||
notify(msg)
|
||||
}(fn)
|
||||
}
|
||||
}
|
||||
|
||||
func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey string) {
|
||||
setHeadscaleConfig(headscaleURL, headscaleAuthKey)
|
||||
// directDialer returns a websocket.Dialer that never uses a proxy.
|
||||
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 {
|
||||
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 {
|
||||
log.Printf("WS connect error: %v, retrying in 5s...", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("WS connected to %s", serverAddr)
|
||||
log.Printf("WS connected to %s (token=%v, proxy=%v)", serverAddr, token != "", IsProxyActive())
|
||||
|
||||
mainConnMu.Lock()
|
||||
mainConn = conn
|
||||
@@ -136,9 +308,11 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
|
||||
log.Println("Waiting for activation...")
|
||||
} else {
|
||||
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()
|
||||
if hsURL != "" && hsKey != "" {
|
||||
if hsURL != "" {
|
||||
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) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("PANIC in handleMessage (action=%s): %v", msg.Action, r)
|
||||
}
|
||||
}()
|
||||
|
||||
switch msg.Action {
|
||||
case "set_token":
|
||||
if msg.Token != "" {
|
||||
if err := saveNodeToken(dataDir, msg.Token); err != nil {
|
||||
log.Printf("saveNodeToken error: %v", err)
|
||||
} else {
|
||||
log.Printf("Node token saved")
|
||||
}
|
||||
}
|
||||
case "activated":
|
||||
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 != "" {
|
||||
act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code}
|
||||
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 != "" {
|
||||
setHeadscaleConfig(msg.HeadscaleURL, msg.HeadscaleAuthKey)
|
||||
cfg, _, err := loadOrCreateConfig(dataDir)
|
||||
@@ -202,11 +399,11 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
||||
log.Printf("loadOrCreateConfig error: %v", err)
|
||||
} else {
|
||||
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 {
|
||||
log.Printf("saveConfig error: %v", err)
|
||||
} 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)
|
||||
@@ -217,21 +414,38 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
||||
"studentName": msg.StudentName,
|
||||
})
|
||||
case "registered":
|
||||
// Server acknowledged our register message; nothing to do.
|
||||
if msg.ServerVersion != "" {
|
||||
setServerAgentVersion(msg.ServerVersion)
|
||||
log.Printf("Server agent version: %s", msg.ServerVersion)
|
||||
}
|
||||
// After registration, send a sync request with our local instances so
|
||||
// the server can reconcile any changes that happened while offline.
|
||||
if act, err := loadActivation(dataDir); err == nil && act.Activated {
|
||||
go sendSyncMessage(dataDir, nodeID)
|
||||
}
|
||||
return
|
||||
case "start_vpn":
|
||||
log.Printf("Server requested VPN start")
|
||||
hsURL, hsKey := getHeadscaleConfig()
|
||||
if hsURL == "" || hsKey == "" {
|
||||
log.Printf("Cannot start VPN: headscale config missing")
|
||||
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"})
|
||||
go sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"})
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("PANIC in start_vpn goroutine: %v", r)
|
||||
}
|
||||
}()
|
||||
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
|
||||
if err != nil {
|
||||
log.Printf("start_vpn error: %v", err)
|
||||
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()})
|
||||
broadcastUI(map[string]interface{}{
|
||||
"action": "status",
|
||||
"status": buildUIStatus(dataDir),
|
||||
})
|
||||
return
|
||||
}
|
||||
for {
|
||||
@@ -243,11 +457,22 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
||||
log.Printf("Sent tailscale_ip to server: %s", ip)
|
||||
break
|
||||
}
|
||||
broadcastUI(map[string]interface{}{
|
||||
"action": "status",
|
||||
"status": buildUIStatus(dataDir),
|
||||
})
|
||||
}()
|
||||
case "stop_vpn":
|
||||
log.Printf("Server requested VPN stop")
|
||||
stopTailscale()
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("PANIC in stop_vpn goroutine: %v", r)
|
||||
}
|
||||
}()
|
||||
sendMessage(WSMessage{Action: "vpn_stopped", NodeID: nodeID})
|
||||
}()
|
||||
case "activation_failed":
|
||||
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
|
||||
notifyUI(map[string]interface{}{
|
||||
@@ -256,93 +481,166 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
||||
})
|
||||
case "start":
|
||||
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() {
|
||||
// Give the container a moment to be ready before touching wp-config.php
|
||||
time.Sleep(2 * time.Second)
|
||||
if err := stripWordPressHardcodedURLs(dataDir, msg.InstanceID); err != nil {
|
||||
log.Printf("stripWordPressHardcodedURLs error: %v", err)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("PANIC in start goroutine instance=%s: %v", msg.InstanceID, r)
|
||||
}
|
||||
}()
|
||||
// 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"})
|
||||
handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.InitScript, msg.Port)
|
||||
}()
|
||||
case "stop":
|
||||
log.Printf("Stop instance %s", msg.InstanceID)
|
||||
if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil {
|
||||
log.Printf("dockerComposeDown error: %v", err)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("PANIC in stop goroutine instance=%s: %v", msg.InstanceID, r)
|
||||
}
|
||||
}()
|
||||
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
|
||||
removeTailscaleServe(inst[msg.InstanceID].Port)
|
||||
}
|
||||
if err := dockerComposeStop(dataDir, msg.InstanceID); err != nil {
|
||||
log.Printf("dockerComposeStop error: %v", err)
|
||||
}
|
||||
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
|
||||
inst[msg.InstanceID].Status = "stopped"
|
||||
_ = saveInstances(dataDir, inst)
|
||||
}
|
||||
go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: msg.InstanceID})
|
||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||
}()
|
||||
case "delete":
|
||||
log.Printf("Delete instance %s", msg.InstanceID)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("PANIC in delete goroutine instance=%s: %v", msg.InstanceID, r)
|
||||
}
|
||||
}()
|
||||
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
|
||||
removeTailscaleServe(inst[msg.InstanceID].Port)
|
||||
}
|
||||
dockerComposeRm(dataDir, msg.InstanceID)
|
||||
removeInstance(dataDir, msg.InstanceID)
|
||||
go sendMessage(WSMessage{Action: "instance_deleted", InstanceID: msg.InstanceID})
|
||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||
}()
|
||||
case "sync_response":
|
||||
log.Printf("Sync response received: start=%d delete=%d stop=%d", len(msg.ToStart), len(msg.ToDelete), len(msg.ToStop))
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("PANIC in sync_response goroutine: %v", r)
|
||||
}
|
||||
}()
|
||||
for _, id := range msg.ToDelete {
|
||||
handleMessage(mainConn, WSMessage{Action: "delete", InstanceID: id}, dataDir, nodeID)
|
||||
}
|
||||
for _, id := range msg.ToStop {
|
||||
handleMessage(mainConn, WSMessage{Action: "stop", InstanceID: id}, dataDir, nodeID)
|
||||
}
|
||||
for _, info := range msg.ToStart {
|
||||
handleMessage(mainConn, WSMessage{
|
||||
Action: "start",
|
||||
InstanceID: info.ID,
|
||||
Type: info.Type,
|
||||
Port: info.Port,
|
||||
ComposeConfig: info.ComposeConfig,
|
||||
InitScript: info.InitScript,
|
||||
}, dataDir, nodeID)
|
||||
}
|
||||
}()
|
||||
case "reset":
|
||||
log.Printf("Reset instance %s", msg.InstanceID)
|
||||
dockerComposeRm(dataDir, msg.InstanceID)
|
||||
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() {
|
||||
// Give the container a moment to be ready before touching wp-config.php
|
||||
time.Sleep(2 * time.Second)
|
||||
if err := stripWordPressHardcodedURLs(dataDir, msg.InstanceID); err != nil {
|
||||
log.Printf("stripWordPressHardcodedURLs error: %v", err)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("PANIC in reset goroutine instance=%s: %v", msg.InstanceID, r)
|
||||
}
|
||||
}()
|
||||
// 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"})
|
||||
handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.InitScript, msg.Port)
|
||||
}()
|
||||
default:
|
||||
log.Printf("Unknown action: %s", msg.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func handleStartInstance(dataDir, nodeID, instanceID, instanceType, composeConfig, 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) {
|
||||
hsURL, hsKey := getHeadscaleConfig()
|
||||
if hsURL == "" || hsKey == "" {
|
||||
@@ -356,6 +654,10 @@ func ensureTailscale(dataDir, nodeID string, port int) {
|
||||
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
|
||||
if err != nil {
|
||||
log.Printf("ensureTailscale start error: %v", err)
|
||||
broadcastUI(map[string]interface{}{
|
||||
"action": "status",
|
||||
"status": buildUIStatus(dataDir),
|
||||
})
|
||||
return
|
||||
}
|
||||
for {
|
||||
@@ -367,4 +669,8 @@ func ensureTailscale(dataDir, nodeID string, port int) {
|
||||
log.Printf("Sent tailscale_ip to server: %s", ip)
|
||||
break
|
||||
}
|
||||
broadcastUI(map[string]interface{}{
|
||||
"action": "status",
|
||||
"status": buildUIStatus(dataDir),
|
||||
})
|
||||
}
|
||||
|
||||
+4
-1
@@ -24,6 +24,7 @@ services:
|
||||
container_name: studioe5-server
|
||||
volumes:
|
||||
- ./server/public:/app/public:ro
|
||||
- ./agent/VERSION:/app/agent-version:ro
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
@@ -34,6 +35,8 @@ services:
|
||||
MAIN_DOMAIN: ${MAIN_DOMAIN}
|
||||
HEADSCALE_URL: ${HEADSCALE_URL}
|
||||
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
|
||||
HEADSCALE_API_KEY: ${HEADSCALE_API_KEY}
|
||||
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -66,7 +69,7 @@ services:
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
environment:
|
||||
TS_AUTHKEY: ${HEADSCALE_AUTH_KEY}
|
||||
TS_AUTHKEY: ${HEADSCALE_RESOLVER_AUTH_KEY}
|
||||
TS_LOGIN_SERVER: ${HEADSCALE_URL}
|
||||
TS_EXTRA_ARGS: --login-server=${HEADSCALE_URL}
|
||||
TS_STATE_DIR: /var/lib/tailscale
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
# Deployeur studioE5 — Onboarding d’un nouvel établissement
|
||||
|
||||
## Objectif
|
||||
|
||||
Ce document décrit le fonctionnement du **deployeur studioE5**, c’est-à-dire l’application / l’outil qui provisionne et configure un nouvel environnement client (un établissement) prêt à accueillir l’application studioE5.
|
||||
|
||||
L’application studioE5 elle-même (agent, VPN on-demand, resolver, Caddy, Headscale, etc.) est documentée dans `SUIVI_VPN_ONDEMAND.md`. Le deployeur est l’outil 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 à n’importe quel serveur studioE5 via résolution d’URL à l’activation. |
|
||||
|
||||
---
|
||||
|
||||
## 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 d’onboarding par le deployeur (vue d’ensemble)
|
||||
|
||||
```
|
||||
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 d’activation
|
||||
↓
|
||||
Build et mise à disposition de l’agent dédié
|
||||
↓
|
||||
Activation de l’agent par un élève
|
||||
↓
|
||||
Création d’une 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 l’IP du VPS provisionné.
|
||||
- Le deployeur dispose d’un 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 d’unicité 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 l’API du registrar pour créer :
|
||||
|
||||
```dns
|
||||
*.tag.edudeploy.com A <IP_DU_VPS>
|
||||
```
|
||||
|
||||
### Domaine propre
|
||||
|
||||
Le deployeur vérifie que l’enregistrement 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 l’utilisateur 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 d’une 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 l’API 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.
|
||||
|
||||
L’administrateur reçoit un lien d’activation sécurisé.
|
||||
|
||||
---
|
||||
|
||||
## 10. Génération des codes d’activation
|
||||
|
||||
Le deployeur configure le serveur pour permettre la génération de codes d’activation.
|
||||
|
||||
### 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. L’administrateur génère un code pour un élève.
|
||||
2. L’élève saisit le code dans l’agent.
|
||||
3. Le serveur valide et renvoie :
|
||||
- l’identité de l’élève
|
||||
- l’URL Headscale
|
||||
- une clé pré-auth Headscale éphémère
|
||||
4. L’agent démarre automatiquement le VPN.
|
||||
|
||||
---
|
||||
|
||||
## 11. Build et mise à disposition de l’agent
|
||||
|
||||
### Principe
|
||||
|
||||
L’agent 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 l’URL 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 l’agent
|
||||
|
||||
### Activation zéro-config
|
||||
|
||||
1. L’élève télécharge l’agent depuis l’URL de l’établissement.
|
||||
2. Il extrait l’archive et lance `studioE5-agent.exe`.
|
||||
3. Il ouvre `http://localhost:7070`.
|
||||
4. Il saisit le code d’activation à 6 caractères.
|
||||
5. L’agent 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 d’une instance et construction de l’URL (validation)
|
||||
|
||||
Le deployeur ou l’administrateur crée une première instance pour valider le déploiement.
|
||||
|
||||
### Format d’URL
|
||||
|
||||
```
|
||||
<appli>-<initiales><id-court>.<tag>.<domaine>
|
||||
```
|
||||
|
||||
Exemple :
|
||||
|
||||
```
|
||||
wp-jd47.ljf.edudeploy.com
|
||||
```
|
||||
|
||||
Avec :
|
||||
|
||||
- `wp` : type d’application
|
||||
- `jd` : initiales de l’élève
|
||||
- `47` : identifiant court unique
|
||||
- `ljf` : tag de l’établissement
|
||||
- `edudeploy.com` : domaine de base
|
||||
|
||||
### Mapping type d’application → préfixe
|
||||
|
||||
| Application | Préfixe |
|
||||
|-------------|---------|
|
||||
| WordPress | `wp` |
|
||||
| PrestaShop | `ps` |
|
||||
| Moodle | `mdl` |
|
||||
| Nextcloud | `nc` |
|
||||
|
||||
### Protection de l’identité
|
||||
|
||||
- L’URL 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 l’application 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 l’application 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 l’identité de l’élève
|
||||
|
||||
- L’URL 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 d’instance.
|
||||
|
||||
### 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 d’expiration.
|
||||
- Non persistées côté agent.
|
||||
|
||||
---
|
||||
|
||||
## 16. Checklist de validation du deployeur
|
||||
|
||||
À l’issue d’un 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 d’activation peut être généré pour un élève.
|
||||
- [ ] L’agent est buildé et téléchargeable depuis le serveur de l’établissement.
|
||||
- [ ] L’agent s’active 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é d’un 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 à l’activation.
|
||||
|
||||
### Moyen terme
|
||||
|
||||
- **Agent générique** : déterminer l’URL serveur cible à l’activation (code structuré, hub de résolution, ou champ URL).
|
||||
- **Script de provisionning** : installation Docker, déploiement stack, génération secrets, DNS wildcard.
|
||||
- **Registry d’images 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 l’agent 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 l’agent.
|
||||
- Génération automatique de codes d’activation par import CSV.
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -38,6 +38,10 @@ database:
|
||||
sqlite:
|
||||
path: /etc/headscale/db.sqlite
|
||||
|
||||
policy:
|
||||
path: /etc/headscale/acl_policy.hujson
|
||||
mode: file
|
||||
|
||||
log:
|
||||
format: text
|
||||
level: info
|
||||
|
||||
@@ -30,9 +30,9 @@ docker build -t edubox-prestashop:9 .
|
||||
|
||||
```bash
|
||||
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 \
|
||||
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
|
||||
@@ -75,7 +75,7 @@ Le template PrestaShop 9 dans `server/prisma/seed.ts` utilise cette image :
|
||||
|
||||
```yaml
|
||||
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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth, requireRole, getScopedEstablishmentId, forbidden } from "@/lib/api-auth";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await requireAuth();
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const establishmentId = searchParams.get("establishmentId");
|
||||
if (!establishmentId) return NextResponse.json({ error: "Missing establishmentId" }, { status: 400 });
|
||||
const requestedId = searchParams.get("establishmentId");
|
||||
const establishmentId = getScopedEstablishmentId(user, requestedId);
|
||||
if (establishmentId instanceof NextResponse) return establishmentId;
|
||||
|
||||
const where = establishmentId ? { establishmentId } : {};
|
||||
|
||||
const classes = await prisma.class.findMany({
|
||||
where: { establishmentId },
|
||||
where,
|
||||
include: { _count: { select: { students: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
@@ -15,8 +22,19 @@ export async function GET(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 { 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({
|
||||
data: { establishmentId, name, level },
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version";
|
||||
|
||||
const AGENT_VERSION = "0.3.0";
|
||||
const AGENT_BIN_NAME = "studioE5-agent";
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: Request) {
|
||||
const baseUrl = getBaseUrlFromRequest(request);
|
||||
const info = getAgentVersionInfo(baseUrl);
|
||||
return NextResponse.json({
|
||||
version: AGENT_VERSION,
|
||||
windows: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}.exe`,
|
||||
linux: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}`,
|
||||
mac: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}-mac`,
|
||||
version: info.version,
|
||||
windows: info.downloadUrls.windows,
|
||||
linux: info.downloadUrls.linux,
|
||||
mac: info.downloadUrls.mac,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { requireAuth, requireRole } from "@/lib/api-auth";
|
||||
|
||||
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({
|
||||
where,
|
||||
include: { subscription: true, _count: { select: { users: true, classes: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
@@ -11,6 +17,12 @@ export async function GET() {
|
||||
}
|
||||
|
||||
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 { name, slug, adminEmail, adminPassword } = body;
|
||||
|
||||
|
||||
@@ -1,16 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
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) {
|
||||
const user = await requireAuth();
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const nodeId = searchParams.get("nodeId");
|
||||
const establishmentId = searchParams.get("establishmentId");
|
||||
const establishmentIdParam = searchParams.get("establishmentId");
|
||||
|
||||
let where: any = {};
|
||||
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 nodes = await prisma.node.findMany({ where: { studentId: { in: students.map((s) => s.id) } }, select: { id: true } });
|
||||
where.nodeId = { in: nodes.map((n) => n.id) };
|
||||
@@ -39,12 +77,8 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const enriched = instances.map((inst) => {
|
||||
const domain = inst.node.student?.class.establishment?.domain;
|
||||
const publicUrl = domain
|
||||
? `https://${inst.id}.${domain}`
|
||||
: null;
|
||||
const localUrl = inst.node.tailscaleIp
|
||||
? `http://${inst.node.tailscaleIp}:${inst.port}`
|
||||
: null;
|
||||
const publicUrl = domain ? `https://${inst.id}.${domain}` : null;
|
||||
const localUrl = inst.node.tailscaleIp ? `http://${inst.node.tailscaleIp}:${inst.port}` : null;
|
||||
return {
|
||||
...inst,
|
||||
publicUrl,
|
||||
@@ -56,22 +90,32 @@ export async function GET(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 { 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 } });
|
||||
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({
|
||||
where: { id: nodeId },
|
||||
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 publicUrl = domain ? `https://${publicDomain}` : null;
|
||||
const sent = sendToNode(nodeId, {
|
||||
@@ -84,6 +128,13 @@ export async function POST(req: NextRequest) {
|
||||
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
||||
initScript: template.initScript
|
||||
? template.initScript
|
||||
.replace(/{PORT}/g, String(instance.port))
|
||||
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||
.replace(/{PUBLIC_DOMAIN}/g, "localhost")
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!sent) {
|
||||
@@ -94,17 +145,31 @@ export async function POST(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 { 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 (!userCanAccessInstance(user, instance)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const domain = instance.node.student?.class.establishment?.domain;
|
||||
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
||||
const publicUrl = domain ? `https://${publicDomain}` : null;
|
||||
|
||||
if (action === "stop") {
|
||||
sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
|
||||
await prisma.instance.update({ where: { id }, data: { status: "stopped" } });
|
||||
const sent = sendToNode(instance.nodeId, { action: "stop", instanceId: instance.id });
|
||||
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
||||
} else if (action === "start") {
|
||||
const sent = sendToNode(instance.nodeId, {
|
||||
action: "start",
|
||||
@@ -116,6 +181,13 @@ export async function PATCH(req: NextRequest) {
|
||||
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
||||
initScript: instance.template.initScript
|
||||
? instance.template.initScript
|
||||
.replace(/{PORT}/g, String(instance.port))
|
||||
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||
.replace(/{PUBLIC_DOMAIN}/g, "localhost")
|
||||
: undefined,
|
||||
});
|
||||
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
||||
} else if (action === "reset") {
|
||||
@@ -129,18 +201,39 @@ export async function PATCH(req: NextRequest) {
|
||||
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
||||
initScript: instance.template.initScript
|
||||
? instance.template.initScript
|
||||
.replace(/{PORT}/g, String(instance.port))
|
||||
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||
.replace(/{PUBLIC_DOMAIN}/g, "localhost")
|
||||
: undefined,
|
||||
});
|
||||
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
||||
} else {
|
||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
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 id = searchParams.get("id");
|
||||
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 });
|
||||
await prisma.instance.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
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) {
|
||||
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 { nodeId, message } = body;
|
||||
if (!nodeId || !message) {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth, getScopedEstablishmentId, forbidden } from "@/lib/api-auth";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await requireAuth();
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
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 = {};
|
||||
if (establishmentId) {
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
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) {
|
||||
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 subdomain = searchParams.get("subdomain");
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
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;
|
||||
}
|
||||
import { generateUniqueActivationCode } from "@/lib/activation";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
@@ -31,13 +25,15 @@ export async function GET(req: NextRequest) {
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { classId, firstName, lastName, email } = body;
|
||||
const { code, expiresAt } = await generateUniqueActivationCode();
|
||||
const student = await prisma.student.create({
|
||||
data: {
|
||||
classId,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
activationCode: generateCode(),
|
||||
activationCode: code,
|
||||
activationCodeExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(student, { status: 201 });
|
||||
|
||||
@@ -1,25 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
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) {
|
||||
const user = await requireAuth();
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
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({
|
||||
where: {
|
||||
OR: [
|
||||
{ isPublic: true },
|
||||
...(establishmentId ? [{ establishmentId }] : []),
|
||||
],
|
||||
},
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return NextResponse.json(templates);
|
||||
}
|
||||
|
||||
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 { 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({
|
||||
data: { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy },
|
||||
});
|
||||
@@ -27,16 +59,39 @@ export async function POST(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 { 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 });
|
||||
return NextResponse.json(template);
|
||||
}
|
||||
|
||||
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 id = searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||
|
||||
if (!(await canManageTemplate(user, id))) return forbidden();
|
||||
|
||||
await prisma.template.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { requireAuth, requireRole, forbidden } from "@/lib/api-auth";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await requireAuth();
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const establishmentId = searchParams.get("establishmentId");
|
||||
const role = searchParams.get("role");
|
||||
|
||||
if (user.role !== "superadmin") {
|
||||
if (establishmentId && establishmentId !== user.establishmentId) {
|
||||
return forbidden();
|
||||
}
|
||||
}
|
||||
|
||||
const where: any = {};
|
||||
if (establishmentId) where.establishmentId = establishmentId;
|
||||
else if (user.role !== "superadmin") where.establishmentId = user.establishmentId;
|
||||
if (role) where.role = role;
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
@@ -19,23 +30,56 @@ export async function GET(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 { 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: {
|
||||
email,
|
||||
password: await hashPassword(password),
|
||||
role,
|
||||
establishmentId,
|
||||
establishmentId: finalEstablishmentId,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(user, { status: 201 });
|
||||
return NextResponse.json(newUser, { status: 201 });
|
||||
}
|
||||
|
||||
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 id = searchParams.get("id");
|
||||
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 } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -1,23 +1,49 @@
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
|
||||
const AGENT_VERSION = "0.3.0";
|
||||
const AGENT_BIN_NAME = "studioE5-agent";
|
||||
import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Windows</CardTitle>
|
||||
<CardTitle>Windows (.exe)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Windows (64 bits)</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>
|
||||
<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={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'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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -3,17 +3,9 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth-config";
|
||||
import { generateUniqueActivationCode } from "@/lib/activation";
|
||||
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) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) redirect("/login");
|
||||
@@ -54,9 +46,10 @@ export async function generateActivationCodeAction(formData: FormData) {
|
||||
|
||||
if (!student) return;
|
||||
|
||||
const { code, expiresAt } = await generateUniqueActivationCode();
|
||||
await prisma.student.update({
|
||||
where: { id },
|
||||
data: { activationCode: generateCode() },
|
||||
data: { activationCode: code, activationCodeExpiresAt: expiresAt },
|
||||
});
|
||||
|
||||
redirect(`/dashboard/students/${id}`);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth-config";
|
||||
import { generateUniqueActivationCode } from "@/lib/activation";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -17,15 +18,6 @@ const schema = z.object({
|
||||
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) {
|
||||
"use server";
|
||||
const session = await getServerSession(authOptions);
|
||||
@@ -35,13 +27,15 @@ async function createStudent(formData: FormData) {
|
||||
const parsed = schema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsed.success) return;
|
||||
|
||||
const { code, expiresAt } = await generateUniqueActivationCode();
|
||||
await prisma.student.create({
|
||||
data: {
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
email: parsed.data.email,
|
||||
classId: parsed.data.classId,
|
||||
activationCode: generateActivationCode(),
|
||||
activationCode: code,
|
||||
activationCodeExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+279
-20
@@ -1,5 +1,9 @@
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { randomBytes } from "crypto";
|
||||
import type { IncomingMessage } from "http";
|
||||
import { prisma } from "./prisma";
|
||||
import { createEphemeralPreAuthKey, getHeadscaleUserId } from "./headscale";
|
||||
import { getAgentVersion } from "./agent-version";
|
||||
|
||||
interface NodeMessage {
|
||||
action: string;
|
||||
@@ -9,17 +13,77 @@ interface NodeMessage {
|
||||
type?: string;
|
||||
port?: number;
|
||||
composeConfig?: string;
|
||||
initScript?: string;
|
||||
studentName?: string;
|
||||
error?: string;
|
||||
tailscaleIp?: string;
|
||||
token?: string;
|
||||
serverVersion?: string;
|
||||
instances?: Array<{ id: string; status: string; port: number; templateName?: string }>;
|
||||
toStart?: Array<{ id: string; type: string; port: number; composeConfig?: string; initScript?: string }>;
|
||||
toDelete?: string[];
|
||||
toStop?: string[];
|
||||
}
|
||||
|
||||
const nodes = new Map<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) {
|
||||
wss.on("connection", (ws: WebSocket) => {
|
||||
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
|
||||
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) => {
|
||||
try {
|
||||
@@ -27,19 +91,69 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
||||
console.log("[WS] Received:", msg.action, "from", msg.nodeId || nodeId);
|
||||
|
||||
if (msg.action === "register" && msg.nodeId) {
|
||||
nodeId = msg.nodeId;
|
||||
nodes.set(nodeId, ws);
|
||||
await prisma.node.upsert({
|
||||
where: { id: nodeId },
|
||||
update: { status: "online", lastSeen: new Date() },
|
||||
create: { id: nodeId, status: "online", lastSeen: new Date() },
|
||||
const id = msg.nodeId;
|
||||
const existing = await prisma.node.findUnique({ where: { id } });
|
||||
|
||||
if (token) {
|
||||
// Token supplied: it must match the stored token for this node.
|
||||
if (!existing || existing.token !== token) {
|
||||
console.log("[WS] Invalid token for node", id);
|
||||
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: "registered" }));
|
||||
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;
|
||||
}
|
||||
|
||||
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({
|
||||
where: { activationCode: msg.code },
|
||||
});
|
||||
@@ -48,23 +162,97 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
||||
ws.send(JSON.stringify({ action: "activation_failed", error: "Invalid code" }));
|
||||
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({
|
||||
where: { id: nodeId },
|
||||
update: { studentId: student.id, status: "online", lastSeen: new Date() },
|
||||
create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() },
|
||||
where: { id },
|
||||
update: {
|
||||
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({
|
||||
action: "activated",
|
||||
studentId: student.id,
|
||||
studentName: `${student.firstName} ${student.lastName}`,
|
||||
headscaleUrl: process.env.HEADSCALE_URL,
|
||||
headscaleAuthKey: process.env.HEADSCALE_AUTH_KEY,
|
||||
headscaleUrl,
|
||||
headscaleAuthKey,
|
||||
token: newToken,
|
||||
}));
|
||||
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({
|
||||
where: { id: nodeId },
|
||||
update: { lastSeen: new Date() },
|
||||
@@ -73,7 +261,7 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.action === "tailscale_ip" && nodeId && msg.tailscaleIp) {
|
||||
if (msg.action === "tailscale_ip" && msg.tailscaleIp) {
|
||||
await prisma.node.update({
|
||||
where: { id: nodeId },
|
||||
data: { tailscaleIp: msg.tailscaleIp },
|
||||
@@ -82,19 +270,90 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.action === "sync" && msg.instances) {
|
||||
const serverInstances = await prisma.instance.findMany({
|
||||
where: { nodeId },
|
||||
include: { template: true },
|
||||
});
|
||||
|
||||
const localIds = new Set(msg.instances.map((i) => i.id));
|
||||
const serverIds = new Set(serverInstances.map((i) => i.id));
|
||||
|
||||
const toDelete = msg.instances
|
||||
.filter((i) => !serverIds.has(i.id))
|
||||
.map((i) => i.id);
|
||||
|
||||
const toStop = msg.instances
|
||||
.filter((i) => {
|
||||
const server = serverInstances.find((s) => s.id === i.id);
|
||||
return server && server.status === "stopped" && i.status === "running";
|
||||
})
|
||||
.map((i) => i.id);
|
||||
|
||||
const toStart = serverInstances
|
||||
.filter((s) => !localIds.has(s.id))
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
type: s.template.type,
|
||||
port: s.port,
|
||||
composeConfig: s.template.composeConfig,
|
||||
initScript: s.template.initScript ?? undefined,
|
||||
}));
|
||||
|
||||
console.log(
|
||||
"[WS] Sync for",
|
||||
nodeId,
|
||||
"- toStart:",
|
||||
toStart.length,
|
||||
"toDelete:",
|
||||
toDelete.length,
|
||||
"toStop:",
|
||||
toStop.length
|
||||
);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
action: "sync_response",
|
||||
toStart,
|
||||
toDelete,
|
||||
toStop,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.action === "instance_started" && msg.instanceId) {
|
||||
await prisma.instance.update({
|
||||
const { count } = await prisma.instance.updateMany({
|
||||
where: { id: msg.instanceId },
|
||||
data: { status: "running" },
|
||||
});
|
||||
if (count) console.log("[WS] Instance started:", msg.instanceId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.action === "instance_stopped" && msg.instanceId) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (msg.action === "instance_error" && msg.instanceId) {
|
||||
await prisma.instance.update({
|
||||
const { count } = await prisma.instance.updateMany({
|
||||
where: { id: msg.instanceId },
|
||||
data: { status: "error" },
|
||||
});
|
||||
if (count) console.log("[WS] Instance error:", msg.instanceId);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Template" ADD COLUMN "initScript" TEXT;
|
||||
@@ -57,6 +57,7 @@ model Student {
|
||||
lastName String
|
||||
email String
|
||||
activationCode String? @unique
|
||||
activationCodeExpiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
nodes Node[]
|
||||
}
|
||||
@@ -65,6 +66,7 @@ model Node {
|
||||
id String @id
|
||||
studentId String?
|
||||
student Student? @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||
token String? @unique
|
||||
tailscaleIp String?
|
||||
status String @default("offline")
|
||||
lastSeen DateTime?
|
||||
@@ -89,6 +91,7 @@ model Template {
|
||||
type String
|
||||
dockerImage String
|
||||
composeConfig String
|
||||
initScript String?
|
||||
isPublic Boolean @default(true)
|
||||
establishmentId String?
|
||||
establishment Establishment? @relation(fields: [establishmentId], references: [id], onDelete: Cascade)
|
||||
|
||||
+67
-3
@@ -1,5 +1,7 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -19,6 +21,11 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
const wpReadyInitScript = fs.readFileSync(
|
||||
path.join(__dirname, "../templates/wordpress-ready/wp-init.sh"),
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const templates = [
|
||||
{
|
||||
name: "WordPress latest vierge",
|
||||
@@ -50,10 +57,34 @@ async function main() {
|
||||
dbPassword: "wordpress",
|
||||
dbRootPassword: "rootpassword",
|
||||
},
|
||||
{
|
||||
name: "WordPress latest prêt à l'emploi",
|
||||
type: "wordpress-ready",
|
||||
dockerImage: "wordpress:latest",
|
||||
dbImage: "mariadb:10.11",
|
||||
dbName: "wordpress",
|
||||
dbUser: "wordpress",
|
||||
dbPassword: "wordpress",
|
||||
dbRootPassword: "rootpassword",
|
||||
ready: true,
|
||||
initScript: wpReadyInitScript,
|
||||
},
|
||||
{
|
||||
name: "WordPress 7.0.0 prêt à l'emploi",
|
||||
type: "wordpress-ready",
|
||||
dockerImage: "wordpress:7.0.0-php8.3",
|
||||
dbImage: "mariadb:10.11",
|
||||
dbName: "wordpress",
|
||||
dbUser: "wordpress",
|
||||
dbPassword: "wordpress",
|
||||
dbRootPassword: "rootpassword",
|
||||
ready: true,
|
||||
initScript: wpReadyInitScript,
|
||||
},
|
||||
{
|
||||
name: "PrestaShop 9 vierge (edubox)",
|
||||
type: "prestashop",
|
||||
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",
|
||||
dbName: "prestashop",
|
||||
dbUser: "prestashop",
|
||||
@@ -66,6 +97,7 @@ async function main() {
|
||||
const dbHost = "db";
|
||||
const dbPort = "3306";
|
||||
const isPrestaShop = t.type === "prestashop";
|
||||
const isWordPressReady = (t as any).ready === true;
|
||||
|
||||
const appEnv = isPrestaShop
|
||||
? ` DB_SERVER: ${dbHost}
|
||||
@@ -93,6 +125,12 @@ async function main() {
|
||||
WORDPRESS_DB_PREFIX: wp_
|
||||
# No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header`;
|
||||
|
||||
const appDNS = isWordPressReady
|
||||
? ` dns:
|
||||
- 8.8.8.8
|
||||
- 1.1.1.1`
|
||||
: "";
|
||||
|
||||
const appVolumes = isPrestaShop
|
||||
? ` volumes:
|
||||
- app_data:/var/www/html`
|
||||
@@ -100,6 +138,28 @@ async function main() {
|
||||
- app_data:/var/www/html
|
||||
- {MU_PLUGINS_DIR}/edubox-public-url.php:/var/www/html/wp-content/mu-plugins/edubox-public-url.php:ro`;
|
||||
|
||||
const wpInitService = isWordPressReady
|
||||
? ` wp-init:
|
||||
image: wordpress:cli
|
||||
user: "0:0"
|
||||
environment:
|
||||
WORDPRESS_DB_HOST: ${dbHost}:${dbPort}
|
||||
WORDPRESS_DB_NAME: ${t.dbName}
|
||||
WORDPRESS_DB_USER: ${t.dbUser}
|
||||
WORDPRESS_DB_PASSWORD: ${t.dbPassword}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
app:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- app_data:/var/www/html
|
||||
- ./wp-init.sh:/wp-init.sh:ro
|
||||
restart: "no"
|
||||
entrypoint: ["/bin/sh", "/wp-init.sh"]
|
||||
`
|
||||
: "";
|
||||
|
||||
const composeConfig = `services:
|
||||
db:
|
||||
image: ${t.dbImage}
|
||||
@@ -123,24 +183,28 @@ async function main() {
|
||||
environment:
|
||||
${appEnv}
|
||||
INSTANCE_ID: {INSTANCE_ID}
|
||||
${appDNS}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
${appVolumes}
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
${wpInitService}volumes:
|
||||
db_data:
|
||||
app_data:
|
||||
`;
|
||||
const initScript = isWordPressReady ? wpReadyInitScript : null;
|
||||
|
||||
await prisma.template.upsert({
|
||||
where: { id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}` },
|
||||
update: { composeConfig },
|
||||
update: { composeConfig, initScript },
|
||||
create: {
|
||||
id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}`,
|
||||
name: t.name,
|
||||
type: t.type,
|
||||
dockerImage: t.dockerImage,
|
||||
composeConfig,
|
||||
initScript,
|
||||
isPublic: true,
|
||||
createdBy: "system",
|
||||
},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user