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_EMAIL=admin@edudeploy.fr
|
||||||
SUPERADMIN_PASSWORD=CHANGE_ME
|
SUPERADMIN_PASSWORD=CHANGE_ME
|
||||||
HEADSCALE_URL=http://headscale:8080
|
HEADSCALE_URL=http://headscale:8080
|
||||||
|
# Legacy reusable pre-auth key (kept for manual/debug setups).
|
||||||
HEADSCALE_AUTH_KEY=CHANGE_ME
|
HEADSCALE_AUTH_KEY=CHANGE_ME
|
||||||
|
# Headscale API key used by the server to generate ephemeral pre-auth keys.
|
||||||
|
HEADSCALE_API_KEY=CHANGE_ME
|
||||||
|
HEADSCALE_RESOLVER_AUTH_KEY=CHANGE_ME
|
||||||
|
INTERNAL_API_KEY=CHANGE_ME
|
||||||
GITEA_URL=http://gitea:3000
|
GITEA_URL=http://gitea:3000
|
||||||
GITEA_TOKEN=CHANGE_ME
|
GITEA_TOKEN=CHANGE_ME
|
||||||
|
|||||||
@@ -26,3 +26,6 @@ headscale/*.key
|
|||||||
headscale/*.state
|
headscale/*.state
|
||||||
agent/resolv.conf
|
agent/resolv.conf
|
||||||
agent/tailscale-bin/
|
agent/tailscale-bin/
|
||||||
|
agent/studioE5-agent-test
|
||||||
|
agent/.cache-go/
|
||||||
|
server/tsconfig.tsbuildinfo
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ headscale.studioe5.edudeploy.com:443 {
|
|||||||
reverse_proxy headscale:8080
|
reverse_proxy headscale:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gitea.alfrednobel.edudeploy.com {
|
||||||
|
tls admin@edudeploy.com
|
||||||
|
reverse_proxy 151.80.60.98:3001
|
||||||
|
}
|
||||||
|
|
||||||
studioe5.edudeploy.com:443 {
|
studioe5.edudeploy.com:443 {
|
||||||
route /studioE5-agent* {
|
route /studioE5-agent* {
|
||||||
file_server {
|
file_server {
|
||||||
|
|||||||
+674
-15
@@ -64,6 +64,68 @@ HTTP/2 200
|
|||||||
- Certificat Let’s Encrypt obtenu automatiquement par Caddy (`tls { on_demand }`).
|
- 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://`.
|
- 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)
|
## 📁 Fichiers modifiés (non exhaustif)
|
||||||
|
|
||||||
- `agent/tailscale.go` – lancement `tailscaled` + `tailscale up`, gestion start/stop/status.
|
- `agent/tailscale.go` – lancement `tailscaled` + `tailscale up`, gestion start/stop/status.
|
||||||
@@ -79,7 +141,7 @@ Agent de test lancé en arrière-plan :
|
|||||||
- data-dir : `/tmp/studioe5-test-clienta`
|
- data-dir : `/tmp/studioe5-test-clienta`
|
||||||
- node-id : `vps-8fc665eb`
|
- node-id : `vps-8fc665eb`
|
||||||
- tailnet IP actuelle : `100.64.0.8`
|
- tailnet IP actuelle : `100.64.0.8`
|
||||||
- PID : `3151830` (lancé le 2026-06-23 09:36 UTC)
|
- PID : voir `/tmp/studioe5-test-clienta/agent.pid` (relancé le 2026-06-26 11:53 UTC avec l’agent v0.3.5 corrigé)
|
||||||
|
|
||||||
Instance de test créée :
|
Instance de test créée :
|
||||||
- ID : `test-wp-001`
|
- ID : `test-wp-001`
|
||||||
@@ -88,6 +150,204 @@ Instance de test créée :
|
|||||||
- Template : `wordpress-wordpress-latest`
|
- Template : `wordpress-wordpress-latest`
|
||||||
- État : WordPress répond sur `http://127.0.0.1:8001` **et en HTTPS public sur `https://test-wp-001.studioe5.edudeploy.com/`**.
|
- État : WordPress répond sur `http://127.0.0.1:8001` **et en HTTPS public sur `https://test-wp-001.studioe5.edudeploy.com/`**.
|
||||||
|
|
||||||
|
## 🪟 Fix agent Windows v0.3.1
|
||||||
|
|
||||||
|
Problème rencontré sur le PC de test (`OMEGA-GAMER-dc166b1a`) :
|
||||||
|
- Le nœud apparaissait `online` dans le dashboard mais sans IP Tailscale.
|
||||||
|
- `tailscale.exe ip -4` retournait une erreur de connexion au socket local.
|
||||||
|
|
||||||
|
Cause racine :
|
||||||
|
- 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
|
## 🛠️ Commandes utiles pour reprendre
|
||||||
|
|
||||||
### Voir l’agent de test
|
### 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
|
### Binaires disponibles
|
||||||
|
|
||||||
- **Windows (archive complète)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.0-windows.zip`
|
- **Windows (archive complète)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.10-windows.zip`
|
||||||
- Contient `studioE5-agent.exe` + `tailscale-bin/windows/` (`tailscale.exe`, `tailscaled.exe`, `wintun.dll`) + `README-Windows.txt`.
|
- Contient `studioE5-agent.exe` + `tailscale-bin/windows/` (`tailscale.exe`, `tailscaled.exe`, `wintun.dll`) + `README-Windows.txt`.
|
||||||
- **Windows (exécutable seul)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.0.exe`
|
- **Windows (exécutable seul)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.10.exe`
|
||||||
- Nécessite d’avoir installé Tailscale Windows séparément ou d’avoir les binaires dans `tailscale-bin/windows/`.
|
- 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
|
### Builder / préparer les binaires
|
||||||
|
|
||||||
@@ -184,13 +444,13 @@ cd /opt/studioe5-client-a/agent
|
|||||||
./build.sh
|
./build.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Le `build.sh` génère automatiquement `studioE5-agent-v0.3.0-windows.zip` et copie les binaires versionnés dans `server/public/`.
|
Le `build.sh` génère automatiquement `studioE5-agent-v0.3.10-windows.zip` et copie les binaires versionnés dans `server/public/`.
|
||||||
|
|
||||||
### Flow d’activation zéro-config (modèle commercialisable)
|
### Flow d’activation zéro-config (modèle commercialisable)
|
||||||
|
|
||||||
L’élève/employé n’a **aucune configuration technique** à saisir :
|
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`.
|
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`).
|
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** :
|
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
|
.\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
|
## 📋 Prochaines étapes à faire
|
||||||
|
|
||||||
- [x] ~~Attendre la fin du rate limit Let’s Encrypt~~ (levé le 2026-06-23).
|
### ✅ Terminé
|
||||||
- [x] ~~Relancer un test HTTPS sur `https://test-wp-001.studioe5.edudeploy.com/`~~ → **OK** (HTTP/2 200).
|
|
||||||
- [x] ~~Créer une branche dédiée et commiter les modifications VPN on-demand~~ → branche `feat/studioe5-vpn-ondemand`, commit `124543d`. Push vers Gitea à faire dès que le remote sera accessible (actuellement `localhost:3001` et `gitea.alfrednobel.edudeploy.com` injoignables).
|
- [x] Rate limit Let’s Encrypt levé.
|
||||||
- [x] ~~Tester le flux complet depuis l’interface web~~ → **OK** via l’API authentifiée (`POST /api/instances`), instance `cmqqgrur20001lw67t2bdgzkg` accessible en HTTPS public.
|
- [x] Flux HTTPS public validé (`test-wp-001.studioe5.edudeploy.com`).
|
||||||
- [ ] **Obtenir un certificat wildcard** pour `*.studioe5.edudeploy.com` (voir étude ci-dessous).
|
- [x] Branche `feat/studioe5-vpn-ondemand` créée, commit `124543d`.
|
||||||
- [ ] **Nettoyer les instances/agent de test** une fois le wildcard en place et le push effectué.
|
- [x] **Pousser la branche** vers Gitea : la branche est synchronisée sur `origin/feat/studioe5-vpn-ondemand` (commit `cf8b663`).
|
||||||
- [x] ~~Packager les binaires Tailscale pour Windows~~ → **OK**, `download-tailscale-bins.sh` + `studioE5-agent-v0.3.0-windows.zip` prêt.
|
- [x] Flux complet UI → API → WebSocket → agent → Docker → VPN → Caddy validé.
|
||||||
- [ ] **Nettoyer les anciens nodes/volumes Headscale** créés pendant les tests.
|
- [x] Packager les binaires Tailscale pour Windows (`studioE5-agent-v0.3.5-windows.zip`).
|
||||||
- [ ] **Documenter la procédure de mise en production** pour le client A (config agent, clés Headscale, ports, etc.).
|
- [x] **Sécurité – authentification du canal serveur → agent** (token par nœud, clé API interne, sessions NextAuth sur les routes API).
|
||||||
|
- [x] **Sécurité – durcissement du code 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`
|
## 🔒 Étude certificat wildcard `*.studioe5.edudeploy.com`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
0.3.18
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
VERSION="0.3.0"
|
VERSION="$(cat "$(dirname "$0")/VERSION")"
|
||||||
APP_NAME="studioE5"
|
APP_NAME="studioE5"
|
||||||
BIN_NAME="studioE5-agent"
|
BIN_NAME="studioE5-agent"
|
||||||
LDFLAGS="-X main.version=${VERSION}"
|
LDFLAGS="-X main.version=${VERSION}"
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ type AgentConfig struct {
|
|||||||
HeadscaleAuthKey string `json:"headscale_auth_key"`
|
HeadscaleAuthKey string `json:"headscale_auth_key"`
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
DataDir string `json:"data_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
|
// ProxyURL is an optional HTTP(S) proxy used for all outbound agent traffic
|
||||||
|
// (WebSocket, update checks, downloads).
|
||||||
|
ProxyURL string `json:"proxy_url,omitempty"`
|
||||||
|
// ProxyMode controls how the proxy is used:
|
||||||
|
// - "disabled" : never use the proxy.
|
||||||
|
// - "auto" : the agent tries direct connections first and falls back to
|
||||||
|
// the proxy after a few failures (useful when moving between
|
||||||
|
// home network and school network).
|
||||||
|
// - "enabled" : always use the proxy.
|
||||||
|
ProxyMode string `json:"proxy_mode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const configFileName = "studioE5-config.json"
|
const configFileName = "studioE5-config.json"
|
||||||
|
|||||||
+45
-15
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -39,31 +40,64 @@ func writeCompose(dataDir, instanceID, compose string, port int) error {
|
|||||||
return os.WriteFile(f, []byte(compose), 0644)
|
return os.WriteFile(f, []byte(compose), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeInitScript(dataDir, instanceID, script string) error {
|
||||||
|
dir := instanceDir(dataDir, instanceID)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f := filepath.Join(dir, "wp-init.sh")
|
||||||
|
return os.WriteFile(f, []byte(script), 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureEngineCmd(cmd *exec.Cmd, dir string) {
|
||||||
|
hideWindow(cmd)
|
||||||
|
logPath := filepath.Join(dir, "compose.log")
|
||||||
|
if f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||||
|
cmd.Stdout = f
|
||||||
|
cmd.Stderr = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func dockerComposeUp(dataDir, instanceID string) error {
|
func dockerComposeUp(dataDir, instanceID string) error {
|
||||||
dir := instanceDir(dataDir, instanceID)
|
dir := instanceDir(dataDir, instanceID)
|
||||||
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "up", "-d")
|
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "up", "-d")
|
||||||
cmd.Stdout = os.Stdout
|
configureEngineCmd(cmd, dir)
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
func dockerComposeDown(dataDir, instanceID string) error {
|
func dockerComposeDown(dataDir, instanceID string) error {
|
||||||
dir := instanceDir(dataDir, instanceID)
|
dir := instanceDir(dataDir, instanceID)
|
||||||
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down")
|
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down")
|
||||||
cmd.Stdout = os.Stdout
|
configureEngineCmd(cmd, dir)
|
||||||
cmd.Stderr = os.Stderr
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerComposeStop(dataDir, instanceID string) error {
|
||||||
|
dir := instanceDir(dataDir, instanceID)
|
||||||
|
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "stop")
|
||||||
|
configureEngineCmd(cmd, dir)
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerComposeStart(dataDir, instanceID string) error {
|
||||||
|
dir := instanceDir(dataDir, instanceID)
|
||||||
|
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "start")
|
||||||
|
configureEngineCmd(cmd, dir)
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
func dockerComposeRm(dataDir, instanceID string) error {
|
func dockerComposeRm(dataDir, instanceID string) error {
|
||||||
dir := instanceDir(dataDir, instanceID)
|
dir := instanceDir(dataDir, instanceID)
|
||||||
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v")
|
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v")
|
||||||
cmd.Stdout = os.Stdout
|
configureEngineCmd(cmd, dir)
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.RemoveAll(dir)
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
log.Printf("dockerComposeRm: failed to remove %s: %v (will retry on next startup)", dir, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractPublicURL tries to find the public URL from a WordPress compose config.
|
// extractPublicURL tries to find the public URL from a WordPress compose config.
|
||||||
@@ -104,15 +138,13 @@ fi
|
|||||||
defer os.Remove(scriptPath)
|
defer os.Remove(scriptPath)
|
||||||
|
|
||||||
cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/update-wp-urls.sh")
|
cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/update-wp-urls.sh")
|
||||||
cpCmd.Stdout = os.Stdout
|
configureEngineCmd(cpCmd, dir)
|
||||||
cpCmd.Stderr = os.Stderr
|
|
||||||
if err := cpCmd.Run(); err != nil {
|
if err := cpCmd.Run(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/update-wp-urls.sh")
|
execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/update-wp-urls.sh")
|
||||||
execCmd.Stdout = os.Stdout
|
configureEngineCmd(execCmd, dir)
|
||||||
execCmd.Stderr = os.Stderr
|
|
||||||
return execCmd.Run()
|
return execCmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,14 +173,12 @@ fi
|
|||||||
defer os.Remove(scriptPath)
|
defer os.Remove(scriptPath)
|
||||||
|
|
||||||
cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/strip-wp-urls.sh")
|
cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/strip-wp-urls.sh")
|
||||||
cpCmd.Stdout = os.Stdout
|
configureEngineCmd(cpCmd, dir)
|
||||||
cpCmd.Stderr = os.Stderr
|
|
||||||
if err := cpCmd.Run(); err != nil {
|
if err := cpCmd.Run(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/strip-wp-urls.sh")
|
execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/strip-wp-urls.sh")
|
||||||
execCmd.Stdout = os.Stdout
|
configureEngineCmd(execCmd, dir)
|
||||||
execCmd.Stderr = os.Stderr
|
|
||||||
return execCmd.Run()
|
return execCmd.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -88,6 +89,7 @@ func getInstanceStatus(dataDir, instanceID string) string {
|
|||||||
|
|
||||||
// Try modern JSON format first
|
// Try modern JSON format first
|
||||||
cmd := exec.Command(engine, "compose", "-f", composeFile, "ps", "--format", "json")
|
cmd := exec.Command(engine, "compose", "-f", composeFile, "ps", "--format", "json")
|
||||||
|
hideWindow(cmd)
|
||||||
out, err := cmd.Output()
|
out, err := cmd.Output()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
outStr := strings.TrimSpace(string(out))
|
outStr := strings.TrimSpace(string(out))
|
||||||
@@ -119,6 +121,7 @@ func getInstanceStatus(dataDir, instanceID string) string {
|
|||||||
|
|
||||||
// Fallback: use "ps -q" which is supported by all docker-compose versions
|
// Fallback: use "ps -q" which is supported by all docker-compose versions
|
||||||
cmd = exec.Command(engine, "compose", "-f", composeFile, "ps", "-q")
|
cmd = exec.Command(engine, "compose", "-f", composeFile, "ps", "-q")
|
||||||
|
hideWindow(cmd)
|
||||||
out, err = cmd.Output()
|
out, err = cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "error"
|
return "error"
|
||||||
@@ -128,3 +131,34 @@ func getInstanceStatus(dataDir, instanceID string) string {
|
|||||||
}
|
}
|
||||||
return "stopped"
|
return "stopped"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanupOrphanInstanceDirs removes instance directories that have no entry in
|
||||||
|
// instances.json. This typically happens on Windows when a delete operation
|
||||||
|
// could not fully remove the directory because compose.log was locked.
|
||||||
|
func cleanupOrphanInstanceDirs(dataDir string) {
|
||||||
|
instancesDir := filepath.Join(dataDir, "instances")
|
||||||
|
inst, err := loadInstances(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cleanupOrphanInstanceDirs: loadInstances error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(instancesDir)
|
||||||
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
log.Printf("cleanupOrphanInstanceDirs: ReadDir error: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := inst[entry.Name()]; !ok {
|
||||||
|
dir := filepath.Join(instancesDir, entry.Name())
|
||||||
|
log.Printf("cleanupOrphanInstanceDirs: removing orphan directory %s", dir)
|
||||||
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
log.Printf("cleanupOrphanInstanceDirs: RemoveAll error for %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+94
-8
@@ -2,20 +2,21 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// version is injected at build time via -ldflags "-X main.version=X.Y.Z"
|
// version is injected at build time via -ldflags "-X main.version=X.Y.Z"
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
const (
|
const APP_NAME = "studioE5"
|
||||||
AGENT_VERSION = "0.3.0"
|
|
||||||
APP_NAME = "studioE5"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
dataDir = flag.String("data-dir", "./studioE5-data", "Répertoire de données")
|
dataDir = flag.String("data-dir", "./studioE5-data", "Répertoire de données")
|
||||||
@@ -42,6 +43,14 @@ func main() {
|
|||||||
log.Fatalf("Cannot create data-dir: %v", err)
|
log.Fatalf("Cannot create data-dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect agent logs to a file so the console can be hidden on Windows.
|
||||||
|
agentLogPath := filepath.Join(*dataDir, "agent.log")
|
||||||
|
if agentLogFile, err := os.OpenFile(agentLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||||
|
log.SetOutput(io.MultiWriter(agentLogFile, uiLogWriter{}))
|
||||||
|
} else {
|
||||||
|
log.Printf("Cannot open agent log file %s: %v", agentLogPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
cfg, _, err := loadOrCreateConfig(*dataDir)
|
cfg, _, err := loadOrCreateConfig(*dataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Cannot load config: %v", err)
|
log.Fatalf("Cannot load config: %v", err)
|
||||||
@@ -51,18 +60,71 @@ func main() {
|
|||||||
log.Fatalf("Cannot save config: %v", err)
|
log.Fatalf("Cannot save config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[%s Agent] version=%s node=%s data-dir=%s server=%s", APP_NAME, AGENT_VERSION, cfg.NodeID, *dataDir, cfg.Server)
|
log.Printf("[%s Agent] version=%s node=%s data-dir=%s server=%s", APP_NAME, version, cfg.NodeID, *dataDir, cfg.Server)
|
||||||
|
|
||||||
|
// Clean up instance directories left behind by failed deletes (common on
|
||||||
|
// Windows when compose.log is locked during removal).
|
||||||
|
cleanupOrphanInstanceDirs(*dataDir)
|
||||||
|
|
||||||
|
// Ensure Podman machine DNS is configured on Windows/macOS so images can be
|
||||||
|
// pulled and containers can reach the internet.
|
||||||
|
ensurePodmanMachineDNS()
|
||||||
|
|
||||||
if *uiEnabled {
|
if *uiEnabled {
|
||||||
go startUI(*dataDir, cfg.NodeID, cfg.Server)
|
go startUI(*dataDir, cfg.NodeID, cfg.Server)
|
||||||
}
|
}
|
||||||
|
|
||||||
go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey)
|
go startWebSocket(cfg, cfg.NodeID, *dataDir)
|
||||||
|
go updateCheckerLoop(cfg, *dataDir)
|
||||||
|
|
||||||
shutdownCh := make(chan struct{})
|
shutdownCh := make(chan struct{})
|
||||||
|
|
||||||
|
// Capture Ctrl+C / SIGTERM so a console window close or service stop
|
||||||
|
// triggers the same cleanup path as the tray "Quit" menu.
|
||||||
|
go func() {
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
log.Println("Shutdown signal received")
|
||||||
|
close(shutdownCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var cleanupWg sync.WaitGroup
|
||||||
|
cleanupWg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer cleanupWg.Done()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in cleanup goroutine: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
<-shutdownCh
|
||||||
|
log.Println("Cleaning up before exit...")
|
||||||
|
|
||||||
|
// Stop Tailscale so the next agent start does not conflict on the
|
||||||
|
// same socket/state.
|
||||||
|
stopTailscale()
|
||||||
|
|
||||||
|
// Stop any running instances so containers are not left behind, but keep
|
||||||
|
// their volumes intact so data survives the next agent start.
|
||||||
|
if inst, err := loadInstances(*dataDir); err == nil {
|
||||||
|
for id, info := range inst {
|
||||||
|
if info.Status == "running" {
|
||||||
|
log.Printf("Stopping instance %s", id)
|
||||||
|
_ = dockerComposeStop(*dataDir, id)
|
||||||
|
inst[id].Status = "stopped"
|
||||||
|
go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = saveInstances(*dataDir, inst)
|
||||||
|
}
|
||||||
|
log.Println("Cleanup complete")
|
||||||
|
}()
|
||||||
|
|
||||||
if *noTray {
|
if *noTray {
|
||||||
log.Printf("[%s Agent] running in console mode (no tray)", APP_NAME)
|
log.Printf("[%s Agent] running in console mode (no tray)", APP_NAME)
|
||||||
<-shutdownCh
|
<-shutdownCh
|
||||||
|
cleanupWg.Wait()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,11 +137,16 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
<-shutdownCh
|
<-shutdownCh
|
||||||
|
cleanupWg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
|
func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
|
||||||
tsDir := filepath.Join(dataDir, "tailscale")
|
defer func() {
|
||||||
ip, err := startTailscale(tsDir, nodeID, headscaleURL, authKey)
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in startTailscaleAndReport: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
ip, err := startTailscale(dataDir, nodeID, headscaleURL, authKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Tailscale error: %v", err)
|
log.Printf("Tailscale error: %v", err)
|
||||||
return
|
return
|
||||||
@@ -95,4 +162,23 @@ func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
|
|||||||
log.Printf("Sent tailscale_ip to server: %s", ip)
|
log.Printf("Sent tailscale_ip to server: %s", ip)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reconfigure tailscale serve for any instances that were left running
|
||||||
|
// (e.g. after an agent restart while containers kept running).
|
||||||
|
if inst, err := loadInstances(dataDir); err == nil {
|
||||||
|
for id, info := range inst {
|
||||||
|
if info.Status == "running" {
|
||||||
|
log.Printf("Reconfiguring tailscale serve for running instance %s on port %d", id, info.Port)
|
||||||
|
if err := setupTailscaleServe(info.Port); err != nil {
|
||||||
|
log.Printf("setupTailscaleServe error for %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the local UI that the service status has changed.
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
args = []string{url}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := exec.Command(cmd, args...).Start(); err != nil {
|
openCmd := exec.Command(cmd, args...)
|
||||||
|
hideWindow(openCmd)
|
||||||
|
if err := openCmd.Start(); err != nil {
|
||||||
log.Printf("Failed to open browser: %v", err)
|
log.Printf("Failed to open browser: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+144
-16
@@ -9,6 +9,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -61,40 +62,69 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro
|
|||||||
if err := os.MkdirAll(tsDataDir, 0700); err != nil {
|
if err := os.MkdirAll(tsDataDir, 0700); err != nil {
|
||||||
return "", fmt.Errorf("create tailscale dir: %w", err)
|
return "", fmt.Errorf("create tailscale dir: %w", err)
|
||||||
}
|
}
|
||||||
|
// 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")
|
tsSocket = filepath.Join(tsDataDir, "tailscaled.sock")
|
||||||
|
}
|
||||||
stateFile := filepath.Join(tsDataDir, "tailscaled.state")
|
stateFile := filepath.Join(tsDataDir, "tailscaled.state")
|
||||||
|
|
||||||
log.Printf("Starting tailscaled for node %s", nodeID)
|
log.Printf("Starting tailscaled for node %s (socket=%s)", nodeID, tsSocket)
|
||||||
tsCmd = exec.Command(tailscaleBin("tailscaled"),
|
tsCmd = exec.Command(tailscaleBin("tailscaled"),
|
||||||
"--state="+stateFile,
|
"--state="+stateFile,
|
||||||
"--socket="+tsSocket,
|
"--socket="+tsSocket,
|
||||||
"--tun=userspace-networking",
|
"--tun=userspace-networking",
|
||||||
)
|
)
|
||||||
tsCmd.Stdout = os.Stdout
|
hideWindow(tsCmd)
|
||||||
tsCmd.Stderr = os.Stderr
|
// Redirect tailscaled output to a dedicated log file.
|
||||||
|
tsLogPath := filepath.Join(tsDataDir, "tailscaled.log")
|
||||||
|
if tsLogFile, err := os.OpenFile(tsLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||||
|
tsCmd.Stdout = tsLogFile
|
||||||
|
tsCmd.Stderr = tsLogFile
|
||||||
|
} else {
|
||||||
|
log.Printf("Cannot open tailscaled log file %s: %v", tsLogPath, err)
|
||||||
|
}
|
||||||
if err := tsCmd.Start(); err != nil {
|
if err := tsCmd.Start(); err != nil {
|
||||||
tsCmd = nil
|
tsCmd = nil
|
||||||
return "", fmt.Errorf("start tailscaled: %w", err)
|
return "", fmt.Errorf("start tailscaled: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tsDataDir, "tailscaled.pid"), []byte(strconv.Itoa(tsCmd.Process.Pid)), 0644); err != nil {
|
||||||
|
log.Printf("Cannot write tailscaled pid file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Give tailscaled a moment to start listening.
|
// Give tailscaled a moment to start listening.
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
// Bring the interface up with the auth key.
|
// Bring the interface up with the auth key.
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
upCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"),
|
upArgs := []string{
|
||||||
"--socket="+tsSocket,
|
"--socket=" + tsSocket,
|
||||||
"up",
|
"up",
|
||||||
"--authkey="+authKey,
|
"--login-server=" + headscaleURL,
|
||||||
"--login-server="+headscaleURL,
|
"--hostname=" + nodeID,
|
||||||
"--hostname="+nodeID,
|
|
||||||
"--accept-dns=false",
|
"--accept-dns=false",
|
||||||
"--operator=root",
|
}
|
||||||
)
|
// The auth key is omitted on reconnect: Tailscale reuses the existing state.
|
||||||
upCmd.Stdout = os.Stdout
|
if authKey != "" {
|
||||||
upCmd.Stderr = os.Stderr
|
upArgs = append(upArgs, "--authkey="+authKey)
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// On Windows, keep the VPN up even after the tailscale.exe CLI client disconnects.
|
||||||
|
upArgs = append(upArgs, "--unattended")
|
||||||
|
} else {
|
||||||
|
// --operator is only meaningful on Unix systems.
|
||||||
|
upArgs = append(upArgs, "--operator=root")
|
||||||
|
}
|
||||||
|
upCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"), upArgs...)
|
||||||
|
hideWindow(upCmd)
|
||||||
|
upCmd.Stdout = log.Writer()
|
||||||
|
upCmd.Stderr = log.Writer()
|
||||||
if err := upCmd.Run(); err != nil {
|
if err := upCmd.Run(); err != nil {
|
||||||
_ = tsCmd.Process.Kill()
|
_ = tsCmd.Process.Kill()
|
||||||
_ = tsCmd.Wait()
|
_ = tsCmd.Wait()
|
||||||
@@ -104,10 +134,12 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro
|
|||||||
|
|
||||||
// Wait for an IP address.
|
// Wait for an IP address.
|
||||||
for {
|
for {
|
||||||
out, err := exec.CommandContext(ctx, tailscaleBin("tailscale"),
|
statusCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"),
|
||||||
"--socket="+tsSocket,
|
"--socket="+tsSocket,
|
||||||
"status", "--json",
|
"status", "--json",
|
||||||
).Output()
|
)
|
||||||
|
hideWindow(statusCmd)
|
||||||
|
out, err := statusCmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -151,12 +183,17 @@ func stopTailscaleLocked() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tsSocket != "" {
|
if tsSocket != "" {
|
||||||
_ = exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "down").Run()
|
downCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "down")
|
||||||
|
hideWindow(downCmd)
|
||||||
|
_ = downCmd.Run()
|
||||||
}
|
}
|
||||||
_ = tsCmd.Process.Kill()
|
_ = tsCmd.Process.Kill()
|
||||||
_ = tsCmd.Wait()
|
_ = tsCmd.Wait()
|
||||||
tsCmd = nil
|
tsCmd = nil
|
||||||
tsIP = ""
|
tsIP = ""
|
||||||
|
if tsDataDir != "" {
|
||||||
|
_ = os.Remove(filepath.Join(tsDataDir, "tailscaled.pid"))
|
||||||
|
}
|
||||||
log.Printf("Tailscale stopped")
|
log.Printf("Tailscale stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,4 +213,95 @@ func getTailscaleIP() string {
|
|||||||
return tsIP
|
return tsIP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isTailscaleReady reports whether tailscaled is running and has successfully
|
||||||
|
// joined the tailnet (i.e. it has a Tailscale IP). It does not rely on
|
||||||
|
// isTailscaleRunning because tailscaled may have been started by a previous
|
||||||
|
// agent run or externally; the important thing is that the socket responds.
|
||||||
|
func isTailscaleReady() bool {
|
||||||
|
tsCmdMu.Lock()
|
||||||
|
socket := tsSocket
|
||||||
|
tsCmdMu.Unlock()
|
||||||
|
if socket == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+socket, "status", "--json")
|
||||||
|
hideWindow(statusCmd)
|
||||||
|
out, err := statusCmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var st tailscaleStatus
|
||||||
|
if err := json.Unmarshal(out, &st); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(st.Self.TailscaleIPs) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupTailscaleServe configures Tailscale to proxy inbound Tailnet traffic
|
||||||
|
// on the given TCP port to localhost:<port>. This is required on Windows
|
||||||
|
// because userspace networking does not forward incoming connections to
|
||||||
|
// loopback by default.
|
||||||
|
func setupTailscaleServe(port int) error {
|
||||||
|
tsCmdMu.Lock()
|
||||||
|
defer tsCmdMu.Unlock()
|
||||||
|
if tsSocket == "" {
|
||||||
|
return fmt.Errorf("tailscale socket not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
portStr := strconv.Itoa(port)
|
||||||
|
// Clean up any stale config for this port first.
|
||||||
|
offCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "off")
|
||||||
|
hideWindow(offCmd)
|
||||||
|
_ = offCmd.Run()
|
||||||
|
|
||||||
|
serveCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "tcp://localhost:"+portStr)
|
||||||
|
hideWindow(serveCmd)
|
||||||
|
out, err := serveCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tailscale serve: %w: %s", err, string(out))
|
||||||
|
}
|
||||||
|
log.Printf("Tailscale serve configured for port %s", portStr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeTailscaleServe removes the Tailscale serve proxy for a port when an
|
||||||
|
// instance is stopped or deleted.
|
||||||
|
func removeTailscaleServe(port int) {
|
||||||
|
tsCmdMu.Lock()
|
||||||
|
defer tsCmdMu.Unlock()
|
||||||
|
if tsSocket == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
portStr := strconv.Itoa(port)
|
||||||
|
offCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "off")
|
||||||
|
hideWindow(offCmd)
|
||||||
|
_ = offCmd.Run()
|
||||||
|
log.Printf("Tailscale serve removed for port %s", portStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// killStaleTailscaled terminates a previously started tailscaled process that
|
||||||
|
// may have been left running after the agent was force-killed.
|
||||||
|
func killStaleTailscaled(tsDataDir string) {
|
||||||
|
pidFile := filepath.Join(tsDataDir, "tailscaled.pid")
|
||||||
|
data, err := os.ReadFile(pidFile)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var pid int
|
||||||
|
if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proc, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := proc.Signal(syscall.Signal(0)); err == nil {
|
||||||
|
log.Printf("Killing stale tailscaled process %d", pid)
|
||||||
|
_ = proc.Kill()
|
||||||
|
_, _ = proc.Wait()
|
||||||
|
}
|
||||||
|
_ = os.Remove(pidFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
@@ -17,6 +21,25 @@ var uiHTML string
|
|||||||
|
|
||||||
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
||||||
|
|
||||||
|
// uiConnections holds active WebSocket connections from local UI clients.
|
||||||
|
var (
|
||||||
|
uiConnections = make(map[*websocket.Conn]bool)
|
||||||
|
uiConnectionsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// uiLogWriter intercepts log output and forwards it to connected UI clients.
|
||||||
|
type uiLogWriter struct{}
|
||||||
|
|
||||||
|
func (w uiLogWriter) Write(p []byte) (n int, err error) {
|
||||||
|
line := strings.TrimSpace(string(p))
|
||||||
|
if line != "" {
|
||||||
|
sendUILog(line)
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func startUI(dataDir, nodeID, serverAddr string) {
|
func startUI(dataDir, nodeID, serverAddr string) {
|
||||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
@@ -32,8 +55,22 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
|||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Do not expose the auth key in plain GET unless requested; for local UI it is fine.
|
// Expose a merged view with the agent version for the UI.
|
||||||
json.NewEncoder(w).Encode(cfg)
|
serverVersion := getServerAgentVersion()
|
||||||
|
updateAvailable := serverVersion != "" && serverVersion != version
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"server": cfg.Server,
|
||||||
|
"headscale_url": cfg.HeadscaleURL,
|
||||||
|
"headscale_auth_key": cfg.HeadscaleAuthKey,
|
||||||
|
"node_id": cfg.NodeID,
|
||||||
|
"data_dir": cfg.DataDir,
|
||||||
|
"proxy_url": cfg.ProxyURL,
|
||||||
|
"proxy_mode": cfg.ProxyMode,
|
||||||
|
"version": version,
|
||||||
|
"server_version": serverVersion,
|
||||||
|
"update_available": updateAvailable,
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
var cfg AgentConfig
|
var cfg AgentConfig
|
||||||
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||||
@@ -61,6 +98,7 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
go func() {
|
go func() {
|
||||||
cmd := exec.Command(os.Args[0], os.Args[1:]...)
|
cmd := exec.Command(os.Args[0], os.Args[1:]...)
|
||||||
|
hideWindow(cmd)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
@@ -72,6 +110,34 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
|||||||
}()
|
}()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
http.HandleFunc("/api/update", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, _, err := loadOrCreateConfig(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "update_progress",
|
||||||
|
"percent": "10",
|
||||||
|
"message": "Téléchargement de la mise à jour...",
|
||||||
|
})
|
||||||
|
if err := startAgentUpdate(cfg, dataDir); err != nil {
|
||||||
|
log.Printf("Agent update failed: %v", err)
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "update_progress",
|
||||||
|
"percent": "0",
|
||||||
|
"message": "Échec de la mise à jour : " + err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
|
||||||
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,23 +145,33 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
|
uiConnectionsMu.Lock()
|
||||||
|
uiConnections[conn] = true
|
||||||
|
uiConnectionsMu.Unlock()
|
||||||
log.Printf("UI client connected from %s", r.RemoteAddr)
|
log.Printf("UI client connected from %s", r.RemoteAddr)
|
||||||
|
|
||||||
|
// Send current status immediately.
|
||||||
|
sendUIStatus(conn, dataDir)
|
||||||
|
|
||||||
// Register notifier to forward activation results from main WS to this UI connection
|
// Register notifier to forward activation results from main WS to this UI connection
|
||||||
notifierID := registerUINotifier(func(msg map[string]interface{}) {
|
notifierID := registerUINotifier(func(msg map[string]interface{}) {
|
||||||
log.Printf("UI notifier forwarding to browser: %+v", msg)
|
|
||||||
if err := conn.WriteJSON(msg); err != nil {
|
if err := conn.WriteJSON(msg); err != nil {
|
||||||
log.Printf("UI notify error: %v", err)
|
log.Printf("UI notify error: %v", err)
|
||||||
} else {
|
|
||||||
log.Printf("UI notifier sent successfully")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
defer unregisterUINotifier(notifierID)
|
defer func() {
|
||||||
|
unregisterUINotifier(notifierID)
|
||||||
|
uiConnectionsMu.Lock()
|
||||||
|
delete(uiConnections, conn)
|
||||||
|
uiConnectionsMu.Unlock()
|
||||||
|
log.Printf("UI client disconnected")
|
||||||
|
}()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
var msg map[string]interface{}
|
var msg map[string]interface{}
|
||||||
if err := conn.ReadJSON(&msg); err != nil {
|
if err := conn.ReadJSON(&msg); err != nil {
|
||||||
log.Printf("UI client disconnected: %v", err)
|
log.Printf("UI client read error: %v", err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
action, _ := msg["action"].(string)
|
action, _ := msg["action"].(string)
|
||||||
@@ -119,6 +195,42 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
|||||||
}
|
}
|
||||||
case "instances":
|
case "instances":
|
||||||
listInstances(dataDir, conn)
|
listInstances(dataDir, conn)
|
||||||
|
case "get_status":
|
||||||
|
sendUIStatus(conn, dataDir)
|
||||||
|
case "run_diagnostic":
|
||||||
|
sendUIStatus(conn, dataDir)
|
||||||
|
conn.WriteJSON(map[string]interface{}{
|
||||||
|
"action": "diagnostic_result",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
"message": "Diagnostic terminé",
|
||||||
|
})
|
||||||
|
case "get_logs":
|
||||||
|
// Logs are streamed as they are produced; no persistent buffer yet.
|
||||||
|
conn.WriteJSON(map[string]interface{}{
|
||||||
|
"action": "log",
|
||||||
|
"message": "Console prête. Les nouveaux logs apparaîtront ici.",
|
||||||
|
"level": "info",
|
||||||
|
})
|
||||||
|
case "start_instance":
|
||||||
|
instanceID, _ := msg["instanceId"].(string)
|
||||||
|
if instanceID != "" {
|
||||||
|
go uiStartInstance(dataDir, nodeID, instanceID)
|
||||||
|
}
|
||||||
|
case "stop_instance":
|
||||||
|
instanceID, _ := msg["instanceId"].(string)
|
||||||
|
if instanceID != "" {
|
||||||
|
go uiStopInstance(dataDir, instanceID)
|
||||||
|
}
|
||||||
|
case "delete_instance":
|
||||||
|
instanceID, _ := msg["instanceId"].(string)
|
||||||
|
if instanceID != "" {
|
||||||
|
go uiDeleteInstance(dataDir, instanceID)
|
||||||
|
}
|
||||||
|
case "reset_instance":
|
||||||
|
instanceID, _ := msg["instanceId"].(string)
|
||||||
|
if instanceID != "" {
|
||||||
|
go uiResetInstance(dataDir, nodeID, instanceID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -138,7 +250,7 @@ func listInstances(dataDir string, conn *websocket.Conn) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var list []map[string]interface{}
|
list := []map[string]interface{}{}
|
||||||
for _, inst := range instances {
|
for _, inst := range instances {
|
||||||
status := getInstanceStatus(dataDir, inst.ID)
|
status := getInstanceStatus(dataDir, inst.ID)
|
||||||
if status != inst.Status {
|
if status != inst.Status {
|
||||||
@@ -148,6 +260,7 @@ func listInstances(dataDir string, conn *websocket.Conn) {
|
|||||||
list = append(list, map[string]interface{}{
|
list = append(list, map[string]interface{}{
|
||||||
"id": inst.ID,
|
"id": inst.ID,
|
||||||
"templateName": inst.TemplateName,
|
"templateName": inst.TemplateName,
|
||||||
|
"type": inst.TemplateName,
|
||||||
"port": inst.Port,
|
"port": inst.Port,
|
||||||
"status": inst.Status,
|
"status": inst.Status,
|
||||||
"url": instanceURL(inst),
|
"url": instanceURL(inst),
|
||||||
@@ -156,3 +269,242 @@ func listInstances(dataDir string, conn *websocket.Conn) {
|
|||||||
|
|
||||||
conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": list})
|
conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": list})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendUILog broadcasts a log line to all connected UI clients.
|
||||||
|
func sendUILog(message string) {
|
||||||
|
uiConnectionsMu.RLock()
|
||||||
|
conns := make([]*websocket.Conn, 0, len(uiConnections))
|
||||||
|
for conn := range uiConnections {
|
||||||
|
conns = append(conns, conn)
|
||||||
|
}
|
||||||
|
uiConnectionsMu.RUnlock()
|
||||||
|
|
||||||
|
msg := map[string]interface{}{
|
||||||
|
"action": "log",
|
||||||
|
"message": message,
|
||||||
|
"level": "info",
|
||||||
|
}
|
||||||
|
for _, conn := range conns {
|
||||||
|
func(c *websocket.Conn) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in sendUILog: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := c.WriteJSON(msg); err != nil {
|
||||||
|
// Client may have disconnected; ignore.
|
||||||
|
}
|
||||||
|
}(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendInstanceProgress broadcasts a progress update for a specific instance.
|
||||||
|
func sendInstanceProgress(instanceID, step, percent, message string) {
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "progress",
|
||||||
|
"instanceId": instanceID,
|
||||||
|
"step": step,
|
||||||
|
"percent": percent,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcastUI sends a message to all connected UI clients.
|
||||||
|
func broadcastUI(msg map[string]interface{}) {
|
||||||
|
uiConnectionsMu.RLock()
|
||||||
|
conns := make([]*websocket.Conn, 0, len(uiConnections))
|
||||||
|
for conn := range uiConnections {
|
||||||
|
conns = append(conns, conn)
|
||||||
|
}
|
||||||
|
uiConnectionsMu.RUnlock()
|
||||||
|
|
||||||
|
for _, conn := range conns {
|
||||||
|
func(c *websocket.Conn) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in broadcastUI: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := c.WriteJSON(msg); err != nil {
|
||||||
|
// Ignore write errors for disconnected clients.
|
||||||
|
}
|
||||||
|
}(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendUIStatus sends the current services status to a single UI connection.
|
||||||
|
func sendUIStatus(conn *websocket.Conn, dataDir string) {
|
||||||
|
if err := conn.WriteJSON(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("sendUIStatus error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildUIStatus constructs a user-friendly status snapshot.
|
||||||
|
func buildUIStatus(dataDir string) map[string]interface{} {
|
||||||
|
// Connection to the school server.
|
||||||
|
connectionState := "pending"
|
||||||
|
connectionDetail := "Connexion en cours..."
|
||||||
|
mainConnMu.Lock()
|
||||||
|
connected := mainConn != nil
|
||||||
|
mainConnMu.Unlock()
|
||||||
|
if connected {
|
||||||
|
connectionState = "ok"
|
||||||
|
connectionDetail = "Connecté au serveur de l'établissement"
|
||||||
|
} else {
|
||||||
|
connectionState = "error"
|
||||||
|
connectionDetail = "Non connecté au serveur de l'établissement"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application service (Docker/Podman + VPN).
|
||||||
|
appServiceState := "pending"
|
||||||
|
appServiceDetail := "Vérification du service d'applications..."
|
||||||
|
engine := getContainerEngine()
|
||||||
|
if engineAvailable(engine) {
|
||||||
|
if isTailscaleReady() {
|
||||||
|
appServiceState = "ok"
|
||||||
|
appServiceDetail = "Service d'applications prêt"
|
||||||
|
} else if isTailscaleRunning() {
|
||||||
|
appServiceState = "warn"
|
||||||
|
appServiceDetail = "Service d'applications disponible, connexion sécurisée en cours"
|
||||||
|
} else {
|
||||||
|
appServiceState = "warn"
|
||||||
|
appServiceDetail = "Service d'applications disponible, connexion sécurisée inactive"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
appServiceState = "error"
|
||||||
|
appServiceDetail = "Service d'applications non disponible"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applications ready.
|
||||||
|
applicationsState := "pending"
|
||||||
|
applicationsDetail := "Vérification des applications..."
|
||||||
|
if instances, err := loadInstances(dataDir); err == nil {
|
||||||
|
ready := 0
|
||||||
|
total := len(instances)
|
||||||
|
for _, inst := range instances {
|
||||||
|
if getInstanceStatus(dataDir, inst.ID) == "running" {
|
||||||
|
ready++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
applicationsState = "ok"
|
||||||
|
applicationsDetail = "Aucune application assignée"
|
||||||
|
} else if ready == total {
|
||||||
|
applicationsState = "ok"
|
||||||
|
applicationsDetail = fmt.Sprintf("%d application%s prête%s", ready, plural(ready), plural(ready))
|
||||||
|
} else if ready > 0 {
|
||||||
|
applicationsState = "warn"
|
||||||
|
applicationsDetail = fmt.Sprintf("%d / %d application%s prête%s", ready, total, plural(ready), plural(ready))
|
||||||
|
} else {
|
||||||
|
applicationsState = "pending"
|
||||||
|
applicationsDetail = fmt.Sprintf("%d application%s en cours de démarrage", total, plural(total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"connection": connectionState,
|
||||||
|
"connectionDetail": connectionDetail,
|
||||||
|
"appService": appServiceState,
|
||||||
|
"appServiceDetail": appServiceDetail,
|
||||||
|
"applications": applicationsState,
|
||||||
|
"applicationsDetail": applicationsDetail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func engineAvailable(engine string) bool {
|
||||||
|
_, err := exec.LookPath(engine)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func plural(n int) string {
|
||||||
|
if n > 1 {
|
||||||
|
return "s"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiStartInstance starts a stopped instance without recreating its containers,
|
||||||
|
// so volumes and data are preserved.
|
||||||
|
func uiStartInstance(dataDir, nodeID, instanceID string) {
|
||||||
|
inst, err := loadInstances(dataDir)
|
||||||
|
if err != nil || inst[instanceID] == nil {
|
||||||
|
log.Printf("uiStartInstance: instance %s not found", instanceID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info := inst[instanceID]
|
||||||
|
|
||||||
|
if instanceContainersExist(dataDir, instanceID) {
|
||||||
|
if err := dockerComposeStart(dataDir, instanceID); err != nil {
|
||||||
|
log.Printf("uiStartInstance: start failed for %s: %v", instanceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := dockerComposeUp(dataDir, instanceID); err != nil {
|
||||||
|
log.Printf("uiStartInstance: up failed for %s: %v", instanceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
if err := setupTailscaleServe(info.Port); err != nil {
|
||||||
|
log.Printf("uiStartInstance: setupTailscaleServe failed for %s: %v", instanceID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := getInstanceStatus(dataDir, instanceID)
|
||||||
|
info.Status = status
|
||||||
|
_ = upsertInstance(dataDir, info)
|
||||||
|
_ = sendMessage(WSMessage{Action: "instance_started", InstanceID: instanceID, Port: info.Port})
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiStopInstance stops a running instance without removing its containers or volumes.
|
||||||
|
func uiStopInstance(dataDir, instanceID string) {
|
||||||
|
_ = dockerComposeStop(dataDir, instanceID)
|
||||||
|
if inst, err := loadInstances(dataDir); err == nil && inst[instanceID] != nil {
|
||||||
|
inst[instanceID].Status = "stopped"
|
||||||
|
_ = saveInstances(dataDir, inst)
|
||||||
|
}
|
||||||
|
go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: instanceID})
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiDeleteInstance removes an instance and its data (volumes included).
|
||||||
|
func uiDeleteInstance(dataDir, instanceID string) {
|
||||||
|
if inst, err := loadInstances(dataDir); err == nil && inst[instanceID] != nil {
|
||||||
|
removeTailscaleServe(inst[instanceID].Port)
|
||||||
|
}
|
||||||
|
dockerComposeRm(dataDir, instanceID)
|
||||||
|
removeInstance(dataDir, instanceID)
|
||||||
|
go sendMessage(WSMessage{Action: "instance_deleted", InstanceID: instanceID})
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiResetInstance stops, removes volumes and recreates an instance from scratch.
|
||||||
|
func uiResetInstance(dataDir, nodeID, instanceID string) {
|
||||||
|
inst, err := loadInstances(dataDir)
|
||||||
|
if err != nil || inst[instanceID] == nil {
|
||||||
|
log.Printf("uiResetInstance: instance %s not found", instanceID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info := inst[instanceID]
|
||||||
|
composePath := filepath.Join(instanceDir(dataDir, instanceID), "docker-compose.yml")
|
||||||
|
composeBytes, err := os.ReadFile(composePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("uiResetInstance: cannot read compose for %s: %v", instanceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dockerComposeRm(dataDir, instanceID)
|
||||||
|
handleStartInstance(dataDir, nodeID, instanceID, info.TemplateName, string(composeBytes), "", info.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// instanceContainersExist returns true if compose containers already exist for this instance.
|
||||||
|
func instanceContainersExist(dataDir, instanceID string) bool {
|
||||||
|
dir := instanceDir(dataDir, instanceID)
|
||||||
|
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "ps", "-q")
|
||||||
|
configureEngineCmd(cmd, dir)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
return err == nil && strings.TrimSpace(string(out)) != ""
|
||||||
|
}
|
||||||
|
|||||||
+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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type SyncInstanceInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
ComposeConfig string `json:"composeConfig,omitempty"`
|
||||||
|
InitScript string `json:"initScript,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type WSMessage struct {
|
type WSMessage struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
NodeID string `json:"nodeId,omitempty"`
|
NodeID string `json:"nodeId,omitempty"`
|
||||||
@@ -17,12 +27,19 @@ type WSMessage struct {
|
|||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Port int `json:"port,omitempty"`
|
Port int `json:"port,omitempty"`
|
||||||
ComposeConfig string `json:"composeConfig,omitempty"`
|
ComposeConfig string `json:"composeConfig,omitempty"`
|
||||||
|
InitScript string `json:"initScript,omitempty"`
|
||||||
StudentId string `json:"studentId,omitempty"`
|
StudentId string `json:"studentId,omitempty"`
|
||||||
StudentName string `json:"studentName,omitempty"`
|
StudentName string `json:"studentName,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
TailscaleIP string `json:"tailscaleIp,omitempty"`
|
TailscaleIP string `json:"tailscaleIp,omitempty"`
|
||||||
HeadscaleURL string `json:"headscaleUrl,omitempty"`
|
HeadscaleURL string `json:"headscaleUrl,omitempty"`
|
||||||
HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"`
|
HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
ServerVersion string `json:"serverVersion,omitempty"`
|
||||||
|
Instances []InstanceInfo `json:"instances"`
|
||||||
|
ToStart []SyncInstanceInfo `json:"toStart"`
|
||||||
|
ToDelete []string `json:"toDelete"`
|
||||||
|
ToStop []string `json:"toStop"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -51,6 +68,25 @@ func getHeadscaleConfig() (string, string) {
|
|||||||
return currentHeadscaleURL, currentHeadscaleAuthKey
|
return currentHeadscaleURL, currentHeadscaleAuthKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serverAgentVersion holds the agent version expected by the server. It is used
|
||||||
|
// to notify the user when an update is available.
|
||||||
|
var (
|
||||||
|
serverAgentVersion string
|
||||||
|
serverAgentVersionMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func setServerAgentVersion(v string) {
|
||||||
|
serverAgentVersionMu.Lock()
|
||||||
|
serverAgentVersion = v
|
||||||
|
serverAgentVersionMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getServerAgentVersion() string {
|
||||||
|
serverAgentVersionMu.RLock()
|
||||||
|
defer serverAgentVersionMu.RUnlock()
|
||||||
|
return serverAgentVersion
|
||||||
|
}
|
||||||
|
|
||||||
func sendMessage(msg WSMessage) error {
|
func sendMessage(msg WSMessage) error {
|
||||||
mainConnMu.Lock()
|
mainConnMu.Lock()
|
||||||
defer mainConnMu.Unlock()
|
defer mainConnMu.Unlock()
|
||||||
@@ -60,9 +96,33 @@ func sendMessage(msg WSMessage) error {
|
|||||||
if msg.Action != "heartbeat" {
|
if msg.Action != "heartbeat" {
|
||||||
log.Printf("sendMessage: sending %+v", msg)
|
log.Printf("sendMessage: sending %+v", msg)
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in sendMessage: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
return mainConn.WriteJSON(msg)
|
return mainConn.WriteJSON(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendSyncMessage sends the local instance list to the server so it can
|
||||||
|
// reconcile any differences (instances created/deleted while offline).
|
||||||
|
func sendSyncMessage(dataDir, nodeID string) {
|
||||||
|
inst, err := loadInstances(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("sendSyncMessage: loadInstances error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list := make([]InstanceInfo, 0, len(inst))
|
||||||
|
for _, info := range inst {
|
||||||
|
list = append(list, *info)
|
||||||
|
}
|
||||||
|
if err := sendMessage(WSMessage{Action: "sync", NodeID: nodeID, Instances: list}); err != nil {
|
||||||
|
log.Printf("sendSyncMessage error: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("sendSyncMessage: sent %d local instances", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// UI notifier system: broadcast activation results to all connected UI clients
|
// UI notifier system: broadcast activation results to all connected UI clients
|
||||||
type uiNotifier func(msg map[string]interface{})
|
type uiNotifier func(msg map[string]interface{})
|
||||||
|
|
||||||
@@ -99,22 +159,134 @@ func notifyUI(msg map[string]interface{}) {
|
|||||||
|
|
||||||
log.Printf("notifyUI: broadcasting to %d UI clients", len(notifiers))
|
log.Printf("notifyUI: broadcasting to %d UI clients", len(notifiers))
|
||||||
for _, fn := range notifiers {
|
for _, fn := range notifiers {
|
||||||
go fn(msg)
|
go func(notify uiNotifier) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in notifyUI goroutine: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
notify(msg)
|
||||||
|
}(fn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey string) {
|
// directDialer returns a websocket.Dialer that never uses a proxy.
|
||||||
setHeadscaleConfig(headscaleURL, headscaleAuthKey)
|
func directDialer() *websocket.Dialer {
|
||||||
|
d := websocket.DefaultDialer
|
||||||
|
return &websocket.Dialer{
|
||||||
|
Proxy: nil,
|
||||||
|
HandshakeTimeout: d.HandshakeTimeout,
|
||||||
|
ReadBufferSize: d.ReadBufferSize,
|
||||||
|
WriteBufferSize: d.WriteBufferSize,
|
||||||
|
EnableCompression: d.EnableCompression,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyOnlyDialer returns a websocket.Dialer that always uses the configured
|
||||||
|
// proxy URL, ignoring the current auto-proxy state.
|
||||||
|
func proxyOnlyDialer(cfg *AgentConfig) *websocket.Dialer {
|
||||||
|
d := websocket.DefaultDialer
|
||||||
|
u := proxyURL(cfg)
|
||||||
|
if u == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return &websocket.Dialer{
|
||||||
|
Proxy: func(*http.Request) (*url.URL, error) { return u, nil },
|
||||||
|
HandshakeTimeout: d.HandshakeTimeout,
|
||||||
|
ReadBufferSize: d.ReadBufferSize,
|
||||||
|
WriteBufferSize: d.WriteBufferSize,
|
||||||
|
EnableCompression: d.EnableCompression,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialServerWithFallback attempts to connect to the WebSocket server according
|
||||||
|
// to the configured proxy mode. In auto mode it tries direct connections first
|
||||||
|
// and falls back to the proxy after a few failures.
|
||||||
|
func dialServerWithFallback(cfg *AgentConfig, serverAddr string, headers http.Header) (*websocket.Conn, error) {
|
||||||
|
mode := proxyMode(cfg)
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case ProxyModeDisabled:
|
||||||
|
conn, _, err := directDialer().Dial(serverAddr, headers)
|
||||||
|
return conn, err
|
||||||
|
case ProxyModeEnabled:
|
||||||
|
conn, _, err := websocketDialer(cfg).Dial(serverAddr, headers)
|
||||||
|
return conn, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto mode.
|
||||||
|
u := proxyURL(cfg)
|
||||||
|
if u == nil {
|
||||||
|
conn, _, err := directDialer().Dial(serverAddr, headers)
|
||||||
|
return conn, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are currently in auto-proxy mode, try direct again only after the
|
||||||
|
// lock duration has expired. Otherwise stay on the proxy.
|
||||||
|
if IsProxyActive() {
|
||||||
|
if canRetryDirect() {
|
||||||
|
log.Println("Auto proxy: retrying direct connection after lock period")
|
||||||
|
conn, _, err := directDialer().Dial(serverAddr, headers)
|
||||||
|
if err == nil {
|
||||||
|
if setProxyActive(false) {
|
||||||
|
log.Println("Auto proxy: switched back to direct connection")
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
log.Printf("Auto proxy: direct retry failed (%v), keeping proxy", err)
|
||||||
|
}
|
||||||
|
conn, _, err := proxyOnlyDialer(cfg).Dial(serverAddr, headers)
|
||||||
|
if err != nil {
|
||||||
|
// Proxy failed too: clear the active flag so next round restarts the
|
||||||
|
// direct-first fallback sequence.
|
||||||
|
setProxyActive(false)
|
||||||
|
}
|
||||||
|
return conn, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not currently in proxy mode: try direct up to 3 times, then proxy.
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
conn, _, err := directDialer().Dial(serverAddr, headers)
|
||||||
|
if err == nil {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
log.Printf("Auto proxy: direct attempt %d/%d failed: %v", i+1, 3, err)
|
||||||
|
if i < 2 {
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Auto proxy: falling back to proxy")
|
||||||
|
conn, _, err := proxyOnlyDialer(cfg).Dial(serverAddr, headers)
|
||||||
|
if err == nil {
|
||||||
|
if setProxyActive(true) {
|
||||||
|
log.Println("Auto proxy: switched to proxy")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("Auto proxy: proxy fallback failed: %v", err)
|
||||||
|
}
|
||||||
|
return conn, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func startWebSocket(cfg *AgentConfig, nodeID, dataDir string) {
|
||||||
|
setHeadscaleConfig(cfg.HeadscaleURL, cfg.HeadscaleAuthKey)
|
||||||
|
serverAddr := cfg.Server
|
||||||
|
|
||||||
for {
|
for {
|
||||||
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil)
|
token, _ := loadNodeToken(dataDir)
|
||||||
|
headers := http.Header{}
|
||||||
|
if token != "" {
|
||||||
|
headers.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := dialServerWithFallback(cfg, serverAddr, headers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("WS connect error: %v, retrying in 5s...", err)
|
log.Printf("WS connect error: %v, retrying in 5s...", err)
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("WS connected to %s", serverAddr)
|
log.Printf("WS connected to %s (token=%v, proxy=%v)", serverAddr, token != "", IsProxyActive())
|
||||||
|
|
||||||
mainConnMu.Lock()
|
mainConnMu.Lock()
|
||||||
mainConn = conn
|
mainConn = conn
|
||||||
@@ -136,9 +308,11 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
|
|||||||
log.Println("Waiting for activation...")
|
log.Println("Waiting for activation...")
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Already activated as %s", act.StudentName)
|
log.Printf("Already activated as %s", act.StudentName)
|
||||||
// If already activated and we have credentials, ensure VPN is up.
|
// If already activated, ensure VPN is up. The pre-auth key is
|
||||||
|
// one-time only, so on restart we rely on the persisted tailscaled
|
||||||
|
// state; tailscale up without an authkey reuses existing state.
|
||||||
hsURL, hsKey := getHeadscaleConfig()
|
hsURL, hsKey := getHeadscaleConfig()
|
||||||
if hsURL != "" && hsKey != "" {
|
if hsURL != "" {
|
||||||
go startTailscaleAndReport(dataDir, nodeID, hsURL, hsKey)
|
go startTailscaleAndReport(dataDir, nodeID, hsURL, hsKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,9 +356,30 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) {
|
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in handleMessage (action=%s): %v", msg.Action, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
switch msg.Action {
|
switch msg.Action {
|
||||||
|
case "set_token":
|
||||||
|
if msg.Token != "" {
|
||||||
|
if err := saveNodeToken(dataDir, msg.Token); err != nil {
|
||||||
|
log.Printf("saveNodeToken error: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Node token saved")
|
||||||
|
}
|
||||||
|
}
|
||||||
case "activated":
|
case "activated":
|
||||||
log.Printf("handleMessage: activated received, student=%s", msg.StudentName)
|
log.Printf("handleMessage: activated received, student=%s", msg.StudentName)
|
||||||
|
if msg.Token != "" {
|
||||||
|
if err := saveNodeToken(dataDir, msg.Token); err != nil {
|
||||||
|
log.Printf("saveNodeToken error: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Node token saved on activation")
|
||||||
|
}
|
||||||
|
}
|
||||||
if msg.StudentName != "" {
|
if msg.StudentName != "" {
|
||||||
act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code}
|
act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code}
|
||||||
if err := saveActivation(dataDir, act); err != nil {
|
if err := saveActivation(dataDir, act); err != nil {
|
||||||
@@ -194,7 +389,9 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The server also sends Headscale credentials on activation.
|
// The server sends Headscale credentials on activation.
|
||||||
|
// The pre-auth key is ephemeral and must be used immediately;
|
||||||
|
// it is intentionally NOT persisted to the config file.
|
||||||
if msg.HeadscaleURL != "" && msg.HeadscaleAuthKey != "" {
|
if msg.HeadscaleURL != "" && msg.HeadscaleAuthKey != "" {
|
||||||
setHeadscaleConfig(msg.HeadscaleURL, msg.HeadscaleAuthKey)
|
setHeadscaleConfig(msg.HeadscaleURL, msg.HeadscaleAuthKey)
|
||||||
cfg, _, err := loadOrCreateConfig(dataDir)
|
cfg, _, err := loadOrCreateConfig(dataDir)
|
||||||
@@ -202,11 +399,11 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
log.Printf("loadOrCreateConfig error: %v", err)
|
log.Printf("loadOrCreateConfig error: %v", err)
|
||||||
} else {
|
} else {
|
||||||
cfg.HeadscaleURL = msg.HeadscaleURL
|
cfg.HeadscaleURL = msg.HeadscaleURL
|
||||||
cfg.HeadscaleAuthKey = msg.HeadscaleAuthKey
|
// Intentionally do not save HeadscaleAuthKey: it is one-time only.
|
||||||
if err := saveConfig(dataDir, cfg); err != nil {
|
if err := saveConfig(dataDir, cfg); err != nil {
|
||||||
log.Printf("saveConfig error: %v", err)
|
log.Printf("saveConfig error: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Saved Headscale config received from server")
|
log.Printf("Saved Headscale URL received from server (auth key not persisted)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
go startTailscaleAndReport(dataDir, nodeID, msg.HeadscaleURL, msg.HeadscaleAuthKey)
|
go startTailscaleAndReport(dataDir, nodeID, msg.HeadscaleURL, msg.HeadscaleAuthKey)
|
||||||
@@ -217,21 +414,38 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
"studentName": msg.StudentName,
|
"studentName": msg.StudentName,
|
||||||
})
|
})
|
||||||
case "registered":
|
case "registered":
|
||||||
// Server acknowledged our register message; nothing to do.
|
if msg.ServerVersion != "" {
|
||||||
|
setServerAgentVersion(msg.ServerVersion)
|
||||||
|
log.Printf("Server agent version: %s", msg.ServerVersion)
|
||||||
|
}
|
||||||
|
// After registration, send a sync request with our local instances so
|
||||||
|
// the server can reconcile any changes that happened while offline.
|
||||||
|
if act, err := loadActivation(dataDir); err == nil && act.Activated {
|
||||||
|
go sendSyncMessage(dataDir, nodeID)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
case "start_vpn":
|
case "start_vpn":
|
||||||
log.Printf("Server requested VPN start")
|
log.Printf("Server requested VPN start")
|
||||||
hsURL, hsKey := getHeadscaleConfig()
|
hsURL, hsKey := getHeadscaleConfig()
|
||||||
if hsURL == "" || hsKey == "" {
|
if hsURL == "" || hsKey == "" {
|
||||||
log.Printf("Cannot start VPN: headscale config missing")
|
log.Printf("Cannot start VPN: headscale config missing")
|
||||||
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"})
|
go sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in start_vpn goroutine: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
|
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("start_vpn error: %v", err)
|
log.Printf("start_vpn error: %v", err)
|
||||||
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()})
|
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()})
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
@@ -243,11 +457,22 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
log.Printf("Sent tailscale_ip to server: %s", ip)
|
log.Printf("Sent tailscale_ip to server: %s", ip)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
})
|
||||||
}()
|
}()
|
||||||
case "stop_vpn":
|
case "stop_vpn":
|
||||||
log.Printf("Server requested VPN stop")
|
log.Printf("Server requested VPN stop")
|
||||||
stopTailscale()
|
stopTailscale()
|
||||||
|
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})
|
sendMessage(WSMessage{Action: "vpn_stopped", NodeID: nodeID})
|
||||||
|
}()
|
||||||
case "activation_failed":
|
case "activation_failed":
|
||||||
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
|
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
|
||||||
notifyUI(map[string]interface{}{
|
notifyUI(map[string]interface{}{
|
||||||
@@ -256,93 +481,166 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
})
|
})
|
||||||
case "start":
|
case "start":
|
||||||
log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port)
|
log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port)
|
||||||
if err := upsertInstance(dataDir, &InstanceInfo{
|
|
||||||
ID: msg.InstanceID,
|
|
||||||
TemplateName: msg.Type,
|
|
||||||
Port: msg.Port,
|
|
||||||
Status: "starting",
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("upsertInstance error: %v", err)
|
|
||||||
}
|
|
||||||
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); err != nil {
|
|
||||||
log.Printf("writeCompose error: %v", err)
|
|
||||||
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
|
|
||||||
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil {
|
|
||||||
log.Printf("dockerComposeUp error: %v", err)
|
|
||||||
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
|
|
||||||
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
|
|
||||||
// so the studioE5 mu-plugin can compute the public URL from the Host header.
|
|
||||||
go func() {
|
go func() {
|
||||||
// Give the container a moment to be ready before touching wp-config.php
|
defer func() {
|
||||||
time.Sleep(2 * time.Second)
|
if r := recover(); r != nil {
|
||||||
if err := stripWordPressHardcodedURLs(dataDir, msg.InstanceID); err != nil {
|
log.Printf("PANIC in start goroutine instance=%s: %v", msg.InstanceID, r)
|
||||||
log.Printf("stripWordPressHardcodedURLs error: %v", err)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
// Ensure Tailscale is running so the server can reach the node
|
handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.InitScript, msg.Port)
|
||||||
go ensureTailscale(dataDir, nodeID, msg.Port)
|
}()
|
||||||
|
|
||||||
status := getInstanceStatus(dataDir, msg.InstanceID)
|
|
||||||
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
|
|
||||||
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
|
|
||||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
|
||||||
case "stop":
|
case "stop":
|
||||||
log.Printf("Stop instance %s", msg.InstanceID)
|
log.Printf("Stop instance %s", msg.InstanceID)
|
||||||
if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil {
|
go func() {
|
||||||
log.Printf("dockerComposeDown error: %v", err)
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
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 {
|
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
|
||||||
inst[msg.InstanceID].Status = "stopped"
|
inst[msg.InstanceID].Status = "stopped"
|
||||||
_ = saveInstances(dataDir, inst)
|
_ = saveInstances(dataDir, inst)
|
||||||
}
|
}
|
||||||
|
go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: msg.InstanceID})
|
||||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
}()
|
||||||
case "delete":
|
case "delete":
|
||||||
log.Printf("Delete instance %s", msg.InstanceID)
|
log.Printf("Delete instance %s", msg.InstanceID)
|
||||||
|
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)
|
dockerComposeRm(dataDir, msg.InstanceID)
|
||||||
removeInstance(dataDir, msg.InstanceID)
|
removeInstance(dataDir, msg.InstanceID)
|
||||||
|
go sendMessage(WSMessage{Action: "instance_deleted", InstanceID: msg.InstanceID})
|
||||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
}()
|
||||||
|
case "sync_response":
|
||||||
|
log.Printf("Sync response received: start=%d delete=%d stop=%d", len(msg.ToStart), len(msg.ToDelete), len(msg.ToStop))
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in sync_response goroutine: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for _, id := range msg.ToDelete {
|
||||||
|
handleMessage(mainConn, WSMessage{Action: "delete", InstanceID: id}, dataDir, nodeID)
|
||||||
|
}
|
||||||
|
for _, id := range msg.ToStop {
|
||||||
|
handleMessage(mainConn, WSMessage{Action: "stop", InstanceID: id}, dataDir, nodeID)
|
||||||
|
}
|
||||||
|
for _, info := range msg.ToStart {
|
||||||
|
handleMessage(mainConn, WSMessage{
|
||||||
|
Action: "start",
|
||||||
|
InstanceID: info.ID,
|
||||||
|
Type: info.Type,
|
||||||
|
Port: info.Port,
|
||||||
|
ComposeConfig: info.ComposeConfig,
|
||||||
|
InitScript: info.InitScript,
|
||||||
|
}, dataDir, nodeID)
|
||||||
|
}
|
||||||
|
}()
|
||||||
case "reset":
|
case "reset":
|
||||||
log.Printf("Reset instance %s", msg.InstanceID)
|
log.Printf("Reset instance %s", msg.InstanceID)
|
||||||
dockerComposeRm(dataDir, msg.InstanceID)
|
dockerComposeRm(dataDir, msg.InstanceID)
|
||||||
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); err != nil {
|
|
||||||
log.Printf("writeCompose error: %v", err)
|
|
||||||
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
|
|
||||||
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil {
|
|
||||||
log.Printf("dockerComposeUp error: %v", err)
|
|
||||||
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
|
|
||||||
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
|
|
||||||
// so the studioE5 mu-plugin can compute the public URL from the Host header.
|
|
||||||
go func() {
|
go func() {
|
||||||
// Give the container a moment to be ready before touching wp-config.php
|
defer func() {
|
||||||
time.Sleep(2 * time.Second)
|
if r := recover(); r != nil {
|
||||||
if err := stripWordPressHardcodedURLs(dataDir, msg.InstanceID); err != nil {
|
log.Printf("PANIC in reset goroutine instance=%s: %v", msg.InstanceID, r)
|
||||||
log.Printf("stripWordPressHardcodedURLs error: %v", err)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
// Ensure Tailscale is running so the server can reach the node
|
handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.InitScript, msg.Port)
|
||||||
go ensureTailscale(dataDir, nodeID, msg.Port)
|
}()
|
||||||
|
|
||||||
status := getInstanceStatus(dataDir, msg.InstanceID)
|
|
||||||
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
|
|
||||||
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
|
|
||||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
|
||||||
default:
|
default:
|
||||||
log.Printf("Unknown action: %s", msg.Action)
|
log.Printf("Unknown action: %s", msg.Action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleStartInstance(dataDir, nodeID, instanceID, instanceType, composeConfig, initScript string, port int) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in handleStartInstance instance=%s: %v", instanceID, r)
|
||||||
|
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: "error"})
|
||||||
|
sendMessage(WSMessage{Action: "instance_error", InstanceID: instanceID, Error: fmt.Sprintf("internal panic: %v", r)})
|
||||||
|
sendInstanceProgress(instanceID, "start", "0", "Erreur interne")
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("handleStartInstance begin: instance=%s type=%s port=%d dataDir=%s initScriptLen=%d", instanceID, instanceType, port, dataDir, len(initScript))
|
||||||
|
|
||||||
|
notifyInstanceProgress := func(percent, message string) {
|
||||||
|
sendInstanceProgress(instanceID, "start", percent, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = upsertInstance(dataDir, &InstanceInfo{
|
||||||
|
ID: instanceID,
|
||||||
|
TemplateName: instanceType,
|
||||||
|
Port: port,
|
||||||
|
Status: "starting",
|
||||||
|
})
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
notifyInstanceProgress("10", "Préparation de l'application...")
|
||||||
|
|
||||||
|
if err := writeCompose(dataDir, instanceID, composeConfig, port); err != nil {
|
||||||
|
log.Printf("writeCompose error: %v", err)
|
||||||
|
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: "error"})
|
||||||
|
sendMessage(WSMessage{Action: "instance_error", InstanceID: instanceID, Error: err.Error()})
|
||||||
|
notifyInstanceProgress("0", "Erreur de préparation")
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if initScript != "" {
|
||||||
|
if err := writeInitScript(dataDir, instanceID, initScript); err != nil {
|
||||||
|
log.Printf("writeInitScript error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifyInstanceProgress("30", "Configuration de l'application...")
|
||||||
|
|
||||||
|
if err := dockerComposeUp(dataDir, instanceID); err != nil {
|
||||||
|
log.Printf("dockerComposeUp error: %v", err)
|
||||||
|
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: "error"})
|
||||||
|
sendMessage(WSMessage{Action: "instance_error", InstanceID: instanceID, Error: err.Error()})
|
||||||
|
notifyInstanceProgress("0", "Erreur de démarrage")
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notifyInstanceProgress("60", "Application en cours de démarrage...")
|
||||||
|
|
||||||
|
ensureTailscale(dataDir, nodeID, port)
|
||||||
|
if err := setupTailscaleServe(port); err != nil {
|
||||||
|
log.Printf("setupTailscaleServe error: %v", err)
|
||||||
|
// Non-fatal: the instance may still work on Linux or if Windows
|
||||||
|
// userspace forwarding happens to function.
|
||||||
|
}
|
||||||
|
notifyInstanceProgress("80", "Connexion sécurisée active...")
|
||||||
|
|
||||||
|
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
|
||||||
|
// so the studioE5 mu-plugin can compute the public URL from the Host header.
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
if err := stripWordPressHardcodedURLs(dataDir, instanceID); err != nil {
|
||||||
|
log.Printf("stripWordPressHardcodedURLs error: %v", err)
|
||||||
|
}
|
||||||
|
notifyInstanceProgress("90", "Finalisation de l'installation...")
|
||||||
|
|
||||||
|
status := getInstanceStatus(dataDir, instanceID)
|
||||||
|
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: status})
|
||||||
|
sendMessage(WSMessage{Action: "instance_started", InstanceID: instanceID, Port: port})
|
||||||
|
notifyInstanceProgress("100", "Application prête")
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
}
|
||||||
|
|
||||||
func ensureTailscale(dataDir, nodeID string, port int) {
|
func ensureTailscale(dataDir, nodeID string, port int) {
|
||||||
hsURL, hsKey := getHeadscaleConfig()
|
hsURL, hsKey := getHeadscaleConfig()
|
||||||
if hsURL == "" || hsKey == "" {
|
if hsURL == "" || hsKey == "" {
|
||||||
@@ -356,6 +654,10 @@ func ensureTailscale(dataDir, nodeID string, port int) {
|
|||||||
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
|
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ensureTailscale start error: %v", err)
|
log.Printf("ensureTailscale start error: %v", err)
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
@@ -367,4 +669,8 @@ func ensureTailscale(dataDir, nodeID string, port int) {
|
|||||||
log.Printf("Sent tailscale_ip to server: %s", ip)
|
log.Printf("Sent tailscale_ip to server: %s", ip)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -24,6 +24,7 @@ services:
|
|||||||
container_name: studioe5-server
|
container_name: studioe5-server
|
||||||
volumes:
|
volumes:
|
||||||
- ./server/public:/app/public:ro
|
- ./server/public:/app/public:ro
|
||||||
|
- ./agent/VERSION:/app/agent-version:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
@@ -34,6 +35,8 @@ services:
|
|||||||
MAIN_DOMAIN: ${MAIN_DOMAIN}
|
MAIN_DOMAIN: ${MAIN_DOMAIN}
|
||||||
HEADSCALE_URL: ${HEADSCALE_URL}
|
HEADSCALE_URL: ${HEADSCALE_URL}
|
||||||
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
|
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
|
||||||
|
HEADSCALE_API_KEY: ${HEADSCALE_API_KEY}
|
||||||
|
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -66,7 +69,7 @@ services:
|
|||||||
devices:
|
devices:
|
||||||
- /dev/net/tun:/dev/net/tun
|
- /dev/net/tun:/dev/net/tun
|
||||||
environment:
|
environment:
|
||||||
TS_AUTHKEY: ${HEADSCALE_AUTH_KEY}
|
TS_AUTHKEY: ${HEADSCALE_RESOLVER_AUTH_KEY}
|
||||||
TS_LOGIN_SERVER: ${HEADSCALE_URL}
|
TS_LOGIN_SERVER: ${HEADSCALE_URL}
|
||||||
TS_EXTRA_ARGS: --login-server=${HEADSCALE_URL}
|
TS_EXTRA_ARGS: --login-server=${HEADSCALE_URL}
|
||||||
TS_STATE_DIR: /var/lib/tailscale
|
TS_STATE_DIR: /var/lib/tailscale
|
||||||
|
|||||||
@@ -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:
|
sqlite:
|
||||||
path: /etc/headscale/db.sqlite
|
path: /etc/headscale/db.sqlite
|
||||||
|
|
||||||
|
policy:
|
||||||
|
path: /etc/headscale/acl_policy.hujson
|
||||||
|
mode: file
|
||||||
|
|
||||||
log:
|
log:
|
||||||
format: text
|
format: text
|
||||||
level: info
|
level: info
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ docker build -t edubox-prestashop:9 .
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker tag edubox-prestashop:9 \
|
docker tag edubox-prestashop:9 \
|
||||||
151.80.60.98:3001/yacine/edubox/edubox-prestashop:9-edubox-9
|
gitea.alfrednobel.edudeploy.com/yacine/edubox/edubox-prestashop:9-edubox-9
|
||||||
docker push \
|
docker push \
|
||||||
151.80.60.98:3001/yacine/edubox/edubox-prestashop:9-edubox-9
|
gitea.alfrednobel.edudeploy.com/yacine/edubox/edubox-prestashop:9-edubox-9
|
||||||
```
|
```
|
||||||
|
|
||||||
## Patches appliqués
|
## Patches appliqués
|
||||||
@@ -75,7 +75,7 @@ Le template PrestaShop 9 dans `server/prisma/seed.ts` utilise cette image :
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
app:
|
app:
|
||||||
image: 151.80.60.98:3001/yacine/edubox/edubox-prestashop:9-edubox-8
|
image: gitea.alfrednobel.edudeploy.com/yacine/edubox/edubox-prestashop:9-edubox-8
|
||||||
```
|
```
|
||||||
|
|
||||||
## Mise à jour vers une nouvelle version de PrestaShop
|
## Mise à jour vers une nouvelle version de PrestaShop
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth, requireRole, getScopedEstablishmentId, forbidden } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const establishmentId = searchParams.get("establishmentId");
|
const requestedId = searchParams.get("establishmentId");
|
||||||
if (!establishmentId) return NextResponse.json({ error: "Missing establishmentId" }, { status: 400 });
|
const establishmentId = getScopedEstablishmentId(user, requestedId);
|
||||||
|
if (establishmentId instanceof NextResponse) return establishmentId;
|
||||||
|
|
||||||
|
const where = establishmentId ? { establishmentId } : {};
|
||||||
|
|
||||||
const classes = await prisma.class.findMany({
|
const classes = await prisma.class.findMany({
|
||||||
where: { establishmentId },
|
where,
|
||||||
include: { _count: { select: { students: true } } },
|
include: { _count: { select: { students: true } } },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
@@ -15,8 +22,19 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { establishmentId, name, level } = body;
|
const requestedId = body.establishmentId;
|
||||||
|
const establishmentId = getScopedEstablishmentId(user, requestedId);
|
||||||
|
if (establishmentId instanceof NextResponse) return establishmentId;
|
||||||
|
if (!establishmentId) return forbidden();
|
||||||
|
|
||||||
|
const { name, level } = body;
|
||||||
const cls = await prisma.class.create({
|
const cls = await prisma.class.create({
|
||||||
data: { establishmentId, name, level },
|
data: { establishmentId, name, level },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version";
|
||||||
|
|
||||||
const AGENT_VERSION = "0.3.0";
|
export async function GET(request: Request) {
|
||||||
const AGENT_BIN_NAME = "studioE5-agent";
|
const baseUrl = getBaseUrlFromRequest(request);
|
||||||
|
const info = getAgentVersionInfo(baseUrl);
|
||||||
export async function GET() {
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
version: AGENT_VERSION,
|
version: info.version,
|
||||||
windows: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}.exe`,
|
windows: info.downloadUrls.windows,
|
||||||
linux: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}`,
|
linux: info.downloadUrls.linux,
|
||||||
mac: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}-mac`,
|
mac: info.downloadUrls.mac,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { hashPassword } from "@/lib/auth";
|
import { hashPassword } from "@/lib/auth";
|
||||||
|
import { requireAuth, requireRole } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const where = user.role === "superadmin" ? {} : { id: user.establishmentId };
|
||||||
const establishments = await prisma.establishment.findMany({
|
const establishments = await prisma.establishment.findMany({
|
||||||
|
where,
|
||||||
include: { subscription: true, _count: { select: { users: true, classes: true } } },
|
include: { subscription: true, _count: { select: { users: true, classes: true } } },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
@@ -11,6 +17,12 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { name, slug, adminEmail, adminPassword } = body;
|
const { name, slug, adminEmail, adminPassword } = body;
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,54 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendToNode } from "@/lib/websocket";
|
import { sendToNode } from "@/lib/websocket";
|
||||||
|
import { authOptions } from "@/lib/auth-config";
|
||||||
|
|
||||||
|
async function requireAuth() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) return null;
|
||||||
|
return session.user as { id: string; email: string; role: string; establishmentId?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function userCanAccessNode(user: { role: string; establishmentId?: string }, node: any) {
|
||||||
|
if (user.role === "superadmin") return true;
|
||||||
|
const establishmentId = node?.student?.class?.establishmentId;
|
||||||
|
return establishmentId && establishmentId === user.establishmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function userCanAccessInstance(user: { role: string; establishmentId?: string }, instance: any) {
|
||||||
|
if (user.role === "superadmin") return true;
|
||||||
|
const establishmentId = instance?.node?.student?.class?.establishmentId;
|
||||||
|
return establishmentId && establishmentId === user.establishmentId;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const nodeId = searchParams.get("nodeId");
|
const nodeId = searchParams.get("nodeId");
|
||||||
const establishmentId = searchParams.get("establishmentId");
|
const establishmentIdParam = searchParams.get("establishmentId");
|
||||||
|
|
||||||
let where: any = {};
|
let where: any = {};
|
||||||
if (nodeId) where.nodeId = nodeId;
|
if (nodeId) where.nodeId = nodeId;
|
||||||
if (establishmentId) {
|
|
||||||
const classes = await prisma.class.findMany({ where: { establishmentId }, select: { id: true } });
|
if (user.role !== "superadmin") {
|
||||||
|
const classes = await prisma.class.findMany({
|
||||||
|
where: { establishmentId: user.establishmentId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const students = await prisma.student.findMany({
|
||||||
|
where: { classId: { in: classes.map((c) => c.id) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const nodes = await prisma.node.findMany({
|
||||||
|
where: { studentId: { in: students.map((s) => s.id) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
where.nodeId = { in: nodes.map((n) => n.id) };
|
||||||
|
} else if (establishmentIdParam) {
|
||||||
|
const classes = await prisma.class.findMany({ where: { establishmentId: establishmentIdParam }, select: { id: true } });
|
||||||
const students = await prisma.student.findMany({ where: { classId: { in: classes.map((c) => c.id) } }, select: { id: true } });
|
const students = await prisma.student.findMany({ where: { classId: { in: classes.map((c) => c.id) } }, select: { id: true } });
|
||||||
const nodes = await prisma.node.findMany({ where: { studentId: { in: students.map((s) => s.id) } }, select: { id: true } });
|
const nodes = await prisma.node.findMany({ where: { studentId: { in: students.map((s) => s.id) } }, select: { id: true } });
|
||||||
where.nodeId = { in: nodes.map((n) => n.id) };
|
where.nodeId = { in: nodes.map((n) => n.id) };
|
||||||
@@ -39,12 +77,8 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const enriched = instances.map((inst) => {
|
const enriched = instances.map((inst) => {
|
||||||
const domain = inst.node.student?.class.establishment?.domain;
|
const domain = inst.node.student?.class.establishment?.domain;
|
||||||
const publicUrl = domain
|
const publicUrl = domain ? `https://${inst.id}.${domain}` : null;
|
||||||
? `https://${inst.id}.${domain}`
|
const localUrl = inst.node.tailscaleIp ? `http://${inst.node.tailscaleIp}:${inst.port}` : null;
|
||||||
: null;
|
|
||||||
const localUrl = inst.node.tailscaleIp
|
|
||||||
? `http://${inst.node.tailscaleIp}:${inst.port}`
|
|
||||||
: null;
|
|
||||||
return {
|
return {
|
||||||
...inst,
|
...inst,
|
||||||
publicUrl,
|
publicUrl,
|
||||||
@@ -56,22 +90,32 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { nodeId, templateId, port } = body;
|
const { nodeId, templateId, port } = body;
|
||||||
|
if (!nodeId || !templateId) {
|
||||||
|
return NextResponse.json({ error: "Missing nodeId or templateId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const template = await prisma.template.findUnique({ where: { id: templateId } });
|
const template = await prisma.template.findUnique({ where: { id: templateId } });
|
||||||
if (!template) return NextResponse.json({ error: "Template not found" }, { status: 404 });
|
if (!template) return NextResponse.json({ error: "Template not found" }, { status: 404 });
|
||||||
|
|
||||||
const instance = await prisma.instance.create({
|
|
||||||
data: { nodeId, templateId, port: port || 8080, status: "stopped" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const node = await prisma.node.findUnique({
|
const node = await prisma.node.findUnique({
|
||||||
where: { id: nodeId },
|
where: { id: nodeId },
|
||||||
include: { student: { include: { class: { include: { establishment: true } } } } },
|
include: { student: { include: { class: { include: { establishment: true } } } } },
|
||||||
});
|
});
|
||||||
|
if (!node) return NextResponse.json({ error: "Node not found" }, { status: 404 });
|
||||||
|
if (!userCanAccessNode(user, node)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const domain = node?.student?.class.establishment?.domain;
|
const instance = await prisma.instance.create({
|
||||||
|
data: { nodeId, templateId, port: port || 8080, status: "stopped" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const domain = node.student?.class.establishment?.domain;
|
||||||
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
||||||
const publicUrl = domain ? `https://${publicDomain}` : null;
|
const publicUrl = domain ? `https://${publicDomain}` : null;
|
||||||
const sent = sendToNode(nodeId, {
|
const sent = sendToNode(nodeId, {
|
||||||
@@ -84,6 +128,13 @@ export async function POST(req: NextRequest) {
|
|||||||
.replace(/{INSTANCE_ID}/g, instance.id)
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||||
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
||||||
|
initScript: template.initScript
|
||||||
|
? template.initScript
|
||||||
|
.replace(/{PORT}/g, String(instance.port))
|
||||||
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
|
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||||
|
.replace(/{PUBLIC_DOMAIN}/g, "localhost")
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
@@ -94,17 +145,31 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest) {
|
export async function PATCH(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { id, action } = body;
|
const { id, action } = body;
|
||||||
const instance = await prisma.instance.findUnique({ where: { id }, include: { template: true, node: { include: { student: { include: { class: { include: { establishment: true } } } } } } } });
|
if (!id || !action) {
|
||||||
|
return NextResponse.json({ error: "Missing id or action" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = await prisma.instance.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { template: true, node: { include: { student: { include: { class: { include: { establishment: true } } } } } } },
|
||||||
|
});
|
||||||
if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
if (!userCanAccessInstance(user, instance)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const domain = instance.node.student?.class.establishment?.domain;
|
const domain = instance.node.student?.class.establishment?.domain;
|
||||||
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
||||||
const publicUrl = domain ? `https://${publicDomain}` : null;
|
const publicUrl = domain ? `https://${publicDomain}` : null;
|
||||||
|
|
||||||
if (action === "stop") {
|
if (action === "stop") {
|
||||||
sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
|
const sent = sendToNode(instance.nodeId, { action: "stop", instanceId: instance.id });
|
||||||
await prisma.instance.update({ where: { id }, data: { status: "stopped" } });
|
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
||||||
} else if (action === "start") {
|
} else if (action === "start") {
|
||||||
const sent = sendToNode(instance.nodeId, {
|
const sent = sendToNode(instance.nodeId, {
|
||||||
action: "start",
|
action: "start",
|
||||||
@@ -116,6 +181,13 @@ export async function PATCH(req: NextRequest) {
|
|||||||
.replace(/{INSTANCE_ID}/g, instance.id)
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||||
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
||||||
|
initScript: instance.template.initScript
|
||||||
|
? instance.template.initScript
|
||||||
|
.replace(/{PORT}/g, String(instance.port))
|
||||||
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
|
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||||
|
.replace(/{PUBLIC_DOMAIN}/g, "localhost")
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
||||||
} else if (action === "reset") {
|
} else if (action === "reset") {
|
||||||
@@ -129,18 +201,39 @@ export async function PATCH(req: NextRequest) {
|
|||||||
.replace(/{INSTANCE_ID}/g, instance.id)
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||||
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
||||||
|
initScript: instance.template.initScript
|
||||||
|
? instance.template.initScript
|
||||||
|
.replace(/{PORT}/g, String(instance.port))
|
||||||
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
|
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||||
|
.replace(/{PUBLIC_DOMAIN}/g, "localhost")
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
const instance = await prisma.instance.findUnique({ where: { id } });
|
|
||||||
|
const instance = await prisma.instance.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { node: { include: { student: { include: { class: { include: { establishment: true } } } } } } },
|
||||||
|
});
|
||||||
|
if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
if (!userCanAccessInstance(user, instance)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
if (instance) sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
|
if (instance) sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
|
||||||
await prisma.instance.delete({ where: { id } });
|
await prisma.instance.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { sendToNode } from "@/lib/websocket";
|
import { sendToNode } from "@/lib/websocket";
|
||||||
|
|
||||||
|
function getBearerToken(req: NextRequest): string | null {
|
||||||
|
const auth = req.headers.get("authorization") || "";
|
||||||
|
const match = auth.match(/^Bearer\s+(\S+)$/i);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const apiKey = process.env.INTERNAL_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json({ error: "Internal API key not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
const token = getBearerToken(req);
|
||||||
|
if (!token || token !== apiKey) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { nodeId, message } = body;
|
const { nodeId, message } = body;
|
||||||
if (!nodeId || !message) {
|
if (!nodeId || !message) {
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth, getScopedEstablishmentId, forbidden } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const establishmentId = searchParams.get("establishmentId");
|
const requestedId = searchParams.get("establishmentId");
|
||||||
|
const establishmentId = getScopedEstablishmentId(user, requestedId);
|
||||||
|
if (establishmentId instanceof NextResponse) return establishmentId;
|
||||||
|
|
||||||
let where: any = {};
|
let where: any = {};
|
||||||
if (establishmentId) {
|
if (establishmentId) {
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
function getBearerToken(req: NextRequest): string | null {
|
||||||
|
const auth = req.headers.get("authorization") || "";
|
||||||
|
const match = auth.match(/^Bearer\s+(\S+)$/i);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const apiKey = process.env.INTERNAL_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json({ error: "Internal API key not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
const token = getBearerToken(req);
|
||||||
|
if (!token || token !== apiKey) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const subdomain = searchParams.get("subdomain");
|
const subdomain = searchParams.get("subdomain");
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { generateUniqueActivationCode } from "@/lib/activation";
|
||||||
function generateCode(length = 6) {
|
|
||||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
let code = "";
|
|
||||||
for (let i = 0; i < length; i++) code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
@@ -31,13 +25,15 @@ export async function GET(req: NextRequest) {
|
|||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { classId, firstName, lastName, email } = body;
|
const { classId, firstName, lastName, email } = body;
|
||||||
|
const { code, expiresAt } = await generateUniqueActivationCode();
|
||||||
const student = await prisma.student.create({
|
const student = await prisma.student.create({
|
||||||
data: {
|
data: {
|
||||||
classId,
|
classId,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
email,
|
email,
|
||||||
activationCode: generateCode(),
|
activationCode: code,
|
||||||
|
activationCodeExpiresAt: expiresAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json(student, { status: 201 });
|
return NextResponse.json(student, { status: 201 });
|
||||||
|
|||||||
@@ -1,25 +1,57 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth, requireRole, forbidden } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
function templateAccessWhere(user: { role: string; establishmentId?: string }, establishmentId?: string | null) {
|
||||||
|
if (user.role === "superadmin" && establishmentId) {
|
||||||
|
return { OR: [{ isPublic: true }, { establishmentId }] };
|
||||||
|
}
|
||||||
|
if (user.establishmentId) {
|
||||||
|
return { OR: [{ isPublic: true }, { establishmentId: user.establishmentId }] };
|
||||||
|
}
|
||||||
|
return { isPublic: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canManageTemplate(user: { role: string; establishmentId?: string }, id: string) {
|
||||||
|
if (user.role === "superadmin") return true;
|
||||||
|
const template = await prisma.template.findUnique({ where: { id } });
|
||||||
|
if (!template) return false;
|
||||||
|
return template.establishmentId === user.establishmentId;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const establishmentId = searchParams.get("establishmentId");
|
const requestedEst = searchParams.get("establishmentId");
|
||||||
|
|
||||||
|
const where = user.role === "superadmin" && !requestedEst ? {} : templateAccessWhere(user, requestedEst);
|
||||||
|
|
||||||
const templates = await prisma.template.findMany({
|
const templates = await prisma.template.findMany({
|
||||||
where: {
|
where,
|
||||||
OR: [
|
|
||||||
{ isPublic: true },
|
|
||||||
...(establishmentId ? [{ establishmentId }] : []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return NextResponse.json(templates);
|
return NextResponse.json(templates);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy } = body;
|
let { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy } = body;
|
||||||
|
|
||||||
|
if (user.role !== "superadmin") {
|
||||||
|
if (establishmentId && establishmentId !== user.establishmentId) {
|
||||||
|
return forbidden();
|
||||||
|
}
|
||||||
|
establishmentId = user.establishmentId;
|
||||||
|
}
|
||||||
|
|
||||||
const template = await prisma.template.create({
|
const template = await prisma.template.create({
|
||||||
data: { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy },
|
data: { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy },
|
||||||
});
|
});
|
||||||
@@ -27,16 +59,39 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(req: NextRequest) {
|
export async function PUT(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { id, ...data } = body;
|
const { id, ...data } = body;
|
||||||
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
|
|
||||||
|
if (!(await canManageTemplate(user, id))) return forbidden();
|
||||||
|
|
||||||
|
if (user.role !== "superadmin" && data.establishmentId && data.establishmentId !== user.establishmentId) {
|
||||||
|
return forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
const template = await prisma.template.update({ where: { id }, data });
|
const template = await prisma.template.update({ where: { id }, data });
|
||||||
return NextResponse.json(template);
|
return NextResponse.json(template);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
|
|
||||||
|
if (!(await canManageTemplate(user, id))) return forbidden();
|
||||||
|
|
||||||
await prisma.template.delete({ where: { id } });
|
await prisma.template.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { hashPassword } from "@/lib/auth";
|
import { hashPassword } from "@/lib/auth";
|
||||||
|
import { requireAuth, requireRole, forbidden } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const establishmentId = searchParams.get("establishmentId");
|
const establishmentId = searchParams.get("establishmentId");
|
||||||
const role = searchParams.get("role");
|
const role = searchParams.get("role");
|
||||||
|
|
||||||
|
if (user.role !== "superadmin") {
|
||||||
|
if (establishmentId && establishmentId !== user.establishmentId) {
|
||||||
|
return forbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
if (establishmentId) where.establishmentId = establishmentId;
|
if (establishmentId) where.establishmentId = establishmentId;
|
||||||
|
else if (user.role !== "superadmin") where.establishmentId = user.establishmentId;
|
||||||
if (role) where.role = role;
|
if (role) where.role = role;
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
@@ -19,23 +30,56 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { email, password, role, establishmentId } = body;
|
const { email, password, role, establishmentId } = body;
|
||||||
const user = await prisma.user.create({
|
|
||||||
|
if (!email || !password || !role) {
|
||||||
|
return NextResponse.json({ error: "Missing email, password or role" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === "admin") {
|
||||||
|
if (role === "superadmin") return forbidden();
|
||||||
|
if (establishmentId && establishmentId !== user.establishmentId) return forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalEstablishmentId = user.role === "superadmin" ? establishmentId : user.establishmentId;
|
||||||
|
|
||||||
|
const newUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
password: await hashPassword(password),
|
password: await hashPassword(password),
|
||||||
role,
|
role,
|
||||||
establishmentId,
|
establishmentId: finalEstablishmentId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json(user, { status: 201 });
|
return NextResponse.json(newUser, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
|
|
||||||
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
if (user.role === "admin") {
|
||||||
|
if (target.role === "superadmin") return forbidden();
|
||||||
|
if (target.establishmentId !== user.establishmentId) return forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.user.delete({ where: { id } });
|
await prisma.user.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,49 @@
|
|||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version";
|
||||||
const AGENT_VERSION = "0.3.0";
|
import { headers } from "next/headers";
|
||||||
const AGENT_BIN_NAME = "studioE5-agent";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default function DownloadPage() {
|
export default async function DownloadPage() {
|
||||||
|
const h = await headers();
|
||||||
|
const proto = h.get("x-forwarded-proto") ?? "https";
|
||||||
|
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "";
|
||||||
|
const baseUrl = host ? `${proto}://${host}` : undefined;
|
||||||
|
const info = getAgentVersionInfo(baseUrl);
|
||||||
|
const { version, downloadUrls } = info;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-3xl font-bold">Téléchargements Agent</h1>
|
<h1 className="text-3xl font-bold">Téléchargements Agent</h1>
|
||||||
<p className="text-sm text-muted-foreground">Version actuelle : <strong>{AGENT_VERSION}</strong></p>
|
<p className="text-sm text-muted-foreground">Version actuelle : <strong>{version}</strong></p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Windows</CardTitle>
|
<CardTitle>Windows (.exe)</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Windows (64 bits)</p>
|
<p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Windows (64 bits). Nécessite Tailscale installé séparément ou les binaires dans <code>tailscale-bin/windows/</code>.</p>
|
||||||
<a href={`/${AGENT_BIN_NAME}-v${AGENT_VERSION}.exe`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.exe)</a>
|
<a href={downloadUrls.windows} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.exe)</a>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Windows (archive)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">Archive complète incluant l'agent, Tailscale et le README Windows.</p>
|
||||||
|
<a href={downloadUrls.windowsZip} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.zip)</a>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Linux</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Linux (64 bits).</p>
|
||||||
|
<a href={downloadUrls.linux} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (Linux)</a>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,17 +3,9 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { authOptions } from "@/lib/auth-config";
|
import { authOptions } from "@/lib/auth-config";
|
||||||
|
import { generateUniqueActivationCode } from "@/lib/activation";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
function generateCode(): string {
|
|
||||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
let code = "";
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
}
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteStudent(formData: FormData) {
|
export async function deleteStudent(formData: FormData) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user) redirect("/login");
|
if (!session?.user) redirect("/login");
|
||||||
@@ -54,9 +46,10 @@ export async function generateActivationCodeAction(formData: FormData) {
|
|||||||
|
|
||||||
if (!student) return;
|
if (!student) return;
|
||||||
|
|
||||||
|
const { code, expiresAt } = await generateUniqueActivationCode();
|
||||||
await prisma.student.update({
|
await prisma.student.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { activationCode: generateCode() },
|
data: { activationCode: code, activationCodeExpiresAt: expiresAt },
|
||||||
});
|
});
|
||||||
|
|
||||||
redirect(`/dashboard/students/${id}`);
|
redirect(`/dashboard/students/${id}`);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { authOptions } from "@/lib/auth-config";
|
import { authOptions } from "@/lib/auth-config";
|
||||||
|
import { generateUniqueActivationCode } from "@/lib/activation";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -17,15 +18,6 @@ const schema = z.object({
|
|||||||
classId: z.string().min(1),
|
classId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
function generateActivationCode(): string {
|
|
||||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
let code = "";
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
}
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createStudent(formData: FormData) {
|
async function createStudent(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@@ -35,13 +27,15 @@ async function createStudent(formData: FormData) {
|
|||||||
const parsed = schema.safeParse(Object.fromEntries(formData));
|
const parsed = schema.safeParse(Object.fromEntries(formData));
|
||||||
if (!parsed.success) return;
|
if (!parsed.success) return;
|
||||||
|
|
||||||
|
const { code, expiresAt } = await generateUniqueActivationCode();
|
||||||
await prisma.student.create({
|
await prisma.student.create({
|
||||||
data: {
|
data: {
|
||||||
firstName: parsed.data.firstName,
|
firstName: parsed.data.firstName,
|
||||||
lastName: parsed.data.lastName,
|
lastName: parsed.data.lastName,
|
||||||
email: parsed.data.email,
|
email: parsed.data.email,
|
||||||
classId: parsed.data.classId,
|
classId: parsed.data.classId,
|
||||||
activationCode: generateActivationCode(),
|
activationCode: code,
|
||||||
|
activationCodeExpiresAt: expiresAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 { WebSocketServer, WebSocket } from "ws";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import type { IncomingMessage } from "http";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
import { createEphemeralPreAuthKey, getHeadscaleUserId } from "./headscale";
|
||||||
|
import { getAgentVersion } from "./agent-version";
|
||||||
|
|
||||||
interface NodeMessage {
|
interface NodeMessage {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -9,17 +13,77 @@ interface NodeMessage {
|
|||||||
type?: string;
|
type?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
composeConfig?: string;
|
composeConfig?: string;
|
||||||
|
initScript?: string;
|
||||||
studentName?: string;
|
studentName?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
tailscaleIp?: string;
|
tailscaleIp?: string;
|
||||||
|
token?: string;
|
||||||
|
serverVersion?: string;
|
||||||
|
instances?: Array<{ id: string; status: string; port: number; templateName?: string }>;
|
||||||
|
toStart?: Array<{ id: string; type: string; port: number; composeConfig?: string; initScript?: string }>;
|
||||||
|
toDelete?: string[];
|
||||||
|
toStop?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes = new Map<string, WebSocket>();
|
const nodes = new Map<string, WebSocket>();
|
||||||
|
|
||||||
|
interface AttemptWindow {
|
||||||
|
count: number;
|
||||||
|
firstAttempt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activationAttemptsByCode = new Map<string, AttemptWindow>();
|
||||||
|
const activationAttemptsByNode = new Map<string, AttemptWindow>();
|
||||||
|
const MAX_ACTIVATION_ATTEMPTS = 5;
|
||||||
|
const ACTIVATION_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
const HEADSCALE_USER = "studioe5";
|
||||||
|
const HEADSCALE_AGENT_TAG = "tag:student-agent";
|
||||||
|
const HEADSCALE_KEY_EXPIRATION_MINUTES = 15;
|
||||||
|
|
||||||
|
let headscaleUserIdCache: string | null = null;
|
||||||
|
|
||||||
|
function recordActivationAttempt(map: Map<string, AttemptWindow>, key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const win = map.get(key);
|
||||||
|
if (!win || now - win.firstAttempt > ACTIVATION_WINDOW_MS) {
|
||||||
|
map.set(key, { count: 1, firstAttempt: now });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
win.count++;
|
||||||
|
return win.count <= MAX_ACTIVATION_ATTEMPTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActivationAttempts(code: string, nodeId: string) {
|
||||||
|
activationAttemptsByCode.delete(code);
|
||||||
|
activationAttemptsByNode.delete(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateNodeToken(): string {
|
||||||
|
return randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBearerToken(req: IncomingMessage): string | null {
|
||||||
|
const auth = req.headers.authorization || "";
|
||||||
|
const match = auth.match(/^Bearer\s+(\S+)$/i);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(ws: WebSocket, code: number, reason: string) {
|
||||||
|
try {
|
||||||
|
ws.close(code, reason);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function initWebSocketServer(wss: WebSocketServer) {
|
export function initWebSocketServer(wss: WebSocketServer) {
|
||||||
wss.on("connection", (ws: WebSocket) => {
|
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
|
||||||
let nodeId: string | null = null;
|
let nodeId: string | null = null;
|
||||||
console.log("[WS] New connection");
|
let authenticated = false;
|
||||||
|
const token = getBearerToken(req);
|
||||||
|
|
||||||
|
console.log("[WS] New connection", token ? "(token provided)" : "(no token)");
|
||||||
|
|
||||||
ws.on("message", async (raw) => {
|
ws.on("message", async (raw) => {
|
||||||
try {
|
try {
|
||||||
@@ -27,19 +91,69 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
|||||||
console.log("[WS] Received:", msg.action, "from", msg.nodeId || nodeId);
|
console.log("[WS] Received:", msg.action, "from", msg.nodeId || nodeId);
|
||||||
|
|
||||||
if (msg.action === "register" && msg.nodeId) {
|
if (msg.action === "register" && msg.nodeId) {
|
||||||
nodeId = msg.nodeId;
|
const id = msg.nodeId;
|
||||||
nodes.set(nodeId, ws);
|
const existing = await prisma.node.findUnique({ where: { id } });
|
||||||
await prisma.node.upsert({
|
|
||||||
where: { id: nodeId },
|
if (token) {
|
||||||
update: { status: "online", lastSeen: new Date() },
|
// Token supplied: it must match the stored token for this node.
|
||||||
create: { id: nodeId, status: "online", lastSeen: new Date() },
|
if (!existing || existing.token !== token) {
|
||||||
|
console.log("[WS] Invalid token for node", id);
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.action === "activate" && msg.code && msg.nodeId) {
|
if (msg.action === "activate" && msg.code && msg.nodeId) {
|
||||||
nodeId = msg.nodeId;
|
const id = msg.nodeId;
|
||||||
|
nodeId = id;
|
||||||
|
|
||||||
|
if (!recordActivationAttempt(activationAttemptsByCode, msg.code) ||
|
||||||
|
!recordActivationAttempt(activationAttemptsByNode, id)) {
|
||||||
|
console.log("[WS] Too many activation attempts for code/node", msg.code, id);
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Too many attempts" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.node.findUnique({ where: { id } });
|
||||||
|
if (existing && existing.token && (!authenticated || nodeId !== id)) {
|
||||||
|
console.log("[WS] Node already activated and not authenticated:", id);
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Node already activated" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const student = await prisma.student.findUnique({
|
const student = await prisma.student.findUnique({
|
||||||
where: { activationCode: msg.code },
|
where: { activationCode: msg.code },
|
||||||
});
|
});
|
||||||
@@ -48,23 +162,97 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
|||||||
ws.send(JSON.stringify({ action: "activation_failed", error: "Invalid code" }));
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Invalid code" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!student.activationCodeExpiresAt || student.activationCodeExpiresAt < new Date()) {
|
||||||
|
console.log("[WS] Expired code:", msg.code);
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Code expired" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newToken = generateNodeToken();
|
||||||
await prisma.node.upsert({
|
await prisma.node.upsert({
|
||||||
where: { id: nodeId },
|
where: { id },
|
||||||
update: { studentId: student.id, status: "online", lastSeen: new Date() },
|
update: {
|
||||||
create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() },
|
studentId: student.id,
|
||||||
|
status: "online",
|
||||||
|
lastSeen: new Date(),
|
||||||
|
token: newToken,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id,
|
||||||
|
studentId: student.id,
|
||||||
|
status: "online",
|
||||||
|
lastSeen: new Date(),
|
||||||
|
token: newToken,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
console.log("[WS] Activated:", student.firstName, student.lastName, "on", nodeId);
|
|
||||||
|
// Invalidate the activation code so it cannot be reused.
|
||||||
|
await prisma.student.update({
|
||||||
|
where: { id: student.id },
|
||||||
|
data: { activationCode: null, activationCodeExpiresAt: null },
|
||||||
|
});
|
||||||
|
clearActivationAttempts(msg.code, id);
|
||||||
|
|
||||||
|
authenticated = true;
|
||||||
|
const previous = nodes.get(id);
|
||||||
|
if (previous && previous !== ws && previous.readyState === WebSocket.OPEN) {
|
||||||
|
console.log("[WS] Superseding previous connection for", id);
|
||||||
|
previous.close(1008, "superseded");
|
||||||
|
}
|
||||||
|
nodes.set(id, ws);
|
||||||
|
const headscaleUrl = process.env.HEADSCALE_URL;
|
||||||
|
const headscaleApiKey = process.env.HEADSCALE_API_KEY;
|
||||||
|
const reusableAuthKey = process.env.HEADSCALE_AUTH_KEY;
|
||||||
|
|
||||||
|
if (!headscaleUrl) {
|
||||||
|
console.log("[WS] HEADSCALE_URL missing");
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Server misconfiguration" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let headscaleAuthKey: string;
|
||||||
|
try {
|
||||||
|
if (headscaleApiKey) {
|
||||||
|
if (!headscaleUserIdCache) {
|
||||||
|
headscaleUserIdCache = await getHeadscaleUserId(headscaleUrl, headscaleApiKey, HEADSCALE_USER);
|
||||||
|
}
|
||||||
|
headscaleAuthKey = await createEphemeralPreAuthKey(headscaleUrl, headscaleApiKey, headscaleUserIdCache, {
|
||||||
|
expirationMinutes: HEADSCALE_KEY_EXPIRATION_MINUTES,
|
||||||
|
aclTags: [HEADSCALE_AGENT_TAG],
|
||||||
|
});
|
||||||
|
console.log("[WS] Generated ephemeral Headscale key for", id);
|
||||||
|
} else if (reusableAuthKey) {
|
||||||
|
console.log("[WS] HEADSCALE_API_KEY not set, falling back to reusable HEADSCALE_AUTH_KEY");
|
||||||
|
headscaleAuthKey = reusableAuthKey;
|
||||||
|
} else {
|
||||||
|
console.log("[WS] No Headscale key available");
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Server misconfiguration" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[WS] Failed to create ephemeral Headscale key:", err);
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Failed to create VPN key" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[WS] Activated:", student.firstName, student.lastName, "on", id);
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
action: "activated",
|
action: "activated",
|
||||||
studentId: student.id,
|
studentId: student.id,
|
||||||
studentName: `${student.firstName} ${student.lastName}`,
|
studentName: `${student.firstName} ${student.lastName}`,
|
||||||
headscaleUrl: process.env.HEADSCALE_URL,
|
headscaleUrl,
|
||||||
headscaleAuthKey: process.env.HEADSCALE_AUTH_KEY,
|
headscaleAuthKey,
|
||||||
|
token: newToken,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.action === "heartbeat" && nodeId) {
|
if (!authenticated || !nodeId) {
|
||||||
|
console.log("[WS] Unauthenticated message", msg.action, "ignored");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.action === "heartbeat") {
|
||||||
await prisma.node.upsert({
|
await prisma.node.upsert({
|
||||||
where: { id: nodeId },
|
where: { id: nodeId },
|
||||||
update: { lastSeen: new Date() },
|
update: { lastSeen: new Date() },
|
||||||
@@ -73,7 +261,7 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.action === "tailscale_ip" && nodeId && msg.tailscaleIp) {
|
if (msg.action === "tailscale_ip" && msg.tailscaleIp) {
|
||||||
await prisma.node.update({
|
await prisma.node.update({
|
||||||
where: { id: nodeId },
|
where: { id: nodeId },
|
||||||
data: { tailscaleIp: msg.tailscaleIp },
|
data: { tailscaleIp: msg.tailscaleIp },
|
||||||
@@ -82,19 +270,90 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.action === "sync" && msg.instances) {
|
||||||
|
const serverInstances = await prisma.instance.findMany({
|
||||||
|
where: { nodeId },
|
||||||
|
include: { template: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const localIds = new Set(msg.instances.map((i) => i.id));
|
||||||
|
const serverIds = new Set(serverInstances.map((i) => i.id));
|
||||||
|
|
||||||
|
const toDelete = msg.instances
|
||||||
|
.filter((i) => !serverIds.has(i.id))
|
||||||
|
.map((i) => i.id);
|
||||||
|
|
||||||
|
const toStop = msg.instances
|
||||||
|
.filter((i) => {
|
||||||
|
const server = serverInstances.find((s) => s.id === i.id);
|
||||||
|
return server && server.status === "stopped" && i.status === "running";
|
||||||
|
})
|
||||||
|
.map((i) => i.id);
|
||||||
|
|
||||||
|
const toStart = serverInstances
|
||||||
|
.filter((s) => !localIds.has(s.id))
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
type: s.template.type,
|
||||||
|
port: s.port,
|
||||||
|
composeConfig: s.template.composeConfig,
|
||||||
|
initScript: s.template.initScript ?? undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[WS] Sync for",
|
||||||
|
nodeId,
|
||||||
|
"- toStart:",
|
||||||
|
toStart.length,
|
||||||
|
"toDelete:",
|
||||||
|
toDelete.length,
|
||||||
|
"toStop:",
|
||||||
|
toStop.length
|
||||||
|
);
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
action: "sync_response",
|
||||||
|
toStart,
|
||||||
|
toDelete,
|
||||||
|
toStop,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.action === "instance_started" && msg.instanceId) {
|
if (msg.action === "instance_started" && msg.instanceId) {
|
||||||
await prisma.instance.update({
|
const { count } = await prisma.instance.updateMany({
|
||||||
where: { id: msg.instanceId },
|
where: { id: msg.instanceId },
|
||||||
data: { status: "running" },
|
data: { status: "running" },
|
||||||
});
|
});
|
||||||
|
if (count) console.log("[WS] Instance started:", msg.instanceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.action === "instance_stopped" && msg.instanceId) {
|
||||||
|
const { count } = await prisma.instance.updateMany({
|
||||||
|
where: { id: msg.instanceId },
|
||||||
|
data: { status: "stopped" },
|
||||||
|
});
|
||||||
|
if (count) console.log("[WS] Instance stopped:", msg.instanceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.action === "instance_deleted" && msg.instanceId) {
|
||||||
|
const { count } = await prisma.instance.deleteMany({
|
||||||
|
where: { id: msg.instanceId },
|
||||||
|
});
|
||||||
|
if (count) console.log("[WS] Instance deleted:", msg.instanceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.action === "instance_error" && msg.instanceId) {
|
if (msg.action === "instance_error" && msg.instanceId) {
|
||||||
await prisma.instance.update({
|
const { count } = await prisma.instance.updateMany({
|
||||||
where: { id: msg.instanceId },
|
where: { id: msg.instanceId },
|
||||||
data: { status: "error" },
|
data: { status: "error" },
|
||||||
});
|
});
|
||||||
|
if (count) console.log("[WS] Instance error:", msg.instanceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Template" ADD COLUMN "initScript" TEXT;
|
||||||
@@ -57,6 +57,7 @@ model Student {
|
|||||||
lastName String
|
lastName String
|
||||||
email String
|
email String
|
||||||
activationCode String? @unique
|
activationCode String? @unique
|
||||||
|
activationCodeExpiresAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
nodes Node[]
|
nodes Node[]
|
||||||
}
|
}
|
||||||
@@ -65,6 +66,7 @@ model Node {
|
|||||||
id String @id
|
id String @id
|
||||||
studentId String?
|
studentId String?
|
||||||
student Student? @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
student Student? @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||||
|
token String? @unique
|
||||||
tailscaleIp String?
|
tailscaleIp String?
|
||||||
status String @default("offline")
|
status String @default("offline")
|
||||||
lastSeen DateTime?
|
lastSeen DateTime?
|
||||||
@@ -89,6 +91,7 @@ model Template {
|
|||||||
type String
|
type String
|
||||||
dockerImage String
|
dockerImage String
|
||||||
composeConfig String
|
composeConfig String
|
||||||
|
initScript String?
|
||||||
isPublic Boolean @default(true)
|
isPublic Boolean @default(true)
|
||||||
establishmentId String?
|
establishmentId String?
|
||||||
establishment Establishment? @relation(fields: [establishmentId], references: [id], onDelete: Cascade)
|
establishment Establishment? @relation(fields: [establishmentId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
+67
-3
@@ -1,5 +1,7 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -19,6 +21,11 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const wpReadyInitScript = fs.readFileSync(
|
||||||
|
path.join(__dirname, "../templates/wordpress-ready/wp-init.sh"),
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
const templates = [
|
const templates = [
|
||||||
{
|
{
|
||||||
name: "WordPress latest vierge",
|
name: "WordPress latest vierge",
|
||||||
@@ -50,10 +57,34 @@ async function main() {
|
|||||||
dbPassword: "wordpress",
|
dbPassword: "wordpress",
|
||||||
dbRootPassword: "rootpassword",
|
dbRootPassword: "rootpassword",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "WordPress latest prêt à l'emploi",
|
||||||
|
type: "wordpress-ready",
|
||||||
|
dockerImage: "wordpress:latest",
|
||||||
|
dbImage: "mariadb:10.11",
|
||||||
|
dbName: "wordpress",
|
||||||
|
dbUser: "wordpress",
|
||||||
|
dbPassword: "wordpress",
|
||||||
|
dbRootPassword: "rootpassword",
|
||||||
|
ready: true,
|
||||||
|
initScript: wpReadyInitScript,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "WordPress 7.0.0 prêt à l'emploi",
|
||||||
|
type: "wordpress-ready",
|
||||||
|
dockerImage: "wordpress:7.0.0-php8.3",
|
||||||
|
dbImage: "mariadb:10.11",
|
||||||
|
dbName: "wordpress",
|
||||||
|
dbUser: "wordpress",
|
||||||
|
dbPassword: "wordpress",
|
||||||
|
dbRootPassword: "rootpassword",
|
||||||
|
ready: true,
|
||||||
|
initScript: wpReadyInitScript,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "PrestaShop 9 vierge (edubox)",
|
name: "PrestaShop 9 vierge (edubox)",
|
||||||
type: "prestashop",
|
type: "prestashop",
|
||||||
dockerImage: "151.80.60.98:3001/yacine/edubox/edubox-prestashop:9-edubox-9",
|
dockerImage: "gitea.alfrednobel.edudeploy.com/yacine/edubox/edubox-prestashop:9-edubox-9",
|
||||||
dbImage: "mariadb:10.11",
|
dbImage: "mariadb:10.11",
|
||||||
dbName: "prestashop",
|
dbName: "prestashop",
|
||||||
dbUser: "prestashop",
|
dbUser: "prestashop",
|
||||||
@@ -66,6 +97,7 @@ async function main() {
|
|||||||
const dbHost = "db";
|
const dbHost = "db";
|
||||||
const dbPort = "3306";
|
const dbPort = "3306";
|
||||||
const isPrestaShop = t.type === "prestashop";
|
const isPrestaShop = t.type === "prestashop";
|
||||||
|
const isWordPressReady = (t as any).ready === true;
|
||||||
|
|
||||||
const appEnv = isPrestaShop
|
const appEnv = isPrestaShop
|
||||||
? ` DB_SERVER: ${dbHost}
|
? ` DB_SERVER: ${dbHost}
|
||||||
@@ -93,6 +125,12 @@ async function main() {
|
|||||||
WORDPRESS_DB_PREFIX: wp_
|
WORDPRESS_DB_PREFIX: wp_
|
||||||
# No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header`;
|
# No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header`;
|
||||||
|
|
||||||
|
const appDNS = isWordPressReady
|
||||||
|
? ` dns:
|
||||||
|
- 8.8.8.8
|
||||||
|
- 1.1.1.1`
|
||||||
|
: "";
|
||||||
|
|
||||||
const appVolumes = isPrestaShop
|
const appVolumes = isPrestaShop
|
||||||
? ` volumes:
|
? ` volumes:
|
||||||
- app_data:/var/www/html`
|
- app_data:/var/www/html`
|
||||||
@@ -100,6 +138,28 @@ async function main() {
|
|||||||
- app_data:/var/www/html
|
- app_data:/var/www/html
|
||||||
- {MU_PLUGINS_DIR}/edubox-public-url.php:/var/www/html/wp-content/mu-plugins/edubox-public-url.php:ro`;
|
- {MU_PLUGINS_DIR}/edubox-public-url.php:/var/www/html/wp-content/mu-plugins/edubox-public-url.php:ro`;
|
||||||
|
|
||||||
|
const wpInitService = isWordPressReady
|
||||||
|
? ` wp-init:
|
||||||
|
image: wordpress:cli
|
||||||
|
user: "0:0"
|
||||||
|
environment:
|
||||||
|
WORDPRESS_DB_HOST: ${dbHost}:${dbPort}
|
||||||
|
WORDPRESS_DB_NAME: ${t.dbName}
|
||||||
|
WORDPRESS_DB_USER: ${t.dbUser}
|
||||||
|
WORDPRESS_DB_PASSWORD: ${t.dbPassword}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
app:
|
||||||
|
condition: service_started
|
||||||
|
volumes:
|
||||||
|
- app_data:/var/www/html
|
||||||
|
- ./wp-init.sh:/wp-init.sh:ro
|
||||||
|
restart: "no"
|
||||||
|
entrypoint: ["/bin/sh", "/wp-init.sh"]
|
||||||
|
`
|
||||||
|
: "";
|
||||||
|
|
||||||
const composeConfig = `services:
|
const composeConfig = `services:
|
||||||
db:
|
db:
|
||||||
image: ${t.dbImage}
|
image: ${t.dbImage}
|
||||||
@@ -123,24 +183,28 @@ async function main() {
|
|||||||
environment:
|
environment:
|
||||||
${appEnv}
|
${appEnv}
|
||||||
INSTANCE_ID: {INSTANCE_ID}
|
INSTANCE_ID: {INSTANCE_ID}
|
||||||
|
${appDNS}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
${appVolumes}
|
${appVolumes}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
${wpInitService}volumes:
|
||||||
db_data:
|
db_data:
|
||||||
app_data:
|
app_data:
|
||||||
`;
|
`;
|
||||||
|
const initScript = isWordPressReady ? wpReadyInitScript : null;
|
||||||
|
|
||||||
await prisma.template.upsert({
|
await prisma.template.upsert({
|
||||||
where: { id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}` },
|
where: { id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}` },
|
||||||
update: { composeConfig },
|
update: { composeConfig, initScript },
|
||||||
create: {
|
create: {
|
||||||
id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}`,
|
id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}`,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
type: t.type,
|
type: t.type,
|
||||||
dockerImage: t.dockerImage,
|
dockerImage: t.dockerImage,
|
||||||
composeConfig,
|
composeConfig,
|
||||||
|
initScript,
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
createdBy: "system",
|
createdBy: "system",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
WP_PATH=/var/www/html
|
||||||
|
FLAG=$WP_PATH/.studioe5-init-done
|
||||||
|
WP_USER=www-data
|
||||||
|
|
||||||
|
if [ -f "$FLAG" ]; then
|
||||||
|
echo "[studioE5] WordPress already initialized."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[studioE5] Waiting for WordPress config and database..."
|
||||||
|
until [ -f "$WP_PATH/wp-config.php" ] && wp --path="$WP_PATH" db query "SELECT 1" --allow-root > /dev/null 2>&1; do
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[studioE5] Fixing permissions..."
|
||||||
|
# Ensure the web server (www-data) can write to wp-content and wp-config.php.
|
||||||
|
chmod -R 777 "$WP_PATH/wp-content"
|
||||||
|
chmod 666 "$WP_PATH/wp-config.php"
|
||||||
|
|
||||||
|
run_wp() {
|
||||||
|
su -s /bin/sh "$WP_USER" -c "wp --path=$WP_PATH $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[studioE5] Installing WordPress..."
|
||||||
|
run_wp "core install --url='{PUBLIC_URL}' --title='Mon site wordpress' --admin_user='admin' --admin_password='admin' --admin_email='admin@example.com' --skip-email --allow-root"
|
||||||
|
|
||||||
|
echo "[studioE5] Setting language to French..."
|
||||||
|
run_wp "language core install fr_FR --activate --allow-root" || true
|
||||||
|
|
||||||
|
echo "[studioE5] Installing and activating theme Astra..."
|
||||||
|
run_wp "theme install astra --activate --allow-root" || true
|
||||||
|
|
||||||
|
echo "[studioE5] Installing plugins..."
|
||||||
|
run_wp "plugin install wordpress-seo --allow-root" || true
|
||||||
|
run_wp "plugin install ultimate-addons-for-gutenberg --activate --allow-root" || true
|
||||||
|
|
||||||
|
echo "[studioE5] Disabling automatic updates..."
|
||||||
|
run_wp "config set AUTOMATIC_UPDATER_DISABLED true --raw --allow-root" || true
|
||||||
|
run_wp "config set WP_AUTO_UPDATE_CORE false --raw --allow-root" || true
|
||||||
|
run_wp "config set AUTO_UPDATE_PLUGIN false --raw --allow-root" || true
|
||||||
|
run_wp "config set AUTO_UPDATE_THEME false --raw --allow-root" || true
|
||||||
|
|
||||||
|
touch "$FLAG"
|
||||||
|
echo "[studioE5] Initialization complete."
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user