From a414f03a59daf4c306849637389156718a4f2c7b Mon Sep 17 00:00:00 2001 From: EduBox Dev Date: Thu, 25 Jun 2026 22:59:09 +0000 Subject: [PATCH] feat(agent): v0.3.5 Windows inbound forwarding, UI actions, lifecycle - Configure tailscale serve automatically for each instance on Windows userspace networking. - Add local UI buttons: start/stop/reset/delete instances (stop/start preserve volumes). - Clean shutdown: stop tailscaled and instances, notify server with instance_stopped. - Restart tailscaled on agent boot using persisted state when pre-auth key is absent. - Sync instance stopped/deleted status to dashboard (server/lib/websocket.ts). - Security: include prior authz/scoping changes across API routes, ephemeral pre-auth keys, ACL policy, internal API key. - Update SUIVI_VPN_ONDEMAND.md and docs/ONBOARDING_CLIENT.md. - Bump agent version to 0.3.5. --- .env.example | 5 + .gitignore | 2 + SUIVI_VPN_ONDEMAND.md | 424 +++++++- agent/build.sh | 2 +- agent/docker.go | 14 + agent/main.go | 65 +- agent/tailscale.go | 81 +- agent/token.go | 31 + agent/ui.go | 316 +++++- agent/ui/index.html | 978 +++++++++++++++--- agent/websocket.go | 186 ++-- docker-compose.yml | 4 +- docs/ONBOARDING_CLIENT.md | 549 ++++++++++ headscale/acl_policy.hujson | 18 + headscale/config.yaml | 4 + server/app/api/classes/route.ts | 26 +- server/app/api/download/route.ts | 2 +- server/app/api/establishments/route.ts | 12 + server/app/api/instances/route.ts | 104 +- server/app/api/internal/send-to-node/route.ts | 15 + server/app/api/nodes/route.ts | 8 +- server/app/api/resolve/route.ts | 15 + server/app/api/students/route.ts | 12 +- server/app/api/templates/route.ts | 71 +- server/app/api/users/route.ts | 50 +- server/app/dashboard/students/[id]/actions.ts | 13 +- server/app/dashboard/students/new/page.tsx | 14 +- server/lib/activation.ts | 25 + server/lib/api-auth.ts | 39 + server/lib/headscale.ts | 75 ++ server/lib/websocket.ts | 234 ++++- server/prisma/schema.prisma | 20 +- server/tsconfig.tsbuildinfo | 1 - 33 files changed, 3075 insertions(+), 340 deletions(-) create mode 100644 agent/token.go create mode 100644 docs/ONBOARDING_CLIENT.md create mode 100644 headscale/acl_policy.hujson create mode 100644 server/lib/activation.ts create mode 100644 server/lib/api-auth.ts create mode 100644 server/lib/headscale.ts delete mode 100644 server/tsconfig.tsbuildinfo diff --git a/.env.example b/.env.example index 3801a3d..48ffecf 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,11 @@ NEXTAUTH_URL=http://localhost SUPERADMIN_EMAIL=admin@edudeploy.fr SUPERADMIN_PASSWORD=CHANGE_ME HEADSCALE_URL=http://headscale:8080 +# Legacy reusable pre-auth key (kept for manual/debug setups). HEADSCALE_AUTH_KEY=CHANGE_ME +# Headscale API key used by the server to generate ephemeral pre-auth keys. +HEADSCALE_API_KEY=CHANGE_ME +HEADSCALE_RESOLVER_AUTH_KEY=CHANGE_ME +INTERNAL_API_KEY=CHANGE_ME GITEA_URL=http://gitea:3000 GITEA_TOKEN=CHANGE_ME diff --git a/.gitignore b/.gitignore index da7d9f1..2911748 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ headscale/*.key headscale/*.state agent/resolv.conf agent/tailscale-bin/ +agent/studioE5-agent-test +server/tsconfig.tsbuildinfo diff --git a/SUIVI_VPN_ONDEMAND.md b/SUIVI_VPN_ONDEMAND.md index 6e3f32c..414e7e8 100644 --- a/SUIVI_VPN_ONDEMAND.md +++ b/SUIVI_VPN_ONDEMAND.md @@ -112,6 +112,59 @@ Validation manuelle sur Windows : .\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= tcp://localhost: +``` + +| Action agent | Commande Tailscale | +|--------------|--------------------| +| Démarrage d’instance | `serve --bg --tcp= tcp://localhost:` | +| Arrêt d’instance | `serve --bg --tcp= off` | +| Suppression d’instance | `serve --bg --tcp= 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` + ## 🛠️ Commandes utiles pour reprendre ### Voir l’agent de test @@ -190,11 +243,11 @@ L’agent est servi par Caddy depuis le dossier `agent/` monté dans le conteneu ### Binaires disponibles -- **Windows (archive complète)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.3-windows.zip` +- **Windows (archive complète)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5-windows.zip` - Contient `studioE5-agent.exe` + `tailscale-bin/windows/` (`tailscale.exe`, `tailscaled.exe`, `wintun.dll`) + `README-Windows.txt`. -- **Windows (exécutable seul)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.3.exe` +- **Windows (exécutable seul)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5.exe` - Nécessite d’avoir installé Tailscale Windows séparément ou d’avoir les binaires dans `tailscale-bin/windows/`. -- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.3` +- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5` ### Builder / préparer les binaires @@ -208,13 +261,13 @@ cd /opt/studioe5-client-a/agent ./build.sh ``` -Le `build.sh` génère automatiquement `studioE5-agent-v0.3.0-windows.zip` et copie les binaires versionnés dans `server/public/`. +Le `build.sh` génère automatiquement `studioE5-agent-v0.3.5-windows.zip` et copie les binaires versionnés dans `server/public/`. ### Flow d’activation zéro-config (modèle commercialisable) L’élève/employé n’a **aucune configuration technique** à saisir : -1. **Télécharger** l’agent Windows (`studioE5-agent-v0.3.0-windows.zip`). +1. **Télécharger** l’agent Windows (`studioE5-agent-v0.3.4-windows.zip`). 2. **Extraire** et **lancer** `studioE5-agent.exe`. 3. **Entrer le code d’activation** à 6 caractères fourni par l’établissement (affiché dans l’UI locale `http://localhost:7070`). 4. L’agent contacte le serveur, le serveur vérifie le code et renvoie **automatiquement** : @@ -245,47 +298,352 @@ Lancement : .\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data ``` +## 🔒 Durcissement du code d’activation + +### Génération + +- Les codes sont générés avec `crypto.randomBytes` (au lieu de `Math.random`). +- Longueur conservée à 6 caractères, alphabet sans ambiguïté (`ABCDEFGHJKLMNPQRSTUVWXYZ23456789`). +- Un champ `activationCodeExpiresAt` a été ajouté au modèle `Student` ; les codes expirent après **60 minutes**. + +### Rate-limiting + +- Maximum de **5 tentatives d’activation par code** sur une fenêtre de **15 minutes**. +- Maximum de **5 tentatives par `nodeId`** sur la même fenêtre. +- Au-delà, le serveur répond `activation_failed` avec `Too many attempts`. + +### Cycle de vie + +- Le code est **invalide après une activation réussie** (`activationCode` et `activationCodeExpiresAt` mis à `null`). +- Un code expiré renvoie `Code expired`. +- Un code déjà utilisé renvoie `Invalid code`. + +### Tests validés + +- Activation valide → `activated` + token node reçu. +- Code expiré → `Code expired`. +- Code déjà utilisé → `Invalid code`. +- 5+ tentatives invalides → `Too many attempts`. + +## 🔒 ACL Headscale (isolation du tailnet) + +### Objectif + +Par défaut, tous les nœuds du tailnet peuvent communiquer entre eux. Les ACL restreignent la connectivité au strict nécessaire : +- les agents élèves ne peuvent pas se parler entre eux ; +- le resolver peut atteindre les agents sur leurs ports d’instance ; +- les agents peuvent joindre le resolver sur son port HTTP interne. + +### Mise en œuvre + +- Fichier de politique : `headscale/acl_policy.hujson`. +- `headscale/config.yaml` pointe vers ce fichier via `policy.path`. +- Le resolver a été déplacé dans un utilisateur Headscale dédié `resolver` (clé `HEADSCALE_RESOLVER_AUTH_KEY`). +- Les agents utilisent l’utilisateur `studioe5` et sont tagués `tag:student-agent`. +- Les nouveaux agents recevront automatiquement le tag via la nouvelle clé pré-auth `HEADSCALE_AUTH_KEY` (créée avec `--tags tag:student-agent`). + +### Contenu de la politique + +```json +{ + "groups": { + "group:agents": ["studioe5@studioe5.local"], + "group:resolvers": ["resolver@studioe5.local"] + }, + "tagOwners": { + "tag:student-agent": ["studioe5@studioe5.local"], + "tag:resolver": ["resolver@studioe5.local"] + }, + "acls": [ + { "action": "accept", "src": ["tag:resolver"], "dst": ["tag:student-agent:*"] }, + { "action": "accept", "src": ["tag:student-agent"], "dst": ["tag:resolver:2020"] } + ] +} +``` + +### Tests validés + +| Test | Résultat | +|------|----------| +| `resolver` ping agent | ✅ OK | +| Agent → agent (port instance) | ❌ bloqué (timeout) | +| Agent → resolver:2020 | ✅ OK | +| Flux HTTPS public | ✅ HTTP 200 | + +## 🔒 Authentification du canal serveur → agent + +### Token d’authentification par nœud + +- Le modèle `Node` dispose d’un champ `token` unique. +- L’agent envoie son token dans l’en-tête `Authorization: Bearer ` 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 `/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 `. +- 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 `. +- 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= + ``` +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 + ``` + +### 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` | `/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. + +--- + ## 📋 Prochaines étapes à faire -- [x] ~~Attendre la fin du rate limit Let’s Encrypt~~ (levé le 2026-06-23). -- [x] ~~Relancer un test HTTPS sur `https://test-wp-001.studioe5.edudeploy.com/`~~ → **OK** (HTTP/2 200). -- [x] ~~Créer une branche dédiée et commiter les modifications VPN on-demand~~ → branche `feat/studioe5-vpn-ondemand`, commit `124543d`. Push vers Gitea à faire dès que le remote sera accessible (actuellement `localhost:3001` et `gitea.alfrednobel.edudeploy.com` injoignables). -- [x] ~~Tester le flux complet depuis l’interface web~~ → **OK** via l’API authentifiée (`POST /api/instances`), instance `cmqqgrur20001lw67t2bdgzkg` accessible en HTTPS public. -- [ ] **Obtenir un certificat wildcard** pour `*.studioe5.edudeploy.com` (voir étude ci-dessous). -- [ ] **Nettoyer les instances/agent de test** une fois le wildcard en place et le push effectué. -- [x] ~~Packager les binaires Tailscale pour Windows~~ → **OK**, `download-tailscale-bins.sh` + `studioE5-agent-v0.3.0-windows.zip` prêt. -- [ ] **Nettoyer les anciens nodes/volumes Headscale** créés pendant les tests. -- [ ] **Documenter la procédure de mise en production** pour le client A (config agent, clés Headscale, ports, etc.). +### ✅ Terminé -## 💡 Améliorations UI envisagées +- [x] Rate limit Let’s Encrypt levé. +- [x] Flux HTTPS public validé (`test-wp-001.studioe5.edudeploy.com`). +- [x] Branche `feat/studioe5-vpn-ondemand` créée, commit `124543d`. +- [x] Flux complet UI → API → WebSocket → agent → Docker → VPN → Caddy validé. +- [x] Packager les binaires Tailscale pour Windows (`studioE5-agent-v0.3.5-windows.zip`). +- [x] **Sécurité – authentification du canal serveur → agent** (token par nœud, clé API interne, sessions NextAuth sur les routes API). +- [x] **Sécurité – durcissement du code d’activation** (`crypto.randomBytes`, expiration 60 min, rate-limiting, invalidation après usage). +- [x] **Sécurité – ACL Headscale** (isolation agent ↔ agent, resolver → agent autorisé). +- [x] **Sécurité – clés pré-auth Headscale éphémères** (génération côté serveur via `HEADSCALE_API_KEY`, non persistées côté agent). +- [x] **Agent v0.3.5 – forwarding entrant Windows** (`tailscale serve` automatique au démarrage de chaque instance). +- [x] **Agent v0.3.5 – UI locale moderne** (dashboard, logs, progression, actions d’instance). +- [x] **Agent v0.3.5 – cycle de vie des instances** (`stop`/`start` préservent les volumes, `reset`/`delete` effacent). +- [x] **Agent v0.3.5 – cleanup au shutdown** (arrêt propre de Tailscale et des instances, notification serveur). +- [x] **Synchronisation dashboard** (messages `instance_stopped` / `instance_deleted` traités côté serveur). -### Console / log intégré dans l’agent +### ⏳ Reste à faire -Plutôt que de laisser Windows ouvrir une fenêtre noire à chaque commande `podman`/`docker`/`tailscale`, rediriger le `Stdout`/`Stderr` de chaque commande vers l’UI locale de l’agent (`http://localhost:7070`). +- [ ] **Certificat wildcard** : transféré au deployeur (`docs/ONBOARDING_CLIENT.md`). L’étude technique reste disponible ci-dessous pour référence. +- [ ] **Nettoyer les instances/agent de test** une fois le push effectué. +- [ ] **Nettoyer les anciens nodes/volumes Headscale** de test (nœuds `edubox`, `prof`, `invalid-*` hors ligne à supprimer). +- [ ] **Pousser la branche** vers Gitea dès que le remote sera accessible. +- [ ] **Documenter la procédure de mise en production** pour le client A (config agent, clés Headscale, ports, ACL, etc.). +- [ ] **É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). -Bénéfices : -- Expérience utilisateur plus propre et commercialisable. -- Diagnostic facilité : l’utilisateur voit exactement ce qui se passe (téléchargement d’image, démarrage, installation PrestaShop, etc.). +## 💡 Améliorations UI -Implémentation : -1. Remplacer `cmd.Stdout = os.Stdout` par un `io.Pipe()` ou `bytes.Buffer` dans `docker.go`, `tailscale.go`, etc. -2. Envoyer les lignes de log au frontend via le WebSocket existant (`agent/ui/websocket`). -3. Afficher les logs dans un panneau dédié du HTML. +### ✅ Console / log intégrée dans l’agent (v0.3.5) -### Barre de progression +Les logs de l’agent sont redirigés vers `/agent.log` et diffusés en temps réel dans l’UI locale (`http://localhost:7070`) via le WebSocket existant. -Associer des étapes connues à une barre de progression dans l’UI : +### ✅ Barre de progression (v0.3.5) + +L’agent envoie des messages `progress` au frontend pendant le démarrage d’une instance : | Étape | Poids | |-------|-------| -| Connexion au serveur | 10 % | -| Démarrage du VPN | 25 % | -| Téléchargement de l’image Docker | 50 % | -| Création de la base de données | 70 % | -| Installation de PrestaShop/WordPress | 90 % | -| Instance prête | 100 % | +| 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 % | -L’agent envoie des messages `progress` au frontend à chaque étape franchie. +### 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` diff --git a/agent/build.sh b/agent/build.sh index 5b63053..4716625 100755 --- a/agent/build.sh +++ b/agent/build.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -VERSION="0.3.3" +VERSION="0.3.5" APP_NAME="studioE5" BIN_NAME="studioE5-agent" LDFLAGS="-X main.version=${VERSION}" diff --git a/agent/docker.go b/agent/docker.go index 740ea9e..2d675e7 100644 --- a/agent/docker.go +++ b/agent/docker.go @@ -62,6 +62,20 @@ func dockerComposeDown(dataDir, instanceID string) error { return cmd.Run() } +func dockerComposeStop(dataDir, instanceID string) error { + dir := instanceDir(dataDir, instanceID) + cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "stop") + configureEngineCmd(cmd, dir) + return cmd.Run() +} + +func dockerComposeStart(dataDir, instanceID string) error { + dir := instanceDir(dataDir, instanceID) + cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "start") + configureEngineCmd(cmd, dir) + return cmd.Run() +} + func dockerComposeRm(dataDir, instanceID string) error { dir := instanceDir(dataDir, instanceID) cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v") diff --git a/agent/main.go b/agent/main.go index dccf206..55e4673 100644 --- a/agent/main.go +++ b/agent/main.go @@ -2,10 +2,14 @@ package main import ( "flag" + "io" "log" "os" + "os/signal" "path/filepath" "runtime" + "sync" + "syscall" "time" ) @@ -42,7 +46,7 @@ func main() { // 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(agentLogFile) + log.SetOutput(io.MultiWriter(agentLogFile, uiLogWriter{})) } else { log.Printf("Cannot open agent log file %s: %v", agentLogPath, err) } @@ -65,9 +69,48 @@ func main() { go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey) 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() + <-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) + info.Status = "stopped" + _ = sendMessage(WSMessage{Action: "instance_stopped", InstanceID: id}) + } + } + _ = saveInstances(*dataDir, inst) + } + log.Println("Cleanup complete") + }() + if *noTray { log.Printf("[%s Agent] running in console mode (no tray)", APP_NAME) <-shutdownCh + cleanupWg.Wait() return } @@ -80,6 +123,7 @@ func main() { }() <-shutdownCh + cleanupWg.Wait() } func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) { @@ -99,4 +143,23 @@ func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) { log.Printf("Sent tailscale_ip to server: %s", ip) break } + + // Reconfigure tailscale serve for any instances that were left running + // (e.g. after an agent restart while containers kept running). + if inst, err := loadInstances(dataDir); err == nil { + for id, info := range inst { + if info.Status == "running" { + log.Printf("Reconfiguring tailscale serve for running instance %s on port %d", id, info.Port) + if err := setupTailscaleServe(info.Port); err != nil { + log.Printf("setupTailscaleServe error for %s: %v", id, err) + } + } + } + } + + // Notify the local UI that the service status has changed. + broadcastUI(map[string]interface{}{ + "action": "status", + "status": buildUIStatus(dataDir), + }) } diff --git a/agent/tailscale.go b/agent/tailscale.go index d7eaca7..aeb86e1 100644 --- a/agent/tailscale.go +++ b/agent/tailscale.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "sync" "syscall" "time" @@ -61,6 +62,9 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro if err := os.MkdirAll(tsDataDir, 0700); err != nil { return "", fmt.Errorf("create tailscale dir: %w", err) } + // Make sure a previous tailscaled (e.g. left behind after a crash or + // force-kill) does not block the new daemon on the same socket/state. + killStaleTailscaled(tsDataDir) if runtime.GOOS == "windows" { // Windows uses named pipes for tailscaled IPC, not Unix sockets. tsSocket = `\\.\pipe\studioe5-tailscaled` @@ -88,6 +92,9 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro tsCmd = nil return "", fmt.Errorf("start tailscaled: %w", err) } + if err := os.WriteFile(filepath.Join(tsDataDir, "tailscaled.pid"), []byte(strconv.Itoa(tsCmd.Process.Pid)), 0644); err != nil { + log.Printf("Cannot write tailscaled pid file: %v", err) + } // Give tailscaled a moment to start listening. time.Sleep(1 * time.Second) @@ -99,11 +106,14 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro upArgs := []string{ "--socket=" + tsSocket, "up", - "--authkey=" + authKey, "--login-server=" + headscaleURL, "--hostname=" + nodeID, "--accept-dns=false", } + // The auth key is omitted on reconnect: Tailscale reuses the existing state. + if authKey != "" { + upArgs = append(upArgs, "--authkey="+authKey) + } if runtime.GOOS == "windows" { // On Windows, keep the VPN up even after the tailscale.exe CLI client disconnects. upArgs = append(upArgs, "--unattended") @@ -181,6 +191,9 @@ func stopTailscaleLocked() { _ = tsCmd.Wait() tsCmd = nil tsIP = "" + if tsDataDir != "" { + _ = os.Remove(filepath.Join(tsDataDir, "tailscaled.pid")) + } log.Printf("Tailscale stopped") } @@ -200,4 +213,70 @@ func getTailscaleIP() string { return tsIP } +// setupTailscaleServe configures Tailscale to proxy inbound Tailnet traffic +// on the given TCP port to localhost:. 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) +} + diff --git a/agent/token.go b/agent/token.go new file mode 100644 index 0000000..97526f6 --- /dev/null +++ b/agent/token.go @@ -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) +} diff --git a/agent/ui.go b/agent/ui.go index 469a41c..956affe 100644 --- a/agent/ui.go +++ b/agent/ui.go @@ -8,6 +8,10 @@ import ( "net/http" "os" "os/exec" + "path/filepath" + "strings" + "sync" + "time" "github.com/gorilla/websocket" ) @@ -17,6 +21,25 @@ var uiHTML string var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} +// uiConnections holds active WebSocket connections from local UI clients. +var ( + uiConnections = make(map[*websocket.Conn]bool) + uiConnectionsMu sync.RWMutex +) + +// uiLogWriter intercepts log output and forwards it to connected UI clients. +type uiLogWriter struct{} + +func (w uiLogWriter) Write(p []byte) (n int, err error) { + line := strings.TrimSpace(string(p)) + if line != "" { + sendUILog(line) + } + return len(p), nil +} + + + func startUI(dataDir, nodeID, serverAddr string) { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") @@ -32,8 +55,16 @@ func startUI(dataDir, nodeID, serverAddr string) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - // Do not expose the auth key in plain GET unless requested; for local UI it is fine. - json.NewEncoder(w).Encode(cfg) + // Expose a merged view with the agent version for the UI. + response := map[string]interface{}{ + "server": cfg.Server, + "headscale_url": cfg.HeadscaleURL, + "headscale_auth_key": cfg.HeadscaleAuthKey, + "node_id": cfg.NodeID, + "data_dir": cfg.DataDir, + "version": version, + } + json.NewEncoder(w).Encode(response) case http.MethodPost: var cfg AgentConfig if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { @@ -80,23 +111,33 @@ func startUI(dataDir, nodeID, serverAddr string) { return } defer conn.Close() + + uiConnectionsMu.Lock() + uiConnections[conn] = true + uiConnectionsMu.Unlock() log.Printf("UI client connected from %s", r.RemoteAddr) + // Send current status immediately. + sendUIStatus(conn, dataDir) + // Register notifier to forward activation results from main WS to this UI connection notifierID := registerUINotifier(func(msg map[string]interface{}) { - log.Printf("UI notifier forwarding to browser: %+v", msg) if err := conn.WriteJSON(msg); err != nil { log.Printf("UI notify error: %v", err) - } else { - log.Printf("UI notifier sent successfully") } }) - defer unregisterUINotifier(notifierID) + defer func() { + unregisterUINotifier(notifierID) + uiConnectionsMu.Lock() + delete(uiConnections, conn) + uiConnectionsMu.Unlock() + log.Printf("UI client disconnected") + }() for { var msg map[string]interface{} if err := conn.ReadJSON(&msg); err != nil { - log.Printf("UI client disconnected: %v", err) + log.Printf("UI client read error: %v", err) break } action, _ := msg["action"].(string) @@ -120,6 +161,42 @@ func startUI(dataDir, nodeID, serverAddr string) { } case "instances": listInstances(dataDir, conn) + case "get_status": + sendUIStatus(conn, dataDir) + case "run_diagnostic": + sendUIStatus(conn, dataDir) + conn.WriteJSON(map[string]interface{}{ + "action": "diagnostic_result", + "status": buildUIStatus(dataDir), + "message": "Diagnostic terminé", + }) + case "get_logs": + // Logs are streamed as they are produced; no persistent buffer yet. + conn.WriteJSON(map[string]interface{}{ + "action": "log", + "message": "Console prête. Les nouveaux logs apparaîtront ici.", + "level": "info", + }) + case "start_instance": + instanceID, _ := msg["instanceId"].(string) + if instanceID != "" { + go uiStartInstance(dataDir, nodeID, instanceID) + } + case "stop_instance": + instanceID, _ := msg["instanceId"].(string) + if instanceID != "" { + go uiStopInstance(dataDir, instanceID) + } + case "delete_instance": + instanceID, _ := msg["instanceId"].(string) + if instanceID != "" { + go uiDeleteInstance(dataDir, instanceID) + } + case "reset_instance": + instanceID, _ := msg["instanceId"].(string) + if instanceID != "" { + go uiResetInstance(dataDir, nodeID, instanceID) + } } } }) @@ -139,7 +216,7 @@ func listInstances(dataDir string, conn *websocket.Conn) { return } - var list []map[string]interface{} + list := []map[string]interface{}{} for _, inst := range instances { status := getInstanceStatus(dataDir, inst.ID) if status != inst.Status { @@ -149,6 +226,7 @@ func listInstances(dataDir string, conn *websocket.Conn) { list = append(list, map[string]interface{}{ "id": inst.ID, "templateName": inst.TemplateName, + "type": inst.TemplateName, "port": inst.Port, "status": inst.Status, "url": instanceURL(inst), @@ -157,3 +235,225 @@ func listInstances(dataDir string, conn *websocket.Conn) { conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": list}) } + +// sendUILog broadcasts a log line to all connected UI clients. +func sendUILog(message string) { + uiConnectionsMu.RLock() + conns := make([]*websocket.Conn, 0, len(uiConnections)) + for conn := range uiConnections { + conns = append(conns, conn) + } + uiConnectionsMu.RUnlock() + + msg := map[string]interface{}{ + "action": "log", + "message": message, + "level": "info", + } + for _, conn := range conns { + if err := conn.WriteJSON(msg); err != nil { + // Client may have disconnected; ignore. + } + } +} + +// 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 { + if err := conn.WriteJSON(msg); err != nil { + // Ignore write errors for disconnected clients. + } + } +} + +// 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 isTailscaleRunning() { + appServiceState = "ok" + appServiceDetail = "Service d'applications prêt" + } else { + appServiceState = "warn" + appServiceDetail = "Service d'applications disponible, connexion sécurisée en cours" + } + } 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) + } + _ = 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) + _ = 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)) != "" +} diff --git a/agent/ui/index.html b/agent/ui/index.html index b5aece4..7a9bbd9 100644 --- a/agent/ui/index.html +++ b/agent/ui/index.html @@ -2,65 +2,562 @@ + studioE5 Agent
-
-

studioE5 Agent

-
-

Connexion en cours...

+
studioE5 Agent
+ + + + + + -