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.
This commit is contained in:
EduBox Dev
2026-06-25 22:59:09 +00:00
parent 331187e9b5
commit a414f03a59
33 changed files with 3075 additions and 340 deletions
+5
View File
@@ -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
+2
View File
@@ -26,3 +26,5 @@ headscale/*.key
headscale/*.state
agent/resolv.conf
agent/tailscale-bin/
agent/studioE5-agent-test
server/tsconfig.tsbuildinfo
+391 -33
View File
@@ -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 lagent soit `online`.
Logs caractéristiques :
```text
client -> backend close connection: close tcp 100.64.0.12:8080->100.64.0.11:xxxxx: endpoint not connected
```
### Solution : `tailscale serve` automatique
Lagent configure automatiquement un proxy TCP pour chaque instance démarrée :
```powershell
tailscale serve --bg --tcp=<port> tcp://localhost:<port>
```
| Action agent | Commande Tailscale |
|--------------|--------------------|
| Démarrage dinstance | `serve --bg --tcp=<port> tcp://localhost:<port>` |
| Arrêt dinstance | `serve --bg --tcp=<port> off` |
| Suppression dinstance | `serve --bg --tcp=<port> off` |
| Redémarrage de lagent | reconfiguration pour les instances déjà `running` |
Fichiers modifiés : `agent/tailscale.go`, `agent/websocket.go`, `agent/main.go`, `agent/ui.go`.
### UI locale modernisée
- Tableau de bord avec indicateurs de service.
- Liste des applications avec badges de statut.
- Boutons daction par instance : **Démarrer**, **Arrêter**, **Redémarrer**, **Supprimer**.
- Panneau de logs et diagnostic intégré.
- Panneau de configuration (URL serveur, Headscale, node ID).
### Cycle de vie des instances
- **Arrêter** → `docker compose stop` (volumes conservés).
- **Démarrer** → `docker compose start` (ou `up -d` la première fois).
- **Redémarrer** → `docker compose down -v` + recréation (données remises à zéro).
- **Supprimer** → `docker compose down -v` + suppression des fichiers.
- À la fermeture de lagent, les instances en cours sont arrêtées proprement (`stop`) et le serveur est notifié (`instance_stopped`).
### Démarrage du VPN après activation
Lagent redémarre `tailscaled` automatiquement au lancement, même si la clé pré-auth a déjà été utilisée. Il se base sur l’état persistant `tailscaled.state` (`tailscale up` sans `--authkey`).
### Téléchargement
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5-windows.zip`
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5.exe`
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5`
## 🛠️ Commandes utiles pour reprendre
### Voir lagent de test
@@ -190,11 +243,11 @@ Lagent 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 davoir installé Tailscale Windows séparément ou davoir 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 dactivation zéro-config (modèle commercialisable)
L’élève/employé na **aucune configuration technique** à saisir :
1. **Télécharger** lagent Windows (`studioE5-agent-v0.3.0-windows.zip`).
1. **Télécharger** lagent Windows (`studioE5-agent-v0.3.4-windows.zip`).
2. **Extraire** et **lancer** `studioE5-agent.exe`.
3. **Entrer le code dactivation** à 6 caractères fourni par l’établissement (affiché dans lUI locale `http://localhost:7070`).
4. Lagent 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 dactivation
### Génération
- Les codes sont générés avec `crypto.randomBytes` (au lieu de `Math.random`).
- Longueur conservée à 6 caractères, alphabet sans ambiguïté (`ABCDEFGHJKLMNPQRSTUVWXYZ23456789`).
- Un champ `activationCodeExpiresAt` a été ajouté au modèle `Student` ; les codes expirent après **60 minutes**.
### Rate-limiting
- Maximum de **5 tentatives dactivation par code** sur une fenêtre de **15 minutes**.
- Maximum de **5 tentatives par `nodeId`** sur la même fenêtre.
- Au-delà, le serveur répond `activation_failed` avec `Too many attempts`.
### Cycle de vie
- Le code est **invalide après une activation réussie** (`activationCode` et `activationCodeExpiresAt` mis à `null`).
- Un code expiré renvoie `Code expired`.
- Un code déjà utilisé renvoie `Invalid code`.
### Tests validés
- Activation valide → `activated` + token node reçu.
- Code expiré → `Code expired`.
- Code déjà utilisé → `Invalid code`.
- 5+ tentatives invalides → `Too many attempts`.
## 🔒 ACL Headscale (isolation du tailnet)
### Objectif
Par défaut, tous les nœuds du tailnet peuvent communiquer entre eux. Les ACL restreignent la connectivité au strict nécessaire :
- les agents élèves ne peuvent pas se parler entre eux ;
- le resolver peut atteindre les agents sur leurs ports dinstance ;
- les agents peuvent joindre le resolver sur son port HTTP interne.
### Mise en œuvre
- Fichier de politique : `headscale/acl_policy.hujson`.
- `headscale/config.yaml` pointe vers ce fichier via `policy.path`.
- Le resolver a été déplacé dans un utilisateur Headscale dédié `resolver` (clé `HEADSCALE_RESOLVER_AUTH_KEY`).
- Les agents utilisent lutilisateur `studioe5` et sont tagués `tag:student-agent`.
- Les nouveaux agents recevront automatiquement le tag via la nouvelle clé pré-auth `HEADSCALE_AUTH_KEY` (créée avec `--tags tag:student-agent`).
### Contenu de la politique
```json
{
"groups": {
"group:agents": ["studioe5@studioe5.local"],
"group:resolvers": ["resolver@studioe5.local"]
},
"tagOwners": {
"tag:student-agent": ["studioe5@studioe5.local"],
"tag:resolver": ["resolver@studioe5.local"]
},
"acls": [
{ "action": "accept", "src": ["tag:resolver"], "dst": ["tag:student-agent:*"] },
{ "action": "accept", "src": ["tag:student-agent"], "dst": ["tag:resolver:2020"] }
]
}
```
### Tests validés
| Test | Résultat |
|------|----------|
| `resolver` ping agent | ✅ OK |
| Agent → agent (port instance) | ❌ bloqué (timeout) |
| Agent → resolver:2020 | ✅ OK |
| Flux HTTPS public | ✅ HTTP 200 |
## 🔒 Authentification du canal serveur → agent
### Token dauthentification par nœud
- Le modèle `Node` dispose dun champ `token` unique.
- Lagent envoie son token dans len-tête `Authorization: Bearer <token>` lors de la connexion WebSocket.
- Le serveur rejette toute connexion/register dont le token ne correspond pas au `nodeId` (fermeture `1008`).
- Lors de lactivation, le serveur génère un token aléatoire (32 octets hex) et le renvoie dans le message `activated` ; lagent le sauvegarde dans `<data-dir>/node.token` (permissions `0600`).
- Pour les nœuds existants sans token, le serveur en génère un à la première connexion et lenvoie via `set_token`.
### Endpoint `/api/internal/send-to-node`
- Protégé par la variable denvironnement `INTERNAL_API_KEY`.
- Requiert len-tête `Authorization: Bearer <INTERNAL_API_KEY>`.
- Appel sans clé → `401 Unauthorized`.
### Routes API métier
- Les routes de gestion des instances (`/api/instances`) requièrent une session NextAuth valide.
- Un administrateur ne peut agir que sur les ressources de son établissement ; le `superadmin` peut tout voir/tout faire.
### Endpoint `/api/resolve`
- Protégé par la même clé `INTERNAL_API_KEY`.
- Requiert len-tête `Authorization: Bearer <INTERNAL_API_KEY>`.
- Le resolver (`resolver:2020`) ne lutilise pas ; il interroge directement PostgreSQL. Cette route est donc réservée aux outils/scripts internes authentifiés.
### Exemples de commandes avec la clé interne
```bash
KEY=$(grep INTERNAL_API_KEY /opt/studioe5-client-a/.env | cut -d= -f2)
curl -sS -X POST https://studioe5.edudeploy.com/api/internal/send-to-node \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $KEY" \
-d '{"nodeId":"vps-8fc665eb","message":{"action":"start_vpn"}}'
curl -sS -H "Authorization: Bearer $KEY" \
"https://studioe5.edudeploy.com/api/resolve?subdomain=test-wp-001"
```
## 🔒 Clés pré-auth Headscale éphémères
### Principe
À lactivation zero-config, le serveur génère désormais une **clé pré-auth unique et à usage unique** pour chaque agent, au lieu denvoyer la clé réutilisable `HEADSCALE_AUTH_KEY`.
Avantages :
- une clé compromise ne permet pas denregistrer dautres nœuds ;
- traçabilité directe entre une activation et une clé Headscale ;
- expiration courte (15 min) ;
- la clé nest **pas persistée** dans `studioE5-config.json` côté agent.
### Implémentation
| Composant | Changement |
|-----------|------------|
| `server/lib/headscale.ts` | Nouveau helper : `getHeadscaleUserId()` + `createEphemeralPreAuthKey()` appelant `POST /api/v1/preauthkey`. |
| `server/lib/websocket.ts` | Sur `activate`, génère une clé éphémère taguée `tag:student-agent` pour lutilisateur `studioe5`. Fallback sur `HEADSCALE_AUTH_KEY` si `HEADSCALE_API_KEY` nest pas configurée. |
| `agent/websocket.go` | La clé reçue est utilisée immédiatement mais **nest plus écrite** dans `studioE5-config.json`. |
| `agent/tailscale.go` | `tailscale up` fonctionne sans `--authkey` quand le state Tailscale existe déjà (reconnexion). |
| `.env.example` / `docker-compose.yml` | Ajout de `HEADSCALE_API_KEY` pour le service `server`. |
### Configuration requise
Générer une clé API Headscale (depuis le conteneur ou la CLI) :
```bash
cd /opt/studioe5-client-a
# Clé valable 10 ans (87600h) pour éviter un renouvellement fréquent.
docker compose exec headscale headscale apikeys create -e 87600h
```
Puis lajouter dans `.env` :
```bash
HEADSCALE_API_KEY=hskey-api-...
```
> ⚠️ La clé API Headscale a une expiration par défaut de 90 jours. La clé de production a été créée avec une expiration de **10 ans** (`-e 87600h`). Les anciennes clés ont été révoquées.
### Rotation / renouvellement
Si la clé doit être changée :
1. Créer une nouvelle clé API :
```bash
docker compose exec headscale headscale apikeys create -e 87600h
```
2. Mettre à jour `.env` :
```bash
HEADSCALE_API_KEY=<nouvelle_clé>
```
3. Redémarrer le serveur :
```bash
docker compose up -d server
```
4. Révoquer lancienne clé :
```bash
docker compose exec headscale headscale apikeys expire --id <id_ancienne>
```
### Déploiement effectué
- Clé API créée et ajoutée au `.env` de production.
- Image serveur rebuildée et redémarrée.
- Agents Linux/Windows rebuildés en v0.3.4 et copiés dans `server/public/`.
## 🔒 Sécurité — points restants à traiter
> Le certificat wildcard `*.studioe5.edudeploy.com` est désormais du ressort du **deployeur** (voir `docs/ONBOARDING_CLIENT.md`). Les points ci-dessous concernent lapplication studioE5 proprement dite.
### Gestion et rotation des secrets
| Secret | Où ? | Action |
|--------|------|--------|
| `INTERNAL_API_KEY` | `.env` serveur | Prévoir une procédure de rotation régulière. |
| `HEADSCALE_API_KEY` | `.env` serveur | Rotation tous les 10 ans max, stockage sécurisé. |
| `NEXTAUTH_SECRET` | `.env` serveur | Génération robuste, rotation si suspicion de fuite. |
| `DATABASE_URL` | `.env` serveur | Utilisateur DB dédié, mot de passe fort. |
| `node.token` | `<data-dir>/node.token` | Vérifier permissions `0600` sur tous les OS. |
### Durcissement des conteneurs
- Limiter les `cap_add` au strict minimum.
- Faire tourner les services avec un utilisateur non-root quand possible.
- Mettre à jour régulièrement les images de base (Caddy, Node, Postgres, Headscale).
- Scanner les images Docker pour les CVE.
### Mises à jour de sécurité
- Mise à jour des binaires Tailscale (Windows et Linux).
- Mise à jour des images Docker (`server`, `resolver`, `caddy`, `postgres`, `headscale`).
- Mise à jour de lOS des VPS et des postes agents.
- Mécanisme de mise à jour automatique ou notification de lagent.
### Logs daudit
- Tracer la création / suppression dinstances.
- Tracer la génération et lusage des codes dactivation.
- Tracer les actions admin (connexion, création d’élève, démarrage/arrêt VPN).
- Conservation et consultation des logs daudit.
### Backups et reprise dactivité
- Backup régulier de la base PostgreSQL.
- Backup du state Headscale.
- Backup des states Tailscale côté agents.
- Procédure de restauration documentée et testée.
### Sécurité du build et distribution de lagent
- Vérifier lintégrité des binaires Tailscale téléchargés (checksum / signature).
- Signer lexécutable Windows `studioE5-agent.exe` pour éviter les alertes Defender.
- Fournir un hash SHA256 des archives dagent.
### RGPD et données personnelles
- Justifier la conservation des noms/prénoms des élèves.
- Gérer les droits daccès, la suppression de compte et lexport de données.
- Définir la durée de conservation des logs et historiques.
### Sécurité réseau complémentaire
- Restreindre laccès à `/api/internal/send-to-node` par IP source si possible.
- Vérifier lexposition publique du dashboard Headscale et la durcir si nécessaire.
- Évaluer si `headscale.studioe5.edudeploy.com` doit rester public.
### Rate limiting et quotas
- Rate-limiting global sur les routes publiques (`/api/auth/*`, activation, création dinstance).
- Limitation du nombre dinstances par élève et par établissement.
- Protection contre les abus sur la génération de codes dactivation.
### Tests de sécurité
- Tests dintrusion légers (agent → agent, accès aux endpoints internes sans clé, accès à une instance dun autre élève).
- Tests automatisés du flux complet avant chaque release.
---
## 📋 Prochaines étapes à faire
- [x] ~~Attendre la fin du rate limit Lets 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 linterface web~~ → **OK** via lAPI 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 Lets 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 dactivation** (`crypto.randomBytes`, expiration 60 min, rate-limiting, invalidation après usage).
- [x] **Sécurité ACL Headscale** (isolation agent ↔ agent, resolver → agent autorisé).
- [x] **Sécurité clés pré-auth Headscale éphémères** (génération côté serveur via `HEADSCALE_API_KEY`, non persistées côté agent).
- [x] **Agent v0.3.5 forwarding entrant Windows** (`tailscale serve` automatique au démarrage de chaque instance).
- [x] **Agent v0.3.5 UI locale moderne** (dashboard, logs, progression, actions dinstance).
- [x] **Agent v0.3.5 cycle de vie des instances** (`stop`/`start` préservent les volumes, `reset`/`delete` effacent).
- [x] **Agent v0.3.5 cleanup au shutdown** (arrêt propre de Tailscale et des instances, notification serveur).
- [x] **Synchronisation dashboard** (messages `instance_stopped` / `instance_deleted` traités côté serveur).
### Console / log intégré dans lagent
### ⏳ 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 lUI locale de lagent (`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 dun nouveau serveur client + agent générique (option A : URL serveur déterminée à lactivation).
- [ ] **Sécurité gestion et rotation des secrets** (`INTERNAL_API_KEY`, `HEADSCALE_API_KEY`, `NEXTAUTH_SECRET`, `DATABASE_URL`).
- [ ] **Sécurité durcissement des conteneurs** (`cap_add`, utilisateurs non-root, scans CVE).
- [ ] **Sécurité mises à jour de sécurité** (Tailscale, images Docker, OS agents).
- [ ] **Sécurité logs daudit** (instances, codes dactivation, actions admin).
- [ ] **Sécurité backups et reprise dactivité** (DB, state Headscale, states agents).
- [ ] **Sécurité intégrité et signature de lagent** (checksum Tailscale, signature Windows, hash SHA256).
- [ ] **Sécurité conformité RGPD** (données élèves, suppression de compte, export).
- [ ] **Sécurité restriction réseau** (endpoint interne, dashboard Headscale).
- [ ] **Sécurité rate limiting et quotas** (routes publiques, instances par élève/établissement).
- [ ] **Sécurité tests de sécurité** (intrusion légère, tests automatisés avant release).
Bénéfices :
- Expérience utilisateur plus propre et commercialisable.
- Diagnostic facilité : lutilisateur voit exactement ce qui se passe (téléchargement dimage, 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 lagent (v0.3.5)
### Barre de progression
Les logs de lagent sont redirigés vers `<data-dir>/agent.log` et diffusés en temps réel dans lUI locale (`http://localhost:7070`) via le WebSocket existant.
Associer des étapes connues à une barre de progression dans lUI :
### ✅ Barre de progression (v0.3.5)
Lagent envoie des messages `progress` au frontend pendant le démarrage dune instance :
| Étape | Poids |
|-------|-------|
| Connexion au serveur | 10 % |
| Démarrage du VPN | 25 % |
| Téléchargement de limage Docker | 50 % |
| Création de la base de données | 70 % |
| Installation de PrestaShop/WordPress | 90 % |
| Instance prête | 100 % |
| Préparation de lapplication | 10 % |
| Configuration de lapplication | 30 % |
| Application en cours de démarrage | 60 % |
| Connexion sécurisée active | 80 % |
| Finalisation de linstallation | 90 % |
| Application prête | 100 % |
Lagent envoie des messages `progress` au frontend à chaque étape franchie.
### Boutons daction par instance (v0.3.5)
LUI locale affiche désormais des boutons **Démarrer**, **Arrêter**, **Redémarrer** et **Supprimer** pour chaque instance.
## 🚀 Scalabilité commerciale — déploiement multi-clients
### Objectif
Permettre de déployer facilement une stack studioE5 complète pour un nouvel établissement/client sur un VPS dédié, sans intervention technique lourde.
### Architecture cible
- **Un serveur = un client** : chaque établissement a sa propre stack Docker Compose, sa base PostgreSQL, son Headscale et son Caddy.
- **Agent générique (option A)** : un seul binaire agent pour tous les clients. LURL du serveur cible est déterminée au moment de lactivation, pas hardcodée dans lagent.
- Pistes : code dactivation résolu par un hub central, code structuré contenant lidentifiant du serveur, ou champ URL serveur saisi dans lUI locale.
- **Interface de déploiement** : dashboard superadmin (hub) permettant de créer un client, provisionner le VPS, générer les secrets et retourner les informations de connexion.
### Prérequis techniques à préparer
Avant de pouvoir déployer un nouveau client en quelques clics, il faut encore préparer les éléments suivants :
| # | Élément | État | Détail |
|---|---------|------|--------|
| 1 | **Agent générique** | ⏳ À faire | `defaultServerURL` est hardcodé (`wss://studioe5.edudeploy.com/api/websocket`). Lagent doit pouvoir déterminer lURL serveur cible à lactivation (option A : champ URL, hub de résolution, ou code structuré). |
| 2 | **Script de provisionning** | ⏳ À faire | Aucun outil automatisé pour provisionner un VPS vierge : installation Docker, déploiement de la stack, génération des secrets, création des clés Headscale, configuration DNS wildcard. |
| 3 | **Registry dimages** | ⏳ À faire | Les images `server` et `resolver` sont buildées sur le serveur cible. Il faut un registry privé pour builder une fois et déployer partout. |
| 4 | **Hub central** | ⏳ À faire | Dashboard superadmin listant les clients, état des serveurs, versions déployées, logs distants et mises à jour. |
| 5 | **Mises à jour à distance** | ⏳ À faire | Mécanisme pour pousser une nouvelle version du serveur et de lagent sur tous les déploiements clients. |
| 6 | **Monitoring / support** | ⏳ À faire | Collecte centralisée de logs, alertes (serveur down, certificat expiré, agent hors ligne). |
| 7 | **Branding / personnalisation** | ⏳ À faire | Logo, nom de l’établissement, couleurs configurables par client. |
| 8 | **Tests automatisés** | ⏳ À faire | Tests du flux activation → VPN → instance → HTTPS public pour valider chaque nouveau déploiement. |
| 9 | **Documentation procédure prod** | ⏳ À faire | Procédure complète de mise en production pour un nouveau client. |
### Statut
- ⏳ À étudier et planifier plus tard. Larchitecture actuelle (un serveur par client + agent zero-config) est déjà compatible avec cette vision, mais le code nest pas encore industrialisé pour un déploiement à grande échelle.
## 🔒 Étude certificat wildcard `*.studioe5.edudeploy.com`
+1 -1
View File
@@ -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}"
+14
View File
@@ -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")
+64 -1
View File
@@ -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),
})
}
+80 -1
View File
@@ -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:<port>. This is required on Windows
// because userspace networking does not forward incoming connections to
// loopback by default.
func setupTailscaleServe(port int) error {
tsCmdMu.Lock()
defer tsCmdMu.Unlock()
if tsSocket == "" {
return fmt.Errorf("tailscale socket not initialized")
}
portStr := strconv.Itoa(port)
// Clean up any stale config for this port first.
offCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "off")
hideWindow(offCmd)
_ = offCmd.Run()
serveCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "tcp://localhost:"+portStr)
hideWindow(serveCmd)
out, err := serveCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("tailscale serve: %w: %s", err, string(out))
}
log.Printf("Tailscale serve configured for port %s", portStr)
return nil
}
// removeTailscaleServe removes the Tailscale serve proxy for a port when an
// instance is stopped or deleted.
func removeTailscaleServe(port int) {
tsCmdMu.Lock()
defer tsCmdMu.Unlock()
if tsSocket == "" {
return
}
portStr := strconv.Itoa(port)
offCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "off")
hideWindow(offCmd)
_ = offCmd.Run()
log.Printf("Tailscale serve removed for port %s", portStr)
}
// killStaleTailscaled terminates a previously started tailscaled process that
// may have been left running after the agent was force-killed.
func killStaleTailscaled(tsDataDir string) {
pidFile := filepath.Join(tsDataDir, "tailscaled.pid")
data, err := os.ReadFile(pidFile)
if err != nil {
return
}
var pid int
if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil {
return
}
proc, err := os.FindProcess(pid)
if err != nil {
return
}
if err := proc.Signal(syscall.Signal(0)); err == nil {
log.Printf("Killing stale tailscaled process %d", pid)
_ = proc.Kill()
_, _ = proc.Wait()
}
_ = os.Remove(pidFile)
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"os"
"path/filepath"
)
const nodeTokenFileName = "node.token"
func nodeTokenPath(dataDir string) string {
return filepath.Join(dataDir, nodeTokenFileName)
}
// loadNodeToken reads the persisted node authentication token, if any.
func loadNodeToken(dataDir string) (string, error) {
path := nodeTokenPath(dataDir)
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
// saveNodeToken persists the node authentication token with restrictive permissions.
func saveNodeToken(dataDir string, token string) error {
if err := os.MkdirAll(dataDir, 0755); err != nil {
return err
}
path := nodeTokenPath(dataDir)
return os.WriteFile(path, []byte(token), 0600)
}
+308 -8
View File
@@ -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)) != ""
}
+843 -135
View File
File diff suppressed because it is too large Load Diff
+115 -71
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"log"
"net/http"
"sync"
"time"
@@ -23,6 +24,7 @@ type WSMessage struct {
TailscaleIP string `json:"tailscaleIp,omitempty"`
HeadscaleURL string `json:"headscaleUrl,omitempty"`
HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"`
Token string `json:"token,omitempty"`
}
var (
@@ -107,14 +109,20 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
setHeadscaleConfig(headscaleURL, headscaleAuthKey)
for {
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil)
token, _ := loadNodeToken(dataDir)
headers := http.Header{}
if token != "" {
headers.Set("Authorization", "Bearer "+token)
}
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, headers)
if err != nil {
log.Printf("WS connect error: %v, retrying in 5s...", err)
time.Sleep(5 * time.Second)
continue
}
log.Printf("WS connected to %s", serverAddr)
log.Printf("WS connected to %s (token=%v)", serverAddr, token != "")
mainConnMu.Lock()
mainConn = conn
@@ -136,9 +144,11 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
log.Println("Waiting for activation...")
} else {
log.Printf("Already activated as %s", act.StudentName)
// If already activated and we have credentials, ensure VPN is up.
// If already activated, ensure VPN is up. The pre-auth key is
// one-time only, so on restart we rely on the persisted tailscaled
// state; tailscale up without an authkey reuses existing state.
hsURL, hsKey := getHeadscaleConfig()
if hsURL != "" && hsKey != "" {
if hsURL != "" {
go startTailscaleAndReport(dataDir, nodeID, hsURL, hsKey)
}
}
@@ -183,8 +193,23 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) {
switch msg.Action {
case "set_token":
if msg.Token != "" {
if err := saveNodeToken(dataDir, msg.Token); err != nil {
log.Printf("saveNodeToken error: %v", err)
} else {
log.Printf("Node token saved")
}
}
case "activated":
log.Printf("handleMessage: activated received, student=%s", msg.StudentName)
if msg.Token != "" {
if err := saveNodeToken(dataDir, msg.Token); err != nil {
log.Printf("saveNodeToken error: %v", err)
} else {
log.Printf("Node token saved on activation")
}
}
if msg.StudentName != "" {
act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code}
if err := saveActivation(dataDir, act); err != nil {
@@ -194,7 +219,9 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
}
}
// The server also sends Headscale credentials on activation.
// The server sends Headscale credentials on activation.
// The pre-auth key is ephemeral and must be used immediately;
// it is intentionally NOT persisted to the config file.
if msg.HeadscaleURL != "" && msg.HeadscaleAuthKey != "" {
setHeadscaleConfig(msg.HeadscaleURL, msg.HeadscaleAuthKey)
cfg, _, err := loadOrCreateConfig(dataDir)
@@ -202,11 +229,11 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
log.Printf("loadOrCreateConfig error: %v", err)
} else {
cfg.HeadscaleURL = msg.HeadscaleURL
cfg.HeadscaleAuthKey = msg.HeadscaleAuthKey
// Intentionally do not save HeadscaleAuthKey: it is one-time only.
if err := saveConfig(dataDir, cfg); err != nil {
log.Printf("saveConfig error: %v", err)
} else {
log.Printf("Saved Headscale config received from server")
log.Printf("Saved Headscale URL received from server (auth key not persisted)")
}
}
go startTailscaleAndReport(dataDir, nodeID, msg.HeadscaleURL, msg.HeadscaleAuthKey)
@@ -232,6 +259,10 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
if err != nil {
log.Printf("start_vpn error: %v", err)
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()})
broadcastUI(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
})
return
}
for {
@@ -243,6 +274,10 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
log.Printf("Sent tailscale_ip to server: %s", ip)
break
}
broadcastUI(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
})
}()
case "stop_vpn":
log.Printf("Server requested VPN stop")
@@ -256,44 +291,12 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
})
case "start":
log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port)
if err := upsertInstance(dataDir, &InstanceInfo{
ID: msg.InstanceID,
TemplateName: msg.Type,
Port: msg.Port,
Status: "starting",
}); err != nil {
log.Printf("upsertInstance error: %v", err)
}
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); err != nil {
log.Printf("writeCompose error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return
}
if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil {
log.Printf("dockerComposeUp error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return
}
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
// so the studioE5 mu-plugin can compute the public URL from the Host header.
go func() {
// Give the container a moment to be ready before touching wp-config.php
time.Sleep(2 * time.Second)
if err := stripWordPressHardcodedURLs(dataDir, msg.InstanceID); err != nil {
log.Printf("stripWordPressHardcodedURLs error: %v", err)
}
}()
// Ensure Tailscale is running so the server can reach the node
go ensureTailscale(dataDir, nodeID, msg.Port)
status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
notifyUI(map[string]interface{}{"action": "instances_updated"})
go handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.Port)
case "stop":
log.Printf("Stop instance %s", msg.InstanceID)
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
removeTailscaleServe(inst[msg.InstanceID].Port)
}
if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil {
log.Printf("dockerComposeDown error: %v", err)
}
@@ -304,45 +307,78 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
notifyUI(map[string]interface{}{"action": "instances_updated"})
case "delete":
log.Printf("Delete instance %s", msg.InstanceID)
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
removeTailscaleServe(inst[msg.InstanceID].Port)
}
dockerComposeRm(dataDir, msg.InstanceID)
removeInstance(dataDir, msg.InstanceID)
notifyUI(map[string]interface{}{"action": "instances_updated"})
case "reset":
log.Printf("Reset instance %s", msg.InstanceID)
dockerComposeRm(dataDir, msg.InstanceID)
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); err != nil {
log.Printf("writeCompose error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return
}
if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil {
log.Printf("dockerComposeUp error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return
}
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
// so the studioE5 mu-plugin can compute the public URL from the Host header.
go func() {
// Give the container a moment to be ready before touching wp-config.php
time.Sleep(2 * time.Second)
if err := stripWordPressHardcodedURLs(dataDir, msg.InstanceID); err != nil {
log.Printf("stripWordPressHardcodedURLs error: %v", err)
}
}()
// Ensure Tailscale is running so the server can reach the node
go ensureTailscale(dataDir, nodeID, msg.Port)
status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
notifyUI(map[string]interface{}{"action": "instances_updated"})
go handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.Port)
default:
log.Printf("Unknown action: %s", msg.Action)
}
}
func handleStartInstance(dataDir, nodeID, instanceID, instanceType, composeConfig string, port int) {
notifyInstanceProgress := func(percent, message string) {
sendInstanceProgress(instanceID, "start", percent, message)
}
_ = upsertInstance(dataDir, &InstanceInfo{
ID: instanceID,
TemplateName: instanceType,
Port: port,
Status: "starting",
})
notifyUI(map[string]interface{}{"action": "instances_updated"})
notifyInstanceProgress("10", "Préparation de l'application...")
if err := writeCompose(dataDir, instanceID, composeConfig, port); err != nil {
log.Printf("writeCompose error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: instanceID, Error: err.Error()})
notifyInstanceProgress("0", "Erreur de préparation")
notifyUI(map[string]interface{}{"action": "instances_updated"})
return
}
notifyInstanceProgress("30", "Configuration de l'application...")
if err := dockerComposeUp(dataDir, instanceID); err != nil {
log.Printf("dockerComposeUp error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: instanceID, Error: err.Error()})
notifyInstanceProgress("0", "Erreur de démarrage")
notifyUI(map[string]interface{}{"action": "instances_updated"})
return
}
notifyInstanceProgress("60", "Application en cours de démarrage...")
ensureTailscale(dataDir, nodeID, port)
if err := setupTailscaleServe(port); err != nil {
log.Printf("setupTailscaleServe error: %v", err)
// Non-fatal: the instance may still work on Linux or if Windows
// userspace forwarding happens to function.
}
notifyInstanceProgress("80", "Connexion sécurisée active...")
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
// so the studioE5 mu-plugin can compute the public URL from the Host header.
time.Sleep(2 * time.Second)
if err := stripWordPressHardcodedURLs(dataDir, instanceID); err != nil {
log.Printf("stripWordPressHardcodedURLs error: %v", err)
}
notifyInstanceProgress("90", "Finalisation de l'installation...")
status := getInstanceStatus(dataDir, instanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: status})
sendMessage(WSMessage{Action: "instance_started", InstanceID: instanceID, Port: port})
notifyInstanceProgress("100", "Application prête")
notifyUI(map[string]interface{}{"action": "instances_updated"})
}
func ensureTailscale(dataDir, nodeID string, port int) {
hsURL, hsKey := getHeadscaleConfig()
if hsURL == "" || hsKey == "" {
@@ -356,6 +392,10 @@ func ensureTailscale(dataDir, nodeID string, port int) {
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
if err != nil {
log.Printf("ensureTailscale start error: %v", err)
broadcastUI(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
})
return
}
for {
@@ -367,4 +407,8 @@ func ensureTailscale(dataDir, nodeID string, port int) {
log.Printf("Sent tailscale_ip to server: %s", ip)
break
}
broadcastUI(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
})
}
+3 -1
View File
@@ -34,6 +34,8 @@ services:
MAIN_DOMAIN: ${MAIN_DOMAIN}
HEADSCALE_URL: ${HEADSCALE_URL}
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
HEADSCALE_API_KEY: ${HEADSCALE_API_KEY}
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
depends_on:
postgres:
condition: service_healthy
@@ -66,7 +68,7 @@ services:
devices:
- /dev/net/tun:/dev/net/tun
environment:
TS_AUTHKEY: ${HEADSCALE_AUTH_KEY}
TS_AUTHKEY: ${HEADSCALE_RESOLVER_AUTH_KEY}
TS_LOGIN_SERVER: ${HEADSCALE_URL}
TS_EXTRA_ARGS: --login-server=${HEADSCALE_URL}
TS_STATE_DIR: /var/lib/tailscale
+549
View File
@@ -0,0 +1,549 @@
# Deployeur studioE5 — Onboarding dun nouvel établissement
## Objectif
Ce document décrit le fonctionnement du **deployeur studioE5**, cest-à-dire lapplication / loutil qui provisionne et configure un nouvel environnement client (un établissement) prêt à accueillir lapplication studioE5.
Lapplication studioE5 elle-même (agent, VPN on-demand, resolver, Caddy, Headscale, etc.) est documentée dans `SUIVI_VPN_ONDEMAND.md`. Le deployeur est loutil qui **déploie** cette application sur un VPS dédié au client.
---
## Public cible
- Équipe produit / développement du deployeur
- Équipe ops / déploiement
- Référents techniques du client A
---
## Glossaire
| Terme | Définition |
|-------|------------|
| **Deployeur** | Application qui provisionne un VPS, déploie la stack studioE5 et configure le DNS/certificats pour un nouvel établissement. |
| **Hub central** | Dashboard superadmin studioE5 qui orchestre les déploiements et la gestion multi-clients. |
| **Établissement** | Entité client (école, lycée, université, entreprise). |
| **Tag établissement** | Slug unique et court identifiant l’établissement dans les URLs et le DNS. |
| **Domaine géré** | Sous-domaine fourni par studioE5 : `*.tag.edudeploy.com`. |
| **Domaine propre** | Domaine détenu par l’établissement : `*.tag.monetablissement.fr`. |
| **Application studioE5** | Stack complète déployée sur le VPS : `server`, `resolver`, `resolver-vpn`, `caddy`, `headscale`, `postgres`, etc. |
| **Agent générique** | Binaire agent unique, capable de se connecter à nimporte quel serveur studioE5 via résolution dURL à lactivation. |
---
## Architecture : deployeur vs application studioE5
```
┌─────────────────────────────────────────────────────────────┐
│ Hub central studioE5 │
│ (superadmin, gestion des établissements, monitoring) │
└───────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Deployeur studioE5 │
│ (provisionning VPS, DNS, certificats, déploiement stack) │
└───────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Application studioE5 (un par client) │
│ Caddy — Resolver — Resolver-VPN — Headscale — Server — DB │
│ ▲ │
│ │ WebSocket / VPN on-demand │
│ ▼ │
│ Agent élève (Windows/Linux) │
└─────────────────────────────────────────────────────────────┘
```
---
## Flux donboarding par le deployeur (vue densemble)
```
Création de l’établissement dans le hub
Choix du domaine (géré ou propre)
Génération du tag établissement
Provisionning du VPS
Configuration DNS wildcard
Génération du certificat wildcard
Déploiement de la stack studioE5 (Docker Compose)
Initialisation de Headscale et création des clés
Création du compte administrateur de l’établissement
Génération des codes dactivation
Build et mise à disposition de lagent dédié
Activation de lagent par un élève
Création dune première instance (validation du déploiement)
```
---
## 1. Création de l’établissement dans le hub
Le superadmin crée un nouvel établissement dans le hub central.
Données minimales :
- Nom officiel
- Type d’établissement (école, lycée, université, entreprise)
- Pays / fuseau horaire
- Contact administrateur
- Choix du mode de domaine (`managed` ou `custom`)
---
## 2. Choix du domaine
### Option A — Domaine géré par studioE5 (MVP)
Le deployeur crée automatiquement un sous-domaine du domaine maître :
```
*.tag.edudeploy.com
```
Le hub gère le DNS chez le registrar studioE5 (actuellement Infomaniak).
### Option B — Domaine propre de l’établissement (évolution)
L’établissement fournit son propre domaine :
```
*.tag.monetablissement.fr
```
Prérequis :
- Le client pointe son DNS wildcard vers lIP du VPS provisionné.
- Le deployeur dispose dun token API du registrar du client pour le challenge DNS-01.
---
## 3. Génération du tag établissement
Le tag est un slug court, unique au niveau du hub, utilisé dans les URLs et le DNS.
### Règles
- Uniquement `[a-z0-9-]`
- Pas de tiret au début ni à la fin
- Longueur conseillée : 2 à 20 caractères
- Vérification dunicité en base
### Exemples
| Nom d’établissement | Tag |
|---------------------|-----|
| Lycée Jules Ferry | `ljf` |
| Institut Supérieur du Digital | `isd` |
| École Notre-Dame | `end` |
### Gestion des collisions
- `ljf``ljf-2`, `ljf-3`, etc.
---
## 4. Provisionning du VPS
Le deployeur provisionne un VPS dédié pour l’établissement.
### Prérequis sur le VPS vierge
- OS Linux (Ubuntu LTS recommandé)
- Docker + Docker Compose installés
- Accès SSH avec clé
- Ports ouverts : 22, 80, 443
### Actions automatisées par le deployeur
1. Installation de Docker et Docker Compose si absent.
2. Création de la structure de dossiers (`/opt/studioe5-<tag>/`).
3. Génération des secrets (`.env`) :
- `INTERNAL_API_KEY`
- `HEADSCALE_API_KEY`
- `HEADSCALE_AUTH_KEY` (réutilisable, taguée `tag:student-agent`)
- `HEADSCALE_RESOLVER_AUTH_KEY`
- `INFOMANIAK_API_TOKEN` (si domaine géré)
- `NEXTAUTH_SECRET`, `DATABASE_URL`, etc.
4. Récupération des images Docker depuis le registry privé (ou build sur place en attendant).
5. Génération des fichiers de configuration (`Caddyfile`, `docker-compose.yml`, `headscale/config.yaml`, `headscale/acl_policy.hujson`).
---
## 5. Configuration DNS wildcard
### Domaine géré
Le deployeur appelle lAPI du registrar pour créer :
```dns
*.tag.edudeploy.com A <IP_DU_VPS>
```
### Domaine propre
Le deployeur vérifie que lenregistrement existe :
```dns
*.tag.monetablissement.fr A <IP_DU_VPS>
```
---
## 6. Certificat wildcard
### Principe
Un seul certificat wildcard couvre toutes les instances futures de l’établissement.
### Mise en œuvre avec Caddy
Le deployeur génère le `Caddyfile` :
```caddy
*.tag.edudeploy.com {
tls {
dns infomaniak {env.INFOMANIAK_API_TOKEN}
}
reverse_proxy resolver:2020 {
header_up Host {host}
}
}
```
Pour un domaine propre, le provider DNS est celui du client.
### Renouvellement
Géré automatiquement par Caddy.
---
## 7. Déploiement de la stack studioE5
Le deployeur lance la stack Docker Compose complète :
```bash
cd /opt/studioe5-<tag>
docker compose up -d
```
Services déployés :
- `server` : API + WebSocket + UI Next.js
- `resolver` : reverse proxy interne vers les instances
- `resolver-vpn` : sidecar Tailscale dans le netns du resolver
- `caddy` : reverse proxy public + TLS
- `headscale` : contrôleur Tailscale
- `postgres` : base de données
---
## 8. Initialisation de Headscale
Le deployeur initialise Headscale et crée les clés nécessaires :
```bash
# Création de lutilisateur dédié au resolver
docker compose exec headscale headscale users create resolver
# Création de la clé pré-auth réutilisable pour les agents
docker compose exec headscale headscale preauthkeys create \
--user studioe5 \
--reusable \
--tags tag:student-agent \
-e 87600h
# Création de la clé pré-auth pour le resolver
docker compose exec headscale headscale preauthkeys create \
--user resolver \
--tags tag:resolver \
-e 87600h
# Création dune clé API Headscale valable 10 ans
docker compose exec headscale headscale apikeys create -e 87600h
```
Ces secrets sont stockés dans le `.env` du serveur.
---
## 9. Création du compte administrateur de l’établissement
Une fois la stack déployée, le deployeur (ou le hub) crée le premier compte administrateur de l’établissement via lAPI du serveur nouvellement déployé.
Rôles :
- `admin` : gestion des élèves, instances, agents.
- `teacher` : gestion limitée à certaines classes/groupes.
- `superadmin` (studioE5) : accès transverse.
Ladministrateur reçoit un lien dactivation sécurisé.
---
## 10. Génération des codes dactivation
Le deployeur configure le serveur pour permettre la génération de codes dactivation.
### Règles de sécurité (implémentées côté application studioE5)
- Génération avec `crypto.randomBytes`
- Alphabet sans ambiguïté : `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`
- 6 caractères
- Expiration après 60 minutes
- Invalidation après usage
- Rate-limiting : 5 tentatives par code / 5 tentatives par `nodeId` sur 15 minutes
### Flux
1. Ladministrateur génère un code pour un élève.
2. L’élève saisit le code dans lagent.
3. Le serveur valide et renvoie :
- lidentité de l’élève
- lURL Headscale
- une clé pré-auth Headscale éphémère
4. Lagent démarre automatiquement le VPN.
---
## 11. Build et mise à disposition de lagent
### Principe
Lagent est un binaire générique, mais il doit être capable de se connecter au bon serveur. Le deployeur génère un agent pré-configuré ou un installeur qui embarque lURL du serveur de l’établissement.
### Build
```bash
cd /opt/studioe5-<tag>/agent
./download-tailscale-bins.sh 1.98.4
./build.sh
```
Artifacts générés :
- `studioE5-agent-vX.Y.Z-windows.zip`
- `studioE5-agent-vX.Y.Z.exe`
- `studioE5-agent-vX.Y.Z` (Linux)
### Mise à disposition
Les fichiers sont servis par Caddy depuis `server/public/agent/` :
```
https://tag.edudeploy.com/studioE5-agent-vX.Y.Z-windows.zip
```
---
## 12. Activation de lagent
### Activation zéro-config
1. L’élève télécharge lagent depuis lURL de l’établissement.
2. Il extrait larchive et lance `studioE5-agent.exe`.
3. Il ouvre `http://localhost:7070`.
4. Il saisit le code dactivation à 6 caractères.
5. Lagent contacte le serveur, récupère la configuration et démarre le VPN.
> Les détails techniques du VPN on-demand (named pipes Windows, logs, ACL, tokens, clés éphémères) sont documentés dans `SUIVI_VPN_ONDEMAND.md`.
---
## 13. Création dune instance et construction de lURL (validation)
Le deployeur ou ladministrateur crée une première instance pour valider le déploiement.
### Format dURL
```
<appli>-<initiales><id-court>.<tag>.<domaine>
```
Exemple :
```
wp-jd47.ljf.edudeploy.com
```
Avec :
- `wp` : type dapplication
- `jd` : initiales de l’élève
- `47` : identifiant court unique
- `ljf` : tag de l’établissement
- `edudeploy.com` : domaine de base
### Mapping type dapplication → préfixe
| Application | Préfixe |
|-------------|---------|
| WordPress | `wp` |
| PrestaShop | `ps` |
| Moodle | `mdl` |
| Nextcloud | `nc` |
### Protection de lidentité
- LURL ne contient pas le nom complet de l’élève.
- Seules les initiales + un identifiant court opaque sont exposées.
---
## 14. Modèles de données du deployeur
### Table / modèle `Organization` (établissement dans le hub)
```json
{
"id": "uuid",
"name": "Lycée Jules Ferry",
"tag": "ljf",
"domainMode": "managed",
"baseDomain": "edudeploy.com",
"adminEmail": "admin@ljf.fr",
"status": "active",
"createdAt": "2026-06-25T17:28:07Z"
}
```
### Table / modèle `Deployment` (déploiement sur un VPS)
```json
{
"id": "uuid",
"organizationId": "uuid",
"serverIp": "203.0.113.10",
"serverHostname": "ljf.studioe5.edudeploy.com",
"wildcardDnsConfigured": true,
"wildcardCertificateReady": true,
"dnsProvider": "infomaniak",
"dnsProviderTokenRef": "env:INFOMANIAK_TOKEN_LJF",
"headscaleApiKeyRef": "env:HEADSCALE_API_KEY_LJF",
"status": "ready",
"deployedAt": "2026-06-25T17:28:07Z"
}
```
### Table / modèle `Student` (dans lapplication studioE5 déployée)
```json
{
"id": "uuid",
"organizationId": "uuid",
"firstName": "Jean",
"lastName": "Dupont",
"initials": "jd",
"activationCode": "AB3D9F",
"activationCodeExpiresAt": "2026-06-25T18:28:07Z",
"nodeId": "vps-8fc665eb",
"nodeToken": "..."
}
```
### Table / modèle `Instance` (dans lapplication studioE5 déployée)
```json
{
"id": "cmqqgrur20001lw67t2bdgzkg",
"organizationId": "uuid",
"studentId": "uuid",
"nodeId": "vps-8fc665eb",
"templateId": "wordpress-wordpress-latest",
"applicationPrefix": "wp",
"shortId": "47",
"subdomain": "wp-jd47",
"fqdn": "wp-jd47.ljf.edudeploy.com",
"port": 8001,
"status": "running"
}
```
---
## 15. Sécurité et RGPD
### Protection de lidentité de l’élève
- LURL publique ne contient pas le nom complet de l’élève.
- Seules les initiales + un identifiant court opaque sont exposées.
### Isolation réseau
- Les agents élèves ne peuvent pas communiquer entre eux (ACL Headscale).
- Le resolver est le seul service autorisé à joindre les agents sur leurs ports dinstance.
### Authentification
- Token unique par agent (`node.token`).
- Clé API interne pour les endpoints serveur → agent.
- Sessions NextAuth sur les routes API métier.
### Clés pré-auth Headscale
- Éphémères, à usage unique, 15 minutes dexpiration.
- Non persistées côté agent.
---
## 16. Checklist de validation du deployeur
À lissue dun onboarding, les points suivants doivent être validés :
- [ ] L’établissement est créé dans le hub avec un tag unique.
- [ ] Le VPS est provisionné et accessible en SSH.
- [ ] Docker et Docker Compose sont installés.
- [ ] Le DNS wildcard est résolu (`*.tag.edudeploy.com` → IP du VPS).
- [ ] Le certificat wildcard est obtenu et valide.
- [ ] La stack studioE5 est démarrée (`docker compose ps`).
- [ ] Headscale est initialisé avec les utilisateurs et clés nécessaires.
- [ ] Le compte administrateur de l’établissement est créé.
- [ ] Un code dactivation peut être généré pour un élève.
- [ ] Lagent est buildé et téléchargeable depuis le serveur de l’établissement.
- [ ] Lagent sactive avec le code zéro-config.
- [ ] Une instance peut être créée et son URL est accessible en HTTPS.
- [ ] Deux instances différentes reçoivent des URL uniques.
- [ ] Le flux HTTPS complet retourne bien HTTP 200.
---
## 17. Roadmap du deployeur
### Court terme (MVP)
- Déploiement manuel ou semi-automatisé dun nouvel établissement sur un VPS.
- Domaine géré par studioE5 uniquement.
- Build des images sur le VPS cible.
- Agent avec URL serveur hardcodée ou fournie à lactivation.
### Moyen terme
- **Agent générique** : déterminer lURL serveur cible à lactivation (code structuré, hub de résolution, ou champ URL).
- **Script de provisionning** : installation Docker, déploiement stack, génération secrets, DNS wildcard.
- **Registry dimages privé** : builder une fois, déployer partout.
- Support de domaines propres à l’établissement.
- Support multi-registrar DNS.
### Long terme
- **Hub central multi-clients** : dashboard superadmin, gestion des versions, logs distants.
- **Mises à jour à distance** : pousser une nouvelle version du serveur et de lagent sur tous les déploiements.
- **Monitoring / support** : alertes serveur down, certificat expiré, agent hors ligne.
- **Tests automatisés** : validation du flux activation → VPN → instance → HTTPS public à chaque déploiement.
- **Console/log intégré et barre de progression** dans lagent.
- Génération automatique de codes dactivation par import CSV.
+18
View File
@@ -0,0 +1,18 @@
{
"tagOwners": {
"tag:student-agent": ["studioe5@studioe5.local"],
"tag:resolver": ["resolver@studioe5.local"]
},
"acls": [
{
"action": "accept",
"src": ["tag:resolver"],
"dst": ["tag:student-agent:*"]
},
{
"action": "accept",
"src": ["tag:student-agent"],
"dst": ["tag:resolver:2020"]
}
]
}
+4
View File
@@ -38,6 +38,10 @@ database:
sqlite:
path: /etc/headscale/db.sqlite
policy:
path: /etc/headscale/acl_policy.hujson
mode: file
log:
format: text
level: info
+22 -4
View File
@@ -1,13 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth, requireRole, getScopedEstablishmentId, forbidden } from "@/lib/api-auth";
export async function GET(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const { searchParams } = new URL(req.url);
const establishmentId = searchParams.get("establishmentId");
if (!establishmentId) return NextResponse.json({ error: "Missing establishmentId" }, { status: 400 });
const requestedId = searchParams.get("establishmentId");
const establishmentId = getScopedEstablishmentId(user, requestedId);
if (establishmentId instanceof NextResponse) return establishmentId;
const where = establishmentId ? { establishmentId } : {};
const classes = await prisma.class.findMany({
where: { establishmentId },
where,
include: { _count: { select: { students: true } } },
orderBy: { createdAt: "desc" },
});
@@ -15,8 +22,19 @@ export async function GET(req: NextRequest) {
}
export async function POST(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const denied = requireRole(user, "superadmin", "admin");
if (denied) return denied;
const body = await req.json();
const { establishmentId, name, level } = body;
const requestedId = body.establishmentId;
const establishmentId = getScopedEstablishmentId(user, requestedId);
if (establishmentId instanceof NextResponse) return establishmentId;
if (!establishmentId) return forbidden();
const { name, level } = body;
const cls = await prisma.class.create({
data: { establishmentId, name, level },
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
const AGENT_VERSION = "0.3.0";
const AGENT_VERSION = "0.3.4";
const AGENT_BIN_NAME = "studioE5-agent";
export async function GET() {
+12
View File
@@ -1,9 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { hashPassword } from "@/lib/auth";
import { requireAuth, requireRole } from "@/lib/api-auth";
export async function GET() {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const where = user.role === "superadmin" ? {} : { id: user.establishmentId };
const establishments = await prisma.establishment.findMany({
where,
include: { subscription: true, _count: { select: { users: true, classes: true } } },
orderBy: { createdAt: "desc" },
});
@@ -11,6 +17,12 @@ export async function GET() {
}
export async function POST(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const denied = requireRole(user, "superadmin");
if (denied) return denied;
const body = await req.json();
const { name, slug, adminEmail, adminPassword } = body;
+88 -16
View File
@@ -1,16 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { prisma } from "@/lib/prisma";
import { sendToNode } from "@/lib/websocket";
import { authOptions } from "@/lib/auth-config";
async function requireAuth() {
const session = await getServerSession(authOptions);
if (!session?.user) return null;
return session.user as { id: string; email: string; role: string; establishmentId?: string };
}
function userCanAccessNode(user: { role: string; establishmentId?: string }, node: any) {
if (user.role === "superadmin") return true;
const establishmentId = node?.student?.class?.establishmentId;
return establishmentId && establishmentId === user.establishmentId;
}
function userCanAccessInstance(user: { role: string; establishmentId?: string }, instance: any) {
if (user.role === "superadmin") return true;
const establishmentId = instance?.node?.student?.class?.establishmentId;
return establishmentId && establishmentId === user.establishmentId;
}
export async function GET(req: NextRequest) {
const user = await requireAuth();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(req.url);
const nodeId = searchParams.get("nodeId");
const establishmentId = searchParams.get("establishmentId");
const establishmentIdParam = searchParams.get("establishmentId");
let where: any = {};
if (nodeId) where.nodeId = nodeId;
if (establishmentId) {
const classes = await prisma.class.findMany({ where: { establishmentId }, select: { id: true } });
if (user.role !== "superadmin") {
const classes = await prisma.class.findMany({
where: { establishmentId: user.establishmentId },
select: { id: true },
});
const students = await prisma.student.findMany({
where: { classId: { in: classes.map((c) => c.id) } },
select: { id: true },
});
const nodes = await prisma.node.findMany({
where: { studentId: { in: students.map((s) => s.id) } },
select: { id: true },
});
where.nodeId = { in: nodes.map((n) => n.id) };
} else if (establishmentIdParam) {
const classes = await prisma.class.findMany({ where: { establishmentId: establishmentIdParam }, select: { id: true } });
const students = await prisma.student.findMany({ where: { classId: { in: classes.map((c) => c.id) } }, select: { id: true } });
const nodes = await prisma.node.findMany({ where: { studentId: { in: students.map((s) => s.id) } }, select: { id: true } });
where.nodeId = { in: nodes.map((n) => n.id) };
@@ -39,12 +77,8 @@ export async function GET(req: NextRequest) {
const enriched = instances.map((inst) => {
const domain = inst.node.student?.class.establishment?.domain;
const publicUrl = domain
? `https://${inst.id}.${domain}`
: null;
const localUrl = inst.node.tailscaleIp
? `http://${inst.node.tailscaleIp}:${inst.port}`
: null;
const publicUrl = domain ? `https://${inst.id}.${domain}` : null;
const localUrl = inst.node.tailscaleIp ? `http://${inst.node.tailscaleIp}:${inst.port}` : null;
return {
...inst,
publicUrl,
@@ -56,22 +90,32 @@ export async function GET(req: NextRequest) {
}
export async function POST(req: NextRequest) {
const user = await requireAuth();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const { nodeId, templateId, port } = body;
if (!nodeId || !templateId) {
return NextResponse.json({ error: "Missing nodeId or templateId" }, { status: 400 });
}
const template = await prisma.template.findUnique({ where: { id: templateId } });
if (!template) return NextResponse.json({ error: "Template not found" }, { status: 404 });
const instance = await prisma.instance.create({
data: { nodeId, templateId, port: port || 8080, status: "stopped" },
});
const node = await prisma.node.findUnique({
where: { id: nodeId },
include: { student: { include: { class: { include: { establishment: true } } } } },
});
if (!node) return NextResponse.json({ error: "Node not found" }, { status: 404 });
if (!userCanAccessNode(user, node)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const domain = node?.student?.class.establishment?.domain;
const instance = await prisma.instance.create({
data: { nodeId, templateId, port: port || 8080, status: "stopped" },
});
const domain = node.student?.class.establishment?.domain;
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
const publicUrl = domain ? `https://${publicDomain}` : null;
const sent = sendToNode(nodeId, {
@@ -94,14 +138,28 @@ export async function POST(req: NextRequest) {
}
export async function PATCH(req: NextRequest) {
const user = await requireAuth();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const { id, action } = body;
const instance = await prisma.instance.findUnique({ where: { id }, include: { template: true, node: { include: { student: { include: { class: { include: { establishment: true } } } } } } } });
if (!id || !action) {
return NextResponse.json({ error: "Missing id or action" }, { status: 400 });
}
const instance = await prisma.instance.findUnique({
where: { id },
include: { template: true, node: { include: { student: { include: { class: { include: { establishment: true } } } } } } },
});
if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!userCanAccessInstance(user, instance)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const domain = instance.node.student?.class.establishment?.domain;
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
const publicUrl = domain ? `https://${publicDomain}` : null;
if (action === "stop") {
sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
await prisma.instance.update({ where: { id }, data: { status: "stopped" } });
@@ -131,16 +189,30 @@ export async function PATCH(req: NextRequest) {
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
});
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
} else {
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
export async function DELETE(req: NextRequest) {
const user = await requireAuth();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
const instance = await prisma.instance.findUnique({ where: { id } });
const instance = await prisma.instance.findUnique({
where: { id },
include: { node: { include: { student: { include: { class: { include: { establishment: true } } } } } } },
});
if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!userCanAccessInstance(user, instance)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (instance) sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
await prisma.instance.delete({ where: { id } });
return NextResponse.json({ ok: true });
@@ -1,7 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { sendToNode } from "@/lib/websocket";
function getBearerToken(req: NextRequest): string | null {
const auth = req.headers.get("authorization") || "";
const match = auth.match(/^Bearer\s+(\S+)$/i);
return match ? match[1] : null;
}
export async function POST(req: NextRequest) {
const apiKey = process.env.INTERNAL_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "Internal API key not configured" }, { status: 500 });
}
const token = getBearerToken(req);
if (!token || token !== apiKey) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const { nodeId, message } = body;
if (!nodeId || !message) {
+7 -1
View File
@@ -1,9 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth, getScopedEstablishmentId, forbidden } from "@/lib/api-auth";
export async function GET(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const { searchParams } = new URL(req.url);
const establishmentId = searchParams.get("establishmentId");
const requestedId = searchParams.get("establishmentId");
const establishmentId = getScopedEstablishmentId(user, requestedId);
if (establishmentId instanceof NextResponse) return establishmentId;
let where: any = {};
if (establishmentId) {
+15
View File
@@ -1,7 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
function getBearerToken(req: NextRequest): string | null {
const auth = req.headers.get("authorization") || "";
const match = auth.match(/^Bearer\s+(\S+)$/i);
return match ? match[1] : null;
}
export async function GET(req: NextRequest) {
const apiKey = process.env.INTERNAL_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "Internal API key not configured" }, { status: 500 });
}
const token = getBearerToken(req);
if (!token || token !== apiKey) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const subdomain = searchParams.get("subdomain");
+4 -8
View File
@@ -1,12 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
function generateCode(length = 6) {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < length; i++) code += chars.charAt(Math.floor(Math.random() * chars.length));
return code;
}
import { generateUniqueActivationCode } from "@/lib/activation";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
@@ -31,13 +25,15 @@ export async function GET(req: NextRequest) {
export async function POST(req: NextRequest) {
const body = await req.json();
const { classId, firstName, lastName, email } = body;
const { code, expiresAt } = await generateUniqueActivationCode();
const student = await prisma.student.create({
data: {
classId,
firstName,
lastName,
email,
activationCode: generateCode(),
activationCode: code,
activationCodeExpiresAt: expiresAt,
},
});
return NextResponse.json(student, { status: 201 });
+63 -8
View File
@@ -1,25 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth, requireRole, forbidden } from "@/lib/api-auth";
function templateAccessWhere(user: { role: string; establishmentId?: string }, establishmentId?: string | null) {
if (user.role === "superadmin" && establishmentId) {
return { OR: [{ isPublic: true }, { establishmentId }] };
}
if (user.establishmentId) {
return { OR: [{ isPublic: true }, { establishmentId: user.establishmentId }] };
}
return { isPublic: true };
}
async function canManageTemplate(user: { role: string; establishmentId?: string }, id: string) {
if (user.role === "superadmin") return true;
const template = await prisma.template.findUnique({ where: { id } });
if (!template) return false;
return template.establishmentId === user.establishmentId;
}
export async function GET(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const { searchParams } = new URL(req.url);
const establishmentId = searchParams.get("establishmentId");
const requestedEst = searchParams.get("establishmentId");
const where = user.role === "superadmin" && !requestedEst ? {} : templateAccessWhere(user, requestedEst);
const templates = await prisma.template.findMany({
where: {
OR: [
{ isPublic: true },
...(establishmentId ? [{ establishmentId }] : []),
],
},
where,
orderBy: { createdAt: "desc" },
});
return NextResponse.json(templates);
}
export async function POST(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const denied = requireRole(user, "superadmin", "admin");
if (denied) return denied;
const body = await req.json();
const { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy } = body;
let { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy } = body;
if (user.role !== "superadmin") {
if (establishmentId && establishmentId !== user.establishmentId) {
return forbidden();
}
establishmentId = user.establishmentId;
}
const template = await prisma.template.create({
data: { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy },
});
@@ -27,16 +59,39 @@ export async function POST(req: NextRequest) {
}
export async function PUT(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const denied = requireRole(user, "superadmin", "admin");
if (denied) return denied;
const body = await req.json();
const { id, ...data } = body;
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
if (!(await canManageTemplate(user, id))) return forbidden();
if (user.role !== "superadmin" && data.establishmentId && data.establishmentId !== user.establishmentId) {
return forbidden();
}
const template = await prisma.template.update({ where: { id }, data });
return NextResponse.json(template);
}
export async function DELETE(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const denied = requireRole(user, "superadmin", "admin");
if (denied) return denied;
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
if (!(await canManageTemplate(user, id))) return forbidden();
await prisma.template.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+47 -3
View File
@@ -1,14 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { hashPassword } from "@/lib/auth";
import { requireAuth, requireRole, forbidden } from "@/lib/api-auth";
export async function GET(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const { searchParams } = new URL(req.url);
const establishmentId = searchParams.get("establishmentId");
const role = searchParams.get("role");
if (user.role !== "superadmin") {
if (establishmentId && establishmentId !== user.establishmentId) {
return forbidden();
}
}
const where: any = {};
if (establishmentId) where.establishmentId = establishmentId;
else if (user.role !== "superadmin") where.establishmentId = user.establishmentId;
if (role) where.role = role;
const users = await prisma.user.findMany({
@@ -19,23 +30,56 @@ export async function GET(req: NextRequest) {
}
export async function POST(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const denied = requireRole(user, "superadmin", "admin");
if (denied) return denied;
const body = await req.json();
const { email, password, role, establishmentId } = body;
const user = await prisma.user.create({
if (!email || !password || !role) {
return NextResponse.json({ error: "Missing email, password or role" }, { status: 400 });
}
if (user.role === "admin") {
if (role === "superadmin") return forbidden();
if (establishmentId && establishmentId !== user.establishmentId) return forbidden();
}
const finalEstablishmentId = user.role === "superadmin" ? establishmentId : user.establishmentId;
const newUser = await prisma.user.create({
data: {
email,
password: await hashPassword(password),
role,
establishmentId,
establishmentId: finalEstablishmentId,
},
});
return NextResponse.json(user, { status: 201 });
return NextResponse.json(newUser, { status: 201 });
}
export async function DELETE(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const denied = requireRole(user, "superadmin", "admin");
if (denied) return denied;
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (user.role === "admin") {
if (target.role === "superadmin") return forbidden();
if (target.establishmentId !== user.establishmentId) return forbidden();
}
await prisma.user.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+3 -10
View File
@@ -3,17 +3,9 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth-config";
import { generateUniqueActivationCode } from "@/lib/activation";
import { redirect } from "next/navigation";
function generateCode(): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
export async function deleteStudent(formData: FormData) {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login");
@@ -54,9 +46,10 @@ export async function generateActivationCodeAction(formData: FormData) {
if (!student) return;
const { code, expiresAt } = await generateUniqueActivationCode();
await prisma.student.update({
where: { id },
data: { activationCode: generateCode() },
data: { activationCode: code, activationCodeExpiresAt: expiresAt },
});
redirect(`/dashboard/students/${id}`);
+4 -10
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth-config";
import { generateUniqueActivationCode } from "@/lib/activation";
import { redirect } from "next/navigation";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -17,15 +18,6 @@ const schema = z.object({
classId: z.string().min(1),
});
function generateActivationCode(): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
async function createStudent(formData: FormData) {
"use server";
const session = await getServerSession(authOptions);
@@ -35,13 +27,15 @@ async function createStudent(formData: FormData) {
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return;
const { code, expiresAt } = await generateUniqueActivationCode();
await prisma.student.create({
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
email: parsed.data.email,
classId: parsed.data.classId,
activationCode: generateActivationCode(),
activationCode: code,
activationCodeExpiresAt: expiresAt,
},
});
+25
View File
@@ -0,0 +1,25 @@
import { randomBytes } from "crypto";
import { prisma } from "./prisma";
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const CODE_LENGTH = 6;
const CODE_TTL_MINUTES = 60;
export function generateActivationCode(): { code: string; expiresAt: Date } {
let code = "";
const bytes = randomBytes(CODE_LENGTH);
for (let i = 0; i < CODE_LENGTH; i++) {
code += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
}
const expiresAt = new Date(Date.now() + CODE_TTL_MINUTES * 60 * 1000);
return { code, expiresAt };
}
export async function generateUniqueActivationCode(retries = 5): Promise<{ code: string; expiresAt: Date }> {
for (let i = 0; i < retries; i++) {
const { code, expiresAt } = generateActivationCode();
const existing = await prisma.student.findUnique({ where: { activationCode: code } });
if (!existing) return { code, expiresAt };
}
throw new Error("Failed to generate a unique activation code");
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "./auth-config";
export type ApiUser = {
id: string;
email: string;
role: "superadmin" | "admin" | "teacher";
establishmentId?: string;
};
export async function requireAuth(): Promise<ApiUser | NextResponse> {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return session.user as ApiUser;
}
export function requireRole(user: ApiUser, ...allowed: string[]): NextResponse | null {
if (!allowed.includes(user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
export function forbidden(): NextResponse {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
export function getScopedEstablishmentId(user: ApiUser, requested?: string | null): string | undefined | NextResponse {
if (user.role === "superadmin") {
return requested ?? undefined;
}
if (requested && requested !== user.establishmentId) {
return forbidden();
}
return user.establishmentId;
}
+75
View File
@@ -0,0 +1,75 @@
interface HeadscaleUser {
id: string;
name: string;
}
interface HeadscalePreAuthKey {
key: string;
expiration: string;
aclTags: string[];
}
export async function getHeadscaleUserId(
baseUrl: string,
apiKey: string,
userName: string
): Promise<string> {
const res = await fetch(
`${baseUrl}/api/v1/user?name=${encodeURIComponent(userName)}`,
{
headers: { Authorization: `Bearer ${apiKey}` },
}
);
if (!res.ok) {
throw new Error(
`Headscale list users failed: ${res.status} ${await res.text()}`
);
}
const data = (await res.json()) as { users: HeadscaleUser[] };
const user = data.users.find((u) => u.name === userName);
if (!user) {
throw new Error(`Headscale user not found: ${userName}`);
}
return user.id;
}
export async function createEphemeralPreAuthKey(
baseUrl: string,
apiKey: string,
userId: string,
options: {
expirationMinutes?: number;
aclTags?: string[];
} = {}
): Promise<string> {
const expirationMinutes = options.expirationMinutes ?? 15;
const aclTags = options.aclTags ?? [];
const expiration = new Date(
Date.now() + expirationMinutes * 60 * 1000
).toISOString();
const res = await fetch(`${baseUrl}/api/v1/preauthkey`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
user: userId,
reusable: false,
ephemeral: false,
expiration,
aclTags,
}),
});
if (!res.ok) {
throw new Error(
`Headscale create preauthkey failed: ${res.status} ${await res.text()}`
);
}
const data = (await res.json()) as { preAuthKey: HeadscalePreAuthKey };
return data.preAuthKey.key;
}
+216 -18
View File
@@ -1,5 +1,8 @@
import { WebSocketServer, WebSocket } from "ws";
import { randomBytes } from "crypto";
import type { IncomingMessage } from "http";
import { prisma } from "./prisma";
import { createEphemeralPreAuthKey, getHeadscaleUserId } from "./headscale";
interface NodeMessage {
action: string;
@@ -12,14 +15,68 @@ interface NodeMessage {
studentName?: string;
error?: string;
tailscaleIp?: string;
token?: string;
}
const nodes = new Map<string, WebSocket>();
interface AttemptWindow {
count: number;
firstAttempt: number;
}
const activationAttemptsByCode = new Map<string, AttemptWindow>();
const activationAttemptsByNode = new Map<string, AttemptWindow>();
const MAX_ACTIVATION_ATTEMPTS = 5;
const ACTIVATION_WINDOW_MS = 15 * 60 * 1000;
const HEADSCALE_USER = "studioe5";
const HEADSCALE_AGENT_TAG = "tag:student-agent";
const HEADSCALE_KEY_EXPIRATION_MINUTES = 15;
let headscaleUserIdCache: string | null = null;
function recordActivationAttempt(map: Map<string, AttemptWindow>, key: string): boolean {
const now = Date.now();
const win = map.get(key);
if (!win || now - win.firstAttempt > ACTIVATION_WINDOW_MS) {
map.set(key, { count: 1, firstAttempt: now });
return true;
}
win.count++;
return win.count <= MAX_ACTIVATION_ATTEMPTS;
}
function clearActivationAttempts(code: string, nodeId: string) {
activationAttemptsByCode.delete(code);
activationAttemptsByNode.delete(nodeId);
}
function generateNodeToken(): string {
return randomBytes(32).toString("hex");
}
function getBearerToken(req: IncomingMessage): string | null {
const auth = req.headers.authorization || "";
const match = auth.match(/^Bearer\s+(\S+)$/i);
return match ? match[1] : null;
}
function close(ws: WebSocket, code: number, reason: string) {
try {
ws.close(code, reason);
} catch {
// ignore
}
}
export function initWebSocketServer(wss: WebSocketServer) {
wss.on("connection", (ws: WebSocket) => {
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
let nodeId: string | null = null;
console.log("[WS] New connection");
let authenticated = false;
const token = getBearerToken(req);
console.log("[WS] New connection", token ? "(token provided)" : "(no token)");
ws.on("message", async (raw) => {
try {
@@ -27,19 +84,69 @@ export function initWebSocketServer(wss: WebSocketServer) {
console.log("[WS] Received:", msg.action, "from", msg.nodeId || nodeId);
if (msg.action === "register" && msg.nodeId) {
nodeId = msg.nodeId;
nodes.set(nodeId, ws);
await prisma.node.upsert({
where: { id: nodeId },
update: { status: "online", lastSeen: new Date() },
create: { id: nodeId, status: "online", lastSeen: new Date() },
});
const id = msg.nodeId;
const existing = await prisma.node.findUnique({ where: { id } });
if (token) {
// Token supplied: it must match the stored token for this node.
if (!existing || existing.token !== token) {
console.log("[WS] Invalid token for node", id);
close(ws, 1008, "invalid token");
return;
}
authenticated = true;
} else if (existing && existing.token) {
// Existing node has a token but none was supplied.
console.log("[WS] Missing token for node", id);
close(ws, 1008, "missing token");
return;
} else if (existing) {
// Migration path: existing node without a token gets one on first register.
const newToken = generateNodeToken();
await prisma.node.update({
where: { id },
data: { token: newToken, status: "online", lastSeen: new Date() },
});
ws.send(JSON.stringify({ action: "set_token", token: newToken }));
authenticated = true;
}
// If the node does not exist yet, we stay unauthenticated until activation.
nodeId = id;
if (authenticated) {
const existing = nodes.get(id);
if (existing && existing !== ws && existing.readyState === WebSocket.OPEN) {
console.log("[WS] Superseding previous connection for", id);
existing.close(1008, "superseded");
}
nodes.set(id, ws);
await prisma.node.upsert({
where: { id },
update: { status: "online", lastSeen: new Date() },
create: { id, status: "online", lastSeen: new Date() },
});
}
ws.send(JSON.stringify({ action: "registered" }));
return;
}
if (msg.action === "activate" && msg.code && msg.nodeId) {
nodeId = msg.nodeId;
const id = msg.nodeId;
nodeId = id;
if (!recordActivationAttempt(activationAttemptsByCode, msg.code) ||
!recordActivationAttempt(activationAttemptsByNode, id)) {
console.log("[WS] Too many activation attempts for code/node", msg.code, id);
ws.send(JSON.stringify({ action: "activation_failed", error: "Too many attempts" }));
return;
}
const existing = await prisma.node.findUnique({ where: { id } });
if (existing && existing.token && (!authenticated || nodeId !== id)) {
console.log("[WS] Node already activated and not authenticated:", id);
ws.send(JSON.stringify({ action: "activation_failed", error: "Node already activated" }));
return;
}
const student = await prisma.student.findUnique({
where: { activationCode: msg.code },
});
@@ -48,23 +155,97 @@ export function initWebSocketServer(wss: WebSocketServer) {
ws.send(JSON.stringify({ action: "activation_failed", error: "Invalid code" }));
return;
}
if (!student.activationCodeExpiresAt || student.activationCodeExpiresAt < new Date()) {
console.log("[WS] Expired code:", msg.code);
ws.send(JSON.stringify({ action: "activation_failed", error: "Code expired" }));
return;
}
const newToken = generateNodeToken();
await prisma.node.upsert({
where: { id: nodeId },
update: { studentId: student.id, status: "online", lastSeen: new Date() },
create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() },
where: { id },
update: {
studentId: student.id,
status: "online",
lastSeen: new Date(),
token: newToken,
},
create: {
id,
studentId: student.id,
status: "online",
lastSeen: new Date(),
token: newToken,
},
});
console.log("[WS] Activated:", student.firstName, student.lastName, "on", nodeId);
// Invalidate the activation code so it cannot be reused.
await prisma.student.update({
where: { id: student.id },
data: { activationCode: null, activationCodeExpiresAt: null },
});
clearActivationAttempts(msg.code, id);
authenticated = true;
const previous = nodes.get(id);
if (previous && previous !== ws && previous.readyState === WebSocket.OPEN) {
console.log("[WS] Superseding previous connection for", id);
previous.close(1008, "superseded");
}
nodes.set(id, ws);
const headscaleUrl = process.env.HEADSCALE_URL;
const headscaleApiKey = process.env.HEADSCALE_API_KEY;
const reusableAuthKey = process.env.HEADSCALE_AUTH_KEY;
if (!headscaleUrl) {
console.log("[WS] HEADSCALE_URL missing");
ws.send(JSON.stringify({ action: "activation_failed", error: "Server misconfiguration" }));
return;
}
let headscaleAuthKey: string;
try {
if (headscaleApiKey) {
if (!headscaleUserIdCache) {
headscaleUserIdCache = await getHeadscaleUserId(headscaleUrl, headscaleApiKey, HEADSCALE_USER);
}
headscaleAuthKey = await createEphemeralPreAuthKey(headscaleUrl, headscaleApiKey, headscaleUserIdCache, {
expirationMinutes: HEADSCALE_KEY_EXPIRATION_MINUTES,
aclTags: [HEADSCALE_AGENT_TAG],
});
console.log("[WS] Generated ephemeral Headscale key for", id);
} else if (reusableAuthKey) {
console.log("[WS] HEADSCALE_API_KEY not set, falling back to reusable HEADSCALE_AUTH_KEY");
headscaleAuthKey = reusableAuthKey;
} else {
console.log("[WS] No Headscale key available");
ws.send(JSON.stringify({ action: "activation_failed", error: "Server misconfiguration" }));
return;
}
} catch (err) {
console.error("[WS] Failed to create ephemeral Headscale key:", err);
ws.send(JSON.stringify({ action: "activation_failed", error: "Failed to create VPN key" }));
return;
}
console.log("[WS] Activated:", student.firstName, student.lastName, "on", id);
ws.send(JSON.stringify({
action: "activated",
studentId: student.id,
studentName: `${student.firstName} ${student.lastName}`,
headscaleUrl: process.env.HEADSCALE_URL,
headscaleAuthKey: process.env.HEADSCALE_AUTH_KEY,
headscaleUrl,
headscaleAuthKey,
token: newToken,
}));
return;
}
if (msg.action === "heartbeat" && nodeId) {
if (!authenticated || !nodeId) {
console.log("[WS] Unauthenticated message", msg.action, "ignored");
return;
}
if (msg.action === "heartbeat") {
await prisma.node.upsert({
where: { id: nodeId },
update: { lastSeen: new Date() },
@@ -73,7 +254,7 @@ export function initWebSocketServer(wss: WebSocketServer) {
return;
}
if (msg.action === "tailscale_ip" && nodeId && msg.tailscaleIp) {
if (msg.action === "tailscale_ip" && msg.tailscaleIp) {
await prisma.node.update({
where: { id: nodeId },
data: { tailscaleIp: msg.tailscaleIp },
@@ -90,6 +271,23 @@ export function initWebSocketServer(wss: WebSocketServer) {
return;
}
if (msg.action === "instance_stopped" && msg.instanceId) {
await prisma.instance.update({
where: { id: msg.instanceId },
data: { status: "stopped" },
});
console.log("[WS] Instance stopped:", msg.instanceId);
return;
}
if (msg.action === "instance_deleted" && msg.instanceId) {
await prisma.instance.delete({
where: { id: msg.instanceId },
});
console.log("[WS] Instance deleted:", msg.instanceId);
return;
}
if (msg.action === "instance_error" && msg.instanceId) {
await prisma.instance.update({
where: { id: msg.instanceId },
+11 -9
View File
@@ -50,21 +50,23 @@ model Class {
}
model Student {
id String @id @default(cuid())
classId String
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
firstName String
lastName String
email String
activationCode String? @unique
createdAt DateTime @default(now())
nodes Node[]
id String @id @default(cuid())
classId String
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
firstName String
lastName String
email String
activationCode String? @unique
activationCodeExpiresAt DateTime?
createdAt DateTime @default(now())
nodes Node[]
}
model Node {
id String @id
studentId String?
student Student? @relation(fields: [studentId], references: [id], onDelete: Cascade)
token String? @unique
tailscaleIp String?
status String @default("offline")
lastSeen DateTime?
File diff suppressed because one or more lines are too long