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
+ + + + + + -