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:
@@ -5,6 +5,11 @@ NEXTAUTH_URL=http://localhost
|
||||
SUPERADMIN_EMAIL=admin@edudeploy.fr
|
||||
SUPERADMIN_PASSWORD=CHANGE_ME
|
||||
HEADSCALE_URL=http://headscale:8080
|
||||
# Legacy reusable pre-auth key (kept for manual/debug setups).
|
||||
HEADSCALE_AUTH_KEY=CHANGE_ME
|
||||
# Headscale API key used by the server to generate ephemeral pre-auth keys.
|
||||
HEADSCALE_API_KEY=CHANGE_ME
|
||||
HEADSCALE_RESOLVER_AUTH_KEY=CHANGE_ME
|
||||
INTERNAL_API_KEY=CHANGE_ME
|
||||
GITEA_URL=http://gitea:3000
|
||||
GITEA_TOKEN=CHANGE_ME
|
||||
|
||||
@@ -26,3 +26,5 @@ headscale/*.key
|
||||
headscale/*.state
|
||||
agent/resolv.conf
|
||||
agent/tailscale-bin/
|
||||
agent/studioE5-agent-test
|
||||
server/tsconfig.tsbuildinfo
|
||||
|
||||
+391
-33
@@ -112,6 +112,59 @@ Validation manuelle sur Windows :
|
||||
.\tailscale.exe --socket="\\.\pipe\studioe5-tailscaled" status # => Logged out (NeedsLogin)
|
||||
```
|
||||
|
||||
## 🪟 Agent v0.3.5 – forwarding entrant Windows + UI locale + cycle de vie
|
||||
|
||||
### Problème
|
||||
|
||||
Sur Windows, Tailscale en `userspace-networking` ne forwarde pas automatiquement les connexions entrantes du Tailnet vers `localhost`. Résultat : les URLs publiques retournaient une erreur 502/timeout, bien que l’agent soit `online`.
|
||||
|
||||
Logs caractéristiques :
|
||||
```text
|
||||
client -> backend close connection: close tcp 100.64.0.12:8080->100.64.0.11:xxxxx: endpoint not connected
|
||||
```
|
||||
|
||||
### Solution : `tailscale serve` automatique
|
||||
|
||||
L’agent configure automatiquement un proxy TCP pour chaque instance démarrée :
|
||||
```powershell
|
||||
tailscale serve --bg --tcp=<port> tcp://localhost:<port>
|
||||
```
|
||||
|
||||
| Action agent | Commande Tailscale |
|
||||
|--------------|--------------------|
|
||||
| Démarrage d’instance | `serve --bg --tcp=<port> tcp://localhost:<port>` |
|
||||
| Arrêt d’instance | `serve --bg --tcp=<port> off` |
|
||||
| Suppression d’instance | `serve --bg --tcp=<port> off` |
|
||||
| Redémarrage de l’agent | reconfiguration pour les instances déjà `running` |
|
||||
|
||||
Fichiers modifiés : `agent/tailscale.go`, `agent/websocket.go`, `agent/main.go`, `agent/ui.go`.
|
||||
|
||||
### UI locale modernisée
|
||||
|
||||
- Tableau de bord avec indicateurs de service.
|
||||
- Liste des applications avec badges de statut.
|
||||
- Boutons d’action par instance : **Démarrer**, **Arrêter**, **Redémarrer**, **Supprimer**.
|
||||
- Panneau de logs et diagnostic intégré.
|
||||
- Panneau de configuration (URL serveur, Headscale, node ID).
|
||||
|
||||
### Cycle de vie des instances
|
||||
|
||||
- **Arrêter** → `docker compose stop` (volumes conservés).
|
||||
- **Démarrer** → `docker compose start` (ou `up -d` la première fois).
|
||||
- **Redémarrer** → `docker compose down -v` + recréation (données remises à zéro).
|
||||
- **Supprimer** → `docker compose down -v` + suppression des fichiers.
|
||||
- À la fermeture de l’agent, les instances en cours sont arrêtées proprement (`stop`) et le serveur est notifié (`instance_stopped`).
|
||||
|
||||
### Démarrage du VPN après activation
|
||||
|
||||
L’agent redémarre `tailscaled` automatiquement au lancement, même si la clé pré-auth a déjà été utilisée. Il se base sur l’état persistant `tailscaled.state` (`tailscale up` sans `--authkey`).
|
||||
|
||||
### Téléchargement
|
||||
|
||||
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5-windows.zip`
|
||||
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5.exe`
|
||||
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5`
|
||||
|
||||
## 🛠️ Commandes utiles pour reprendre
|
||||
|
||||
### Voir l’agent de test
|
||||
@@ -190,11 +243,11 @@ L’agent est servi par Caddy depuis le dossier `agent/` monté dans le conteneu
|
||||
|
||||
### Binaires disponibles
|
||||
|
||||
- **Windows (archive complète)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.3-windows.zip`
|
||||
- **Windows (archive complète)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5-windows.zip`
|
||||
- Contient `studioE5-agent.exe` + `tailscale-bin/windows/` (`tailscale.exe`, `tailscaled.exe`, `wintun.dll`) + `README-Windows.txt`.
|
||||
- **Windows (exécutable seul)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.3.exe`
|
||||
- **Windows (exécutable seul)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5.exe`
|
||||
- Nécessite d’avoir installé Tailscale Windows séparément ou d’avoir les binaires dans `tailscale-bin/windows/`.
|
||||
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.3`
|
||||
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5`
|
||||
|
||||
### Builder / préparer les binaires
|
||||
|
||||
@@ -208,13 +261,13 @@ cd /opt/studioe5-client-a/agent
|
||||
./build.sh
|
||||
```
|
||||
|
||||
Le `build.sh` génère automatiquement `studioE5-agent-v0.3.0-windows.zip` et copie les binaires versionnés dans `server/public/`.
|
||||
Le `build.sh` génère automatiquement `studioE5-agent-v0.3.5-windows.zip` et copie les binaires versionnés dans `server/public/`.
|
||||
|
||||
### Flow d’activation zéro-config (modèle commercialisable)
|
||||
|
||||
L’élève/employé n’a **aucune configuration technique** à saisir :
|
||||
|
||||
1. **Télécharger** l’agent Windows (`studioE5-agent-v0.3.0-windows.zip`).
|
||||
1. **Télécharger** l’agent Windows (`studioE5-agent-v0.3.4-windows.zip`).
|
||||
2. **Extraire** et **lancer** `studioE5-agent.exe`.
|
||||
3. **Entrer le code d’activation** à 6 caractères fourni par l’établissement (affiché dans l’UI locale `http://localhost:7070`).
|
||||
4. L’agent contacte le serveur, le serveur vérifie le code et renvoie **automatiquement** :
|
||||
@@ -245,47 +298,352 @@ Lancement :
|
||||
.\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data
|
||||
```
|
||||
|
||||
## 🔒 Durcissement du code d’activation
|
||||
|
||||
### Génération
|
||||
|
||||
- Les codes sont générés avec `crypto.randomBytes` (au lieu de `Math.random`).
|
||||
- Longueur conservée à 6 caractères, alphabet sans ambiguïté (`ABCDEFGHJKLMNPQRSTUVWXYZ23456789`).
|
||||
- Un champ `activationCodeExpiresAt` a été ajouté au modèle `Student` ; les codes expirent après **60 minutes**.
|
||||
|
||||
### Rate-limiting
|
||||
|
||||
- Maximum de **5 tentatives d’activation par code** sur une fenêtre de **15 minutes**.
|
||||
- Maximum de **5 tentatives par `nodeId`** sur la même fenêtre.
|
||||
- Au-delà, le serveur répond `activation_failed` avec `Too many attempts`.
|
||||
|
||||
### Cycle de vie
|
||||
|
||||
- Le code est **invalide après une activation réussie** (`activationCode` et `activationCodeExpiresAt` mis à `null`).
|
||||
- Un code expiré renvoie `Code expired`.
|
||||
- Un code déjà utilisé renvoie `Invalid code`.
|
||||
|
||||
### Tests validés
|
||||
|
||||
- Activation valide → `activated` + token node reçu.
|
||||
- Code expiré → `Code expired`.
|
||||
- Code déjà utilisé → `Invalid code`.
|
||||
- 5+ tentatives invalides → `Too many attempts`.
|
||||
|
||||
## 🔒 ACL Headscale (isolation du tailnet)
|
||||
|
||||
### Objectif
|
||||
|
||||
Par défaut, tous les nœuds du tailnet peuvent communiquer entre eux. Les ACL restreignent la connectivité au strict nécessaire :
|
||||
- les agents élèves ne peuvent pas se parler entre eux ;
|
||||
- le resolver peut atteindre les agents sur leurs ports d’instance ;
|
||||
- les agents peuvent joindre le resolver sur son port HTTP interne.
|
||||
|
||||
### Mise en œuvre
|
||||
|
||||
- Fichier de politique : `headscale/acl_policy.hujson`.
|
||||
- `headscale/config.yaml` pointe vers ce fichier via `policy.path`.
|
||||
- Le resolver a été déplacé dans un utilisateur Headscale dédié `resolver` (clé `HEADSCALE_RESOLVER_AUTH_KEY`).
|
||||
- Les agents utilisent l’utilisateur `studioe5` et sont tagués `tag:student-agent`.
|
||||
- Les nouveaux agents recevront automatiquement le tag via la nouvelle clé pré-auth `HEADSCALE_AUTH_KEY` (créée avec `--tags tag:student-agent`).
|
||||
|
||||
### Contenu de la politique
|
||||
|
||||
```json
|
||||
{
|
||||
"groups": {
|
||||
"group:agents": ["studioe5@studioe5.local"],
|
||||
"group:resolvers": ["resolver@studioe5.local"]
|
||||
},
|
||||
"tagOwners": {
|
||||
"tag:student-agent": ["studioe5@studioe5.local"],
|
||||
"tag:resolver": ["resolver@studioe5.local"]
|
||||
},
|
||||
"acls": [
|
||||
{ "action": "accept", "src": ["tag:resolver"], "dst": ["tag:student-agent:*"] },
|
||||
{ "action": "accept", "src": ["tag:student-agent"], "dst": ["tag:resolver:2020"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Tests validés
|
||||
|
||||
| Test | Résultat |
|
||||
|------|----------|
|
||||
| `resolver` ping agent | ✅ OK |
|
||||
| Agent → agent (port instance) | ❌ bloqué (timeout) |
|
||||
| Agent → resolver:2020 | ✅ OK |
|
||||
| Flux HTTPS public | ✅ HTTP 200 |
|
||||
|
||||
## 🔒 Authentification du canal serveur → agent
|
||||
|
||||
### Token d’authentification par nœud
|
||||
|
||||
- Le modèle `Node` dispose d’un champ `token` unique.
|
||||
- L’agent envoie son token dans l’en-tête `Authorization: Bearer <token>` lors de la connexion WebSocket.
|
||||
- Le serveur rejette toute connexion/register dont le token ne correspond pas au `nodeId` (fermeture `1008`).
|
||||
- Lors de l’activation, le serveur génère un token aléatoire (32 octets hex) et le renvoie dans le message `activated` ; l’agent le sauvegarde dans `<data-dir>/node.token` (permissions `0600`).
|
||||
- Pour les nœuds existants sans token, le serveur en génère un à la première connexion et l’envoie via `set_token`.
|
||||
|
||||
### Endpoint `/api/internal/send-to-node`
|
||||
|
||||
- Protégé par la variable d’environnement `INTERNAL_API_KEY`.
|
||||
- Requiert l’en-tête `Authorization: Bearer <INTERNAL_API_KEY>`.
|
||||
- Appel sans clé → `401 Unauthorized`.
|
||||
|
||||
### Routes API métier
|
||||
|
||||
- Les routes de gestion des instances (`/api/instances`) requièrent une session NextAuth valide.
|
||||
- Un administrateur ne peut agir que sur les ressources de son établissement ; le `superadmin` peut tout voir/tout faire.
|
||||
|
||||
### Endpoint `/api/resolve`
|
||||
|
||||
- Protégé par la même clé `INTERNAL_API_KEY`.
|
||||
- Requiert l’en-tête `Authorization: Bearer <INTERNAL_API_KEY>`.
|
||||
- Le resolver (`resolver:2020`) ne l’utilise pas ; il interroge directement PostgreSQL. Cette route est donc réservée aux outils/scripts internes authentifiés.
|
||||
|
||||
### Exemples de commandes avec la clé interne
|
||||
|
||||
```bash
|
||||
KEY=$(grep INTERNAL_API_KEY /opt/studioe5-client-a/.env | cut -d= -f2)
|
||||
|
||||
curl -sS -X POST https://studioe5.edudeploy.com/api/internal/send-to-node \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $KEY" \
|
||||
-d '{"nodeId":"vps-8fc665eb","message":{"action":"start_vpn"}}'
|
||||
|
||||
curl -sS -H "Authorization: Bearer $KEY" \
|
||||
"https://studioe5.edudeploy.com/api/resolve?subdomain=test-wp-001"
|
||||
```
|
||||
|
||||
## 🔒 Clés pré-auth Headscale éphémères
|
||||
|
||||
### Principe
|
||||
|
||||
À l’activation zero-config, le serveur génère désormais une **clé pré-auth unique et à usage unique** pour chaque agent, au lieu d’envoyer la clé réutilisable `HEADSCALE_AUTH_KEY`.
|
||||
|
||||
Avantages :
|
||||
- une clé compromise ne permet pas d’enregistrer d’autres nœuds ;
|
||||
- traçabilité directe entre une activation et une clé Headscale ;
|
||||
- expiration courte (15 min) ;
|
||||
- la clé n’est **pas persistée** dans `studioE5-config.json` côté agent.
|
||||
|
||||
### Implémentation
|
||||
|
||||
| Composant | Changement |
|
||||
|-----------|------------|
|
||||
| `server/lib/headscale.ts` | Nouveau helper : `getHeadscaleUserId()` + `createEphemeralPreAuthKey()` appelant `POST /api/v1/preauthkey`. |
|
||||
| `server/lib/websocket.ts` | Sur `activate`, génère une clé éphémère taguée `tag:student-agent` pour l’utilisateur `studioe5`. Fallback sur `HEADSCALE_AUTH_KEY` si `HEADSCALE_API_KEY` n’est pas configurée. |
|
||||
| `agent/websocket.go` | La clé reçue est utilisée immédiatement mais **n’est plus écrite** dans `studioE5-config.json`. |
|
||||
| `agent/tailscale.go` | `tailscale up` fonctionne sans `--authkey` quand le state Tailscale existe déjà (reconnexion). |
|
||||
| `.env.example` / `docker-compose.yml` | Ajout de `HEADSCALE_API_KEY` pour le service `server`. |
|
||||
|
||||
### Configuration requise
|
||||
|
||||
Générer une clé API Headscale (depuis le conteneur ou la CLI) :
|
||||
|
||||
```bash
|
||||
cd /opt/studioe5-client-a
|
||||
# Clé valable 10 ans (87600h) pour éviter un renouvellement fréquent.
|
||||
docker compose exec headscale headscale apikeys create -e 87600h
|
||||
```
|
||||
|
||||
Puis l’ajouter dans `.env` :
|
||||
|
||||
```bash
|
||||
HEADSCALE_API_KEY=hskey-api-...
|
||||
```
|
||||
|
||||
> ⚠️ La clé API Headscale a une expiration par défaut de 90 jours. La clé de production a été créée avec une expiration de **10 ans** (`-e 87600h`). Les anciennes clés ont été révoquées.
|
||||
|
||||
### Rotation / renouvellement
|
||||
|
||||
Si la clé doit être changée :
|
||||
|
||||
1. Créer une nouvelle clé API :
|
||||
```bash
|
||||
docker compose exec headscale headscale apikeys create -e 87600h
|
||||
```
|
||||
2. Mettre à jour `.env` :
|
||||
```bash
|
||||
HEADSCALE_API_KEY=<nouvelle_clé>
|
||||
```
|
||||
3. Redémarrer le serveur :
|
||||
```bash
|
||||
docker compose up -d server
|
||||
```
|
||||
4. Révoquer l’ancienne clé :
|
||||
```bash
|
||||
docker compose exec headscale headscale apikeys expire --id <id_ancienne>
|
||||
```
|
||||
|
||||
### Déploiement effectué
|
||||
|
||||
- Clé API créée et ajoutée au `.env` de production.
|
||||
- Image serveur rebuildée et redémarrée.
|
||||
- Agents Linux/Windows rebuildés en v0.3.4 et copiés dans `server/public/`.
|
||||
|
||||
## 🔒 Sécurité — points restants à traiter
|
||||
|
||||
> Le certificat wildcard `*.studioe5.edudeploy.com` est désormais du ressort du **deployeur** (voir `docs/ONBOARDING_CLIENT.md`). Les points ci-dessous concernent l’application studioE5 proprement dite.
|
||||
|
||||
### Gestion et rotation des secrets
|
||||
|
||||
| Secret | Où ? | Action |
|
||||
|--------|------|--------|
|
||||
| `INTERNAL_API_KEY` | `.env` serveur | Prévoir une procédure de rotation régulière. |
|
||||
| `HEADSCALE_API_KEY` | `.env` serveur | Rotation tous les 10 ans max, stockage sécurisé. |
|
||||
| `NEXTAUTH_SECRET` | `.env` serveur | Génération robuste, rotation si suspicion de fuite. |
|
||||
| `DATABASE_URL` | `.env` serveur | Utilisateur DB dédié, mot de passe fort. |
|
||||
| `node.token` | `<data-dir>/node.token` | Vérifier permissions `0600` sur tous les OS. |
|
||||
|
||||
### Durcissement des conteneurs
|
||||
|
||||
- Limiter les `cap_add` au strict minimum.
|
||||
- Faire tourner les services avec un utilisateur non-root quand possible.
|
||||
- Mettre à jour régulièrement les images de base (Caddy, Node, Postgres, Headscale).
|
||||
- Scanner les images Docker pour les CVE.
|
||||
|
||||
### Mises à jour de sécurité
|
||||
|
||||
- Mise à jour des binaires Tailscale (Windows et Linux).
|
||||
- Mise à jour des images Docker (`server`, `resolver`, `caddy`, `postgres`, `headscale`).
|
||||
- Mise à jour de l’OS des VPS et des postes agents.
|
||||
- Mécanisme de mise à jour automatique ou notification de l’agent.
|
||||
|
||||
### Logs d’audit
|
||||
|
||||
- Tracer la création / suppression d’instances.
|
||||
- Tracer la génération et l’usage des codes d’activation.
|
||||
- Tracer les actions admin (connexion, création d’élève, démarrage/arrêt VPN).
|
||||
- Conservation et consultation des logs d’audit.
|
||||
|
||||
### Backups et reprise d’activité
|
||||
|
||||
- Backup régulier de la base PostgreSQL.
|
||||
- Backup du state Headscale.
|
||||
- Backup des states Tailscale côté agents.
|
||||
- Procédure de restauration documentée et testée.
|
||||
|
||||
### Sécurité du build et distribution de l’agent
|
||||
|
||||
- Vérifier l’intégrité des binaires Tailscale téléchargés (checksum / signature).
|
||||
- Signer l’exécutable Windows `studioE5-agent.exe` pour éviter les alertes Defender.
|
||||
- Fournir un hash SHA256 des archives d’agent.
|
||||
|
||||
### RGPD et données personnelles
|
||||
|
||||
- Justifier la conservation des noms/prénoms des élèves.
|
||||
- Gérer les droits d’accès, la suppression de compte et l’export de données.
|
||||
- Définir la durée de conservation des logs et historiques.
|
||||
|
||||
### Sécurité réseau complémentaire
|
||||
|
||||
- Restreindre l’accès à `/api/internal/send-to-node` par IP source si possible.
|
||||
- Vérifier l’exposition publique du dashboard Headscale et la durcir si nécessaire.
|
||||
- Évaluer si `headscale.studioe5.edudeploy.com` doit rester public.
|
||||
|
||||
### Rate limiting et quotas
|
||||
|
||||
- Rate-limiting global sur les routes publiques (`/api/auth/*`, activation, création d’instance).
|
||||
- Limitation du nombre d’instances par élève et par établissement.
|
||||
- Protection contre les abus sur la génération de codes d’activation.
|
||||
|
||||
### Tests de sécurité
|
||||
|
||||
- Tests d’intrusion légers (agent → agent, accès aux endpoints internes sans clé, accès à une instance d’un autre élève).
|
||||
- Tests automatisés du flux complet avant chaque release.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Prochaines étapes à faire
|
||||
|
||||
- [x] ~~Attendre la fin du rate limit Let’s Encrypt~~ (levé le 2026-06-23).
|
||||
- [x] ~~Relancer un test HTTPS sur `https://test-wp-001.studioe5.edudeploy.com/`~~ → **OK** (HTTP/2 200).
|
||||
- [x] ~~Créer une branche dédiée et commiter les modifications VPN on-demand~~ → branche `feat/studioe5-vpn-ondemand`, commit `124543d`. Push vers Gitea à faire dès que le remote sera accessible (actuellement `localhost:3001` et `gitea.alfrednobel.edudeploy.com` injoignables).
|
||||
- [x] ~~Tester le flux complet depuis l’interface web~~ → **OK** via l’API authentifiée (`POST /api/instances`), instance `cmqqgrur20001lw67t2bdgzkg` accessible en HTTPS public.
|
||||
- [ ] **Obtenir un certificat wildcard** pour `*.studioe5.edudeploy.com` (voir étude ci-dessous).
|
||||
- [ ] **Nettoyer les instances/agent de test** une fois le wildcard en place et le push effectué.
|
||||
- [x] ~~Packager les binaires Tailscale pour Windows~~ → **OK**, `download-tailscale-bins.sh` + `studioE5-agent-v0.3.0-windows.zip` prêt.
|
||||
- [ ] **Nettoyer les anciens nodes/volumes Headscale** créés pendant les tests.
|
||||
- [ ] **Documenter la procédure de mise en production** pour le client A (config agent, clés Headscale, ports, etc.).
|
||||
### ✅ Terminé
|
||||
|
||||
## 💡 Améliorations UI envisagées
|
||||
- [x] Rate limit Let’s Encrypt levé.
|
||||
- [x] Flux HTTPS public validé (`test-wp-001.studioe5.edudeploy.com`).
|
||||
- [x] Branche `feat/studioe5-vpn-ondemand` créée, commit `124543d`.
|
||||
- [x] Flux complet UI → API → WebSocket → agent → Docker → VPN → Caddy validé.
|
||||
- [x] Packager les binaires Tailscale pour Windows (`studioE5-agent-v0.3.5-windows.zip`).
|
||||
- [x] **Sécurité – authentification du canal serveur → agent** (token par nœud, clé API interne, sessions NextAuth sur les routes API).
|
||||
- [x] **Sécurité – durcissement du code d’activation** (`crypto.randomBytes`, expiration 60 min, rate-limiting, invalidation après usage).
|
||||
- [x] **Sécurité – ACL Headscale** (isolation agent ↔ agent, resolver → agent autorisé).
|
||||
- [x] **Sécurité – clés pré-auth Headscale éphémères** (génération côté serveur via `HEADSCALE_API_KEY`, non persistées côté agent).
|
||||
- [x] **Agent v0.3.5 – forwarding entrant Windows** (`tailscale serve` automatique au démarrage de chaque instance).
|
||||
- [x] **Agent v0.3.5 – UI locale moderne** (dashboard, logs, progression, actions d’instance).
|
||||
- [x] **Agent v0.3.5 – cycle de vie des instances** (`stop`/`start` préservent les volumes, `reset`/`delete` effacent).
|
||||
- [x] **Agent v0.3.5 – cleanup au shutdown** (arrêt propre de Tailscale et des instances, notification serveur).
|
||||
- [x] **Synchronisation dashboard** (messages `instance_stopped` / `instance_deleted` traités côté serveur).
|
||||
|
||||
### Console / log intégré dans l’agent
|
||||
### ⏳ Reste à faire
|
||||
|
||||
Plutôt que de laisser Windows ouvrir une fenêtre noire à chaque commande `podman`/`docker`/`tailscale`, rediriger le `Stdout`/`Stderr` de chaque commande vers l’UI locale de l’agent (`http://localhost:7070`).
|
||||
- [ ] **Certificat wildcard** : transféré au deployeur (`docs/ONBOARDING_CLIENT.md`). L’étude technique reste disponible ci-dessous pour référence.
|
||||
- [ ] **Nettoyer les instances/agent de test** une fois le push effectué.
|
||||
- [ ] **Nettoyer les anciens nodes/volumes Headscale** de test (nœuds `edubox`, `prof`, `invalid-*` hors ligne à supprimer).
|
||||
- [ ] **Pousser la branche** vers Gitea dès que le remote sera accessible.
|
||||
- [ ] **Documenter la procédure de mise en production** pour le client A (config agent, clés Headscale, ports, ACL, etc.).
|
||||
- [ ] **Étude – interface de déploiement multi-clients** : outil de provisionning d’un nouveau serveur client + agent générique (option A : URL serveur déterminée à l’activation).
|
||||
- [ ] **Sécurité – gestion et rotation des secrets** (`INTERNAL_API_KEY`, `HEADSCALE_API_KEY`, `NEXTAUTH_SECRET`, `DATABASE_URL`).
|
||||
- [ ] **Sécurité – durcissement des conteneurs** (`cap_add`, utilisateurs non-root, scans CVE).
|
||||
- [ ] **Sécurité – mises à jour de sécurité** (Tailscale, images Docker, OS agents).
|
||||
- [ ] **Sécurité – logs d’audit** (instances, codes d’activation, actions admin).
|
||||
- [ ] **Sécurité – backups et reprise d’activité** (DB, state Headscale, states agents).
|
||||
- [ ] **Sécurité – intégrité et signature de l’agent** (checksum Tailscale, signature Windows, hash SHA256).
|
||||
- [ ] **Sécurité – conformité RGPD** (données élèves, suppression de compte, export).
|
||||
- [ ] **Sécurité – restriction réseau** (endpoint interne, dashboard Headscale).
|
||||
- [ ] **Sécurité – rate limiting et quotas** (routes publiques, instances par élève/établissement).
|
||||
- [ ] **Sécurité – tests de sécurité** (intrusion légère, tests automatisés avant release).
|
||||
|
||||
Bénéfices :
|
||||
- Expérience utilisateur plus propre et commercialisable.
|
||||
- Diagnostic facilité : l’utilisateur voit exactement ce qui se passe (téléchargement d’image, démarrage, installation PrestaShop, etc.).
|
||||
## 💡 Améliorations UI
|
||||
|
||||
Implémentation :
|
||||
1. Remplacer `cmd.Stdout = os.Stdout` par un `io.Pipe()` ou `bytes.Buffer` dans `docker.go`, `tailscale.go`, etc.
|
||||
2. Envoyer les lignes de log au frontend via le WebSocket existant (`agent/ui/websocket`).
|
||||
3. Afficher les logs dans un panneau dédié du HTML.
|
||||
### ✅ Console / log intégrée dans l’agent (v0.3.5)
|
||||
|
||||
### Barre de progression
|
||||
Les logs de l’agent sont redirigés vers `<data-dir>/agent.log` et diffusés en temps réel dans l’UI locale (`http://localhost:7070`) via le WebSocket existant.
|
||||
|
||||
Associer des étapes connues à une barre de progression dans l’UI :
|
||||
### ✅ Barre de progression (v0.3.5)
|
||||
|
||||
L’agent envoie des messages `progress` au frontend pendant le démarrage d’une instance :
|
||||
|
||||
| Étape | Poids |
|
||||
|-------|-------|
|
||||
| Connexion au serveur | 10 % |
|
||||
| Démarrage du VPN | 25 % |
|
||||
| Téléchargement de l’image Docker | 50 % |
|
||||
| Création de la base de données | 70 % |
|
||||
| Installation de PrestaShop/WordPress | 90 % |
|
||||
| Instance prête | 100 % |
|
||||
| Préparation de l’application | 10 % |
|
||||
| Configuration de l’application | 30 % |
|
||||
| Application en cours de démarrage | 60 % |
|
||||
| Connexion sécurisée active | 80 % |
|
||||
| Finalisation de l’installation | 90 % |
|
||||
| Application prête | 100 % |
|
||||
|
||||
L’agent envoie des messages `progress` au frontend à chaque étape franchie.
|
||||
### Boutons d’action par instance (v0.3.5)
|
||||
|
||||
L’UI locale affiche désormais des boutons **Démarrer**, **Arrêter**, **Redémarrer** et **Supprimer** pour chaque instance.
|
||||
|
||||
## 🚀 Scalabilité commerciale — déploiement multi-clients
|
||||
|
||||
### Objectif
|
||||
|
||||
Permettre de déployer facilement une stack studioE5 complète pour un nouvel établissement/client sur un VPS dédié, sans intervention technique lourde.
|
||||
|
||||
### Architecture cible
|
||||
|
||||
- **Un serveur = un client** : chaque établissement a sa propre stack Docker Compose, sa base PostgreSQL, son Headscale et son Caddy.
|
||||
- **Agent générique (option A)** : un seul binaire agent pour tous les clients. L’URL du serveur cible est déterminée au moment de l’activation, pas hardcodée dans l’agent.
|
||||
- Pistes : code d’activation résolu par un hub central, code structuré contenant l’identifiant du serveur, ou champ URL serveur saisi dans l’UI locale.
|
||||
- **Interface de déploiement** : dashboard superadmin (hub) permettant de créer un client, provisionner le VPS, générer les secrets et retourner les informations de connexion.
|
||||
|
||||
### Prérequis techniques à préparer
|
||||
|
||||
Avant de pouvoir déployer un nouveau client en quelques clics, il faut encore préparer les éléments suivants :
|
||||
|
||||
| # | Élément | État | Détail |
|
||||
|---|---------|------|--------|
|
||||
| 1 | **Agent générique** | ⏳ À faire | `defaultServerURL` est hardcodé (`wss://studioe5.edudeploy.com/api/websocket`). L’agent doit pouvoir déterminer l’URL serveur cible à l’activation (option A : champ URL, hub de résolution, ou code structuré). |
|
||||
| 2 | **Script de provisionning** | ⏳ À faire | Aucun outil automatisé pour provisionner un VPS vierge : installation Docker, déploiement de la stack, génération des secrets, création des clés Headscale, configuration DNS wildcard. |
|
||||
| 3 | **Registry d’images** | ⏳ À faire | Les images `server` et `resolver` sont buildées sur le serveur cible. Il faut un registry privé pour builder une fois et déployer partout. |
|
||||
| 4 | **Hub central** | ⏳ À faire | Dashboard superadmin listant les clients, état des serveurs, versions déployées, logs distants et mises à jour. |
|
||||
| 5 | **Mises à jour à distance** | ⏳ À faire | Mécanisme pour pousser une nouvelle version du serveur et de l’agent sur tous les déploiements clients. |
|
||||
| 6 | **Monitoring / support** | ⏳ À faire | Collecte centralisée de logs, alertes (serveur down, certificat expiré, agent hors ligne). |
|
||||
| 7 | **Branding / personnalisation** | ⏳ À faire | Logo, nom de l’établissement, couleurs configurables par client. |
|
||||
| 8 | **Tests automatisés** | ⏳ À faire | Tests du flux activation → VPN → instance → HTTPS public pour valider chaque nouveau déploiement. |
|
||||
| 9 | **Documentation procédure prod** | ⏳ À faire | Procédure complète de mise en production pour un nouveau client. |
|
||||
|
||||
### Statut
|
||||
|
||||
- ⏳ À étudier et planifier plus tard. L’architecture actuelle (un serveur par client + agent zero-config) est déjà compatible avec cette vision, mais le code n’est pas encore industrialisé pour un déploiement à grande échelle.
|
||||
|
||||
## 🔒 Étude certificat wildcard `*.studioe5.edudeploy.com`
|
||||
|
||||
|
||||
+1
-1
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)) != ""
|
||||
}
|
||||
|
||||
+825
-117
File diff suppressed because it is too large
Load Diff
+115
-71
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
# Deployeur studioE5 — Onboarding d’un nouvel établissement
|
||||
|
||||
## Objectif
|
||||
|
||||
Ce document décrit le fonctionnement du **deployeur studioE5**, c’est-à-dire l’application / l’outil qui provisionne et configure un nouvel environnement client (un établissement) prêt à accueillir l’application studioE5.
|
||||
|
||||
L’application studioE5 elle-même (agent, VPN on-demand, resolver, Caddy, Headscale, etc.) est documentée dans `SUIVI_VPN_ONDEMAND.md`. Le deployeur est l’outil qui **déploie** cette application sur un VPS dédié au client.
|
||||
|
||||
---
|
||||
|
||||
## Public cible
|
||||
|
||||
- Équipe produit / développement du deployeur
|
||||
- Équipe ops / déploiement
|
||||
- Référents techniques du client A
|
||||
|
||||
---
|
||||
|
||||
## Glossaire
|
||||
|
||||
| Terme | Définition |
|
||||
|-------|------------|
|
||||
| **Deployeur** | Application qui provisionne un VPS, déploie la stack studioE5 et configure le DNS/certificats pour un nouvel établissement. |
|
||||
| **Hub central** | Dashboard superadmin studioE5 qui orchestre les déploiements et la gestion multi-clients. |
|
||||
| **Établissement** | Entité client (école, lycée, université, entreprise). |
|
||||
| **Tag établissement** | Slug unique et court identifiant l’établissement dans les URLs et le DNS. |
|
||||
| **Domaine géré** | Sous-domaine fourni par studioE5 : `*.tag.edudeploy.com`. |
|
||||
| **Domaine propre** | Domaine détenu par l’établissement : `*.tag.monetablissement.fr`. |
|
||||
| **Application studioE5** | Stack complète déployée sur le VPS : `server`, `resolver`, `resolver-vpn`, `caddy`, `headscale`, `postgres`, etc. |
|
||||
| **Agent générique** | Binaire agent unique, capable de se connecter à n’importe quel serveur studioE5 via résolution d’URL à l’activation. |
|
||||
|
||||
---
|
||||
|
||||
## Architecture : deployeur vs application studioE5
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Hub central studioE5 │
|
||||
│ (superadmin, gestion des établissements, monitoring) │
|
||||
└───────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Deployeur studioE5 │
|
||||
│ (provisionning VPS, DNS, certificats, déploiement stack) │
|
||||
└───────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Application studioE5 (un par client) │
|
||||
│ Caddy — Resolver — Resolver-VPN — Headscale — Server — DB │
|
||||
│ ▲ │
|
||||
│ │ WebSocket / VPN on-demand │
|
||||
│ ▼ │
|
||||
│ Agent élève (Windows/Linux) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flux d’onboarding par le deployeur (vue d’ensemble)
|
||||
|
||||
```
|
||||
Création de l’établissement dans le hub
|
||||
↓
|
||||
Choix du domaine (géré ou propre)
|
||||
↓
|
||||
Génération du tag établissement
|
||||
↓
|
||||
Provisionning du VPS
|
||||
↓
|
||||
Configuration DNS wildcard
|
||||
↓
|
||||
Génération du certificat wildcard
|
||||
↓
|
||||
Déploiement de la stack studioE5 (Docker Compose)
|
||||
↓
|
||||
Initialisation de Headscale et création des clés
|
||||
↓
|
||||
Création du compte administrateur de l’établissement
|
||||
↓
|
||||
Génération des codes d’activation
|
||||
↓
|
||||
Build et mise à disposition de l’agent dédié
|
||||
↓
|
||||
Activation de l’agent par un élève
|
||||
↓
|
||||
Création d’une première instance (validation du déploiement)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Création de l’établissement dans le hub
|
||||
|
||||
Le superadmin crée un nouvel établissement dans le hub central.
|
||||
|
||||
Données minimales :
|
||||
|
||||
- Nom officiel
|
||||
- Type d’établissement (école, lycée, université, entreprise)
|
||||
- Pays / fuseau horaire
|
||||
- Contact administrateur
|
||||
- Choix du mode de domaine (`managed` ou `custom`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Choix du domaine
|
||||
|
||||
### Option A — Domaine géré par studioE5 (MVP)
|
||||
|
||||
Le deployeur crée automatiquement un sous-domaine du domaine maître :
|
||||
|
||||
```
|
||||
*.tag.edudeploy.com
|
||||
```
|
||||
|
||||
Le hub gère le DNS chez le registrar studioE5 (actuellement Infomaniak).
|
||||
|
||||
### Option B — Domaine propre de l’établissement (évolution)
|
||||
|
||||
L’établissement fournit son propre domaine :
|
||||
|
||||
```
|
||||
*.tag.monetablissement.fr
|
||||
```
|
||||
|
||||
Prérequis :
|
||||
|
||||
- Le client pointe son DNS wildcard vers l’IP du VPS provisionné.
|
||||
- Le deployeur dispose d’un token API du registrar du client pour le challenge DNS-01.
|
||||
|
||||
---
|
||||
|
||||
## 3. Génération du tag établissement
|
||||
|
||||
Le tag est un slug court, unique au niveau du hub, utilisé dans les URLs et le DNS.
|
||||
|
||||
### Règles
|
||||
|
||||
- Uniquement `[a-z0-9-]`
|
||||
- Pas de tiret au début ni à la fin
|
||||
- Longueur conseillée : 2 à 20 caractères
|
||||
- Vérification d’unicité en base
|
||||
|
||||
### Exemples
|
||||
|
||||
| Nom d’établissement | Tag |
|
||||
|---------------------|-----|
|
||||
| Lycée Jules Ferry | `ljf` |
|
||||
| Institut Supérieur du Digital | `isd` |
|
||||
| École Notre-Dame | `end` |
|
||||
|
||||
### Gestion des collisions
|
||||
|
||||
- `ljf` → `ljf-2`, `ljf-3`, etc.
|
||||
|
||||
---
|
||||
|
||||
## 4. Provisionning du VPS
|
||||
|
||||
Le deployeur provisionne un VPS dédié pour l’établissement.
|
||||
|
||||
### Prérequis sur le VPS vierge
|
||||
|
||||
- OS Linux (Ubuntu LTS recommandé)
|
||||
- Docker + Docker Compose installés
|
||||
- Accès SSH avec clé
|
||||
- Ports ouverts : 22, 80, 443
|
||||
|
||||
### Actions automatisées par le deployeur
|
||||
|
||||
1. Installation de Docker et Docker Compose si absent.
|
||||
2. Création de la structure de dossiers (`/opt/studioe5-<tag>/`).
|
||||
3. Génération des secrets (`.env`) :
|
||||
- `INTERNAL_API_KEY`
|
||||
- `HEADSCALE_API_KEY`
|
||||
- `HEADSCALE_AUTH_KEY` (réutilisable, taguée `tag:student-agent`)
|
||||
- `HEADSCALE_RESOLVER_AUTH_KEY`
|
||||
- `INFOMANIAK_API_TOKEN` (si domaine géré)
|
||||
- `NEXTAUTH_SECRET`, `DATABASE_URL`, etc.
|
||||
4. Récupération des images Docker depuis le registry privé (ou build sur place en attendant).
|
||||
5. Génération des fichiers de configuration (`Caddyfile`, `docker-compose.yml`, `headscale/config.yaml`, `headscale/acl_policy.hujson`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Configuration DNS wildcard
|
||||
|
||||
### Domaine géré
|
||||
|
||||
Le deployeur appelle l’API du registrar pour créer :
|
||||
|
||||
```dns
|
||||
*.tag.edudeploy.com A <IP_DU_VPS>
|
||||
```
|
||||
|
||||
### Domaine propre
|
||||
|
||||
Le deployeur vérifie que l’enregistrement existe :
|
||||
|
||||
```dns
|
||||
*.tag.monetablissement.fr A <IP_DU_VPS>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Certificat wildcard
|
||||
|
||||
### Principe
|
||||
|
||||
Un seul certificat wildcard couvre toutes les instances futures de l’établissement.
|
||||
|
||||
### Mise en œuvre avec Caddy
|
||||
|
||||
Le deployeur génère le `Caddyfile` :
|
||||
|
||||
```caddy
|
||||
*.tag.edudeploy.com {
|
||||
tls {
|
||||
dns infomaniak {env.INFOMANIAK_API_TOKEN}
|
||||
}
|
||||
|
||||
reverse_proxy resolver:2020 {
|
||||
header_up Host {host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pour un domaine propre, le provider DNS est celui du client.
|
||||
|
||||
### Renouvellement
|
||||
|
||||
Géré automatiquement par Caddy.
|
||||
|
||||
---
|
||||
|
||||
## 7. Déploiement de la stack studioE5
|
||||
|
||||
Le deployeur lance la stack Docker Compose complète :
|
||||
|
||||
```bash
|
||||
cd /opt/studioe5-<tag>
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Services déployés :
|
||||
|
||||
- `server` : API + WebSocket + UI Next.js
|
||||
- `resolver` : reverse proxy interne vers les instances
|
||||
- `resolver-vpn` : sidecar Tailscale dans le netns du resolver
|
||||
- `caddy` : reverse proxy public + TLS
|
||||
- `headscale` : contrôleur Tailscale
|
||||
- `postgres` : base de données
|
||||
|
||||
---
|
||||
|
||||
## 8. Initialisation de Headscale
|
||||
|
||||
Le deployeur initialise Headscale et crée les clés nécessaires :
|
||||
|
||||
```bash
|
||||
# Création de l’utilisateur dédié au resolver
|
||||
docker compose exec headscale headscale users create resolver
|
||||
|
||||
# Création de la clé pré-auth réutilisable pour les agents
|
||||
docker compose exec headscale headscale preauthkeys create \
|
||||
--user studioe5 \
|
||||
--reusable \
|
||||
--tags tag:student-agent \
|
||||
-e 87600h
|
||||
|
||||
# Création de la clé pré-auth pour le resolver
|
||||
docker compose exec headscale headscale preauthkeys create \
|
||||
--user resolver \
|
||||
--tags tag:resolver \
|
||||
-e 87600h
|
||||
|
||||
# Création d’une clé API Headscale valable 10 ans
|
||||
docker compose exec headscale headscale apikeys create -e 87600h
|
||||
```
|
||||
|
||||
Ces secrets sont stockés dans le `.env` du serveur.
|
||||
|
||||
---
|
||||
|
||||
## 9. Création du compte administrateur de l’établissement
|
||||
|
||||
Une fois la stack déployée, le deployeur (ou le hub) crée le premier compte administrateur de l’établissement via l’API du serveur nouvellement déployé.
|
||||
|
||||
Rôles :
|
||||
|
||||
- `admin` : gestion des élèves, instances, agents.
|
||||
- `teacher` : gestion limitée à certaines classes/groupes.
|
||||
- `superadmin` (studioE5) : accès transverse.
|
||||
|
||||
L’administrateur reçoit un lien d’activation sécurisé.
|
||||
|
||||
---
|
||||
|
||||
## 10. Génération des codes d’activation
|
||||
|
||||
Le deployeur configure le serveur pour permettre la génération de codes d’activation.
|
||||
|
||||
### Règles de sécurité (implémentées côté application studioE5)
|
||||
|
||||
- Génération avec `crypto.randomBytes`
|
||||
- Alphabet sans ambiguïté : `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`
|
||||
- 6 caractères
|
||||
- Expiration après 60 minutes
|
||||
- Invalidation après usage
|
||||
- Rate-limiting : 5 tentatives par code / 5 tentatives par `nodeId` sur 15 minutes
|
||||
|
||||
### Flux
|
||||
|
||||
1. L’administrateur génère un code pour un élève.
|
||||
2. L’élève saisit le code dans l’agent.
|
||||
3. Le serveur valide et renvoie :
|
||||
- l’identité de l’élève
|
||||
- l’URL Headscale
|
||||
- une clé pré-auth Headscale éphémère
|
||||
4. L’agent démarre automatiquement le VPN.
|
||||
|
||||
---
|
||||
|
||||
## 11. Build et mise à disposition de l’agent
|
||||
|
||||
### Principe
|
||||
|
||||
L’agent est un binaire générique, mais il doit être capable de se connecter au bon serveur. Le deployeur génère un agent pré-configuré ou un installeur qui embarque l’URL du serveur de l’établissement.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cd /opt/studioe5-<tag>/agent
|
||||
./download-tailscale-bins.sh 1.98.4
|
||||
./build.sh
|
||||
```
|
||||
|
||||
Artifacts générés :
|
||||
|
||||
- `studioE5-agent-vX.Y.Z-windows.zip`
|
||||
- `studioE5-agent-vX.Y.Z.exe`
|
||||
- `studioE5-agent-vX.Y.Z` (Linux)
|
||||
|
||||
### Mise à disposition
|
||||
|
||||
Les fichiers sont servis par Caddy depuis `server/public/agent/` :
|
||||
|
||||
```
|
||||
https://tag.edudeploy.com/studioE5-agent-vX.Y.Z-windows.zip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Activation de l’agent
|
||||
|
||||
### Activation zéro-config
|
||||
|
||||
1. L’élève télécharge l’agent depuis l’URL de l’établissement.
|
||||
2. Il extrait l’archive et lance `studioE5-agent.exe`.
|
||||
3. Il ouvre `http://localhost:7070`.
|
||||
4. Il saisit le code d’activation à 6 caractères.
|
||||
5. L’agent contacte le serveur, récupère la configuration et démarre le VPN.
|
||||
|
||||
> Les détails techniques du VPN on-demand (named pipes Windows, logs, ACL, tokens, clés éphémères) sont documentés dans `SUIVI_VPN_ONDEMAND.md`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Création d’une instance et construction de l’URL (validation)
|
||||
|
||||
Le deployeur ou l’administrateur crée une première instance pour valider le déploiement.
|
||||
|
||||
### Format d’URL
|
||||
|
||||
```
|
||||
<appli>-<initiales><id-court>.<tag>.<domaine>
|
||||
```
|
||||
|
||||
Exemple :
|
||||
|
||||
```
|
||||
wp-jd47.ljf.edudeploy.com
|
||||
```
|
||||
|
||||
Avec :
|
||||
|
||||
- `wp` : type d’application
|
||||
- `jd` : initiales de l’élève
|
||||
- `47` : identifiant court unique
|
||||
- `ljf` : tag de l’établissement
|
||||
- `edudeploy.com` : domaine de base
|
||||
|
||||
### Mapping type d’application → préfixe
|
||||
|
||||
| Application | Préfixe |
|
||||
|-------------|---------|
|
||||
| WordPress | `wp` |
|
||||
| PrestaShop | `ps` |
|
||||
| Moodle | `mdl` |
|
||||
| Nextcloud | `nc` |
|
||||
|
||||
### Protection de l’identité
|
||||
|
||||
- L’URL ne contient pas le nom complet de l’élève.
|
||||
- Seules les initiales + un identifiant court opaque sont exposées.
|
||||
|
||||
---
|
||||
|
||||
## 14. Modèles de données du deployeur
|
||||
|
||||
### Table / modèle `Organization` (établissement dans le hub)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Lycée Jules Ferry",
|
||||
"tag": "ljf",
|
||||
"domainMode": "managed",
|
||||
"baseDomain": "edudeploy.com",
|
||||
"adminEmail": "admin@ljf.fr",
|
||||
"status": "active",
|
||||
"createdAt": "2026-06-25T17:28:07Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Table / modèle `Deployment` (déploiement sur un VPS)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"organizationId": "uuid",
|
||||
"serverIp": "203.0.113.10",
|
||||
"serverHostname": "ljf.studioe5.edudeploy.com",
|
||||
"wildcardDnsConfigured": true,
|
||||
"wildcardCertificateReady": true,
|
||||
"dnsProvider": "infomaniak",
|
||||
"dnsProviderTokenRef": "env:INFOMANIAK_TOKEN_LJF",
|
||||
"headscaleApiKeyRef": "env:HEADSCALE_API_KEY_LJF",
|
||||
"status": "ready",
|
||||
"deployedAt": "2026-06-25T17:28:07Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Table / modèle `Student` (dans l’application studioE5 déployée)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"organizationId": "uuid",
|
||||
"firstName": "Jean",
|
||||
"lastName": "Dupont",
|
||||
"initials": "jd",
|
||||
"activationCode": "AB3D9F",
|
||||
"activationCodeExpiresAt": "2026-06-25T18:28:07Z",
|
||||
"nodeId": "vps-8fc665eb",
|
||||
"nodeToken": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### Table / modèle `Instance` (dans l’application studioE5 déployée)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "cmqqgrur20001lw67t2bdgzkg",
|
||||
"organizationId": "uuid",
|
||||
"studentId": "uuid",
|
||||
"nodeId": "vps-8fc665eb",
|
||||
"templateId": "wordpress-wordpress-latest",
|
||||
"applicationPrefix": "wp",
|
||||
"shortId": "47",
|
||||
"subdomain": "wp-jd47",
|
||||
"fqdn": "wp-jd47.ljf.edudeploy.com",
|
||||
"port": 8001,
|
||||
"status": "running"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. Sécurité et RGPD
|
||||
|
||||
### Protection de l’identité de l’élève
|
||||
|
||||
- L’URL publique ne contient pas le nom complet de l’élève.
|
||||
- Seules les initiales + un identifiant court opaque sont exposées.
|
||||
|
||||
### Isolation réseau
|
||||
|
||||
- Les agents élèves ne peuvent pas communiquer entre eux (ACL Headscale).
|
||||
- Le resolver est le seul service autorisé à joindre les agents sur leurs ports d’instance.
|
||||
|
||||
### Authentification
|
||||
|
||||
- Token unique par agent (`node.token`).
|
||||
- Clé API interne pour les endpoints serveur → agent.
|
||||
- Sessions NextAuth sur les routes API métier.
|
||||
|
||||
### Clés pré-auth Headscale
|
||||
|
||||
- Éphémères, à usage unique, 15 minutes d’expiration.
|
||||
- Non persistées côté agent.
|
||||
|
||||
---
|
||||
|
||||
## 16. Checklist de validation du deployeur
|
||||
|
||||
À l’issue d’un onboarding, les points suivants doivent être validés :
|
||||
|
||||
- [ ] L’établissement est créé dans le hub avec un tag unique.
|
||||
- [ ] Le VPS est provisionné et accessible en SSH.
|
||||
- [ ] Docker et Docker Compose sont installés.
|
||||
- [ ] Le DNS wildcard est résolu (`*.tag.edudeploy.com` → IP du VPS).
|
||||
- [ ] Le certificat wildcard est obtenu et valide.
|
||||
- [ ] La stack studioE5 est démarrée (`docker compose ps`).
|
||||
- [ ] Headscale est initialisé avec les utilisateurs et clés nécessaires.
|
||||
- [ ] Le compte administrateur de l’établissement est créé.
|
||||
- [ ] Un code d’activation peut être généré pour un élève.
|
||||
- [ ] L’agent est buildé et téléchargeable depuis le serveur de l’établissement.
|
||||
- [ ] L’agent s’active avec le code zéro-config.
|
||||
- [ ] Une instance peut être créée et son URL est accessible en HTTPS.
|
||||
- [ ] Deux instances différentes reçoivent des URL uniques.
|
||||
- [ ] Le flux HTTPS complet retourne bien HTTP 200.
|
||||
|
||||
---
|
||||
|
||||
## 17. Roadmap du deployeur
|
||||
|
||||
### Court terme (MVP)
|
||||
|
||||
- Déploiement manuel ou semi-automatisé d’un nouvel établissement sur un VPS.
|
||||
- Domaine géré par studioE5 uniquement.
|
||||
- Build des images sur le VPS cible.
|
||||
- Agent avec URL serveur hardcodée ou fournie à l’activation.
|
||||
|
||||
### Moyen terme
|
||||
|
||||
- **Agent générique** : déterminer l’URL serveur cible à l’activation (code structuré, hub de résolution, ou champ URL).
|
||||
- **Script de provisionning** : installation Docker, déploiement stack, génération secrets, DNS wildcard.
|
||||
- **Registry d’images privé** : builder une fois, déployer partout.
|
||||
- Support de domaines propres à l’établissement.
|
||||
- Support multi-registrar DNS.
|
||||
|
||||
### Long terme
|
||||
|
||||
- **Hub central multi-clients** : dashboard superadmin, gestion des versions, logs distants.
|
||||
- **Mises à jour à distance** : pousser une nouvelle version du serveur et de l’agent sur tous les déploiements.
|
||||
- **Monitoring / support** : alertes serveur down, certificat expiré, agent hors ligne.
|
||||
- **Tests automatisés** : validation du flux activation → VPN → instance → HTTPS public à chaque déploiement.
|
||||
- **Console/log intégré et barre de progression** dans l’agent.
|
||||
- Génération automatique de codes d’activation par import CSV.
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"tagOwners": {
|
||||
"tag:student-agent": ["studioe5@studioe5.local"],
|
||||
"tag:resolver": ["resolver@studioe5.local"]
|
||||
},
|
||||
"acls": [
|
||||
{
|
||||
"action": "accept",
|
||||
"src": ["tag:resolver"],
|
||||
"dst": ["tag:student-agent:*"]
|
||||
},
|
||||
{
|
||||
"action": "accept",
|
||||
"src": ["tag:student-agent"],
|
||||
"dst": ["tag:resolver:2020"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -38,6 +38,10 @@ database:
|
||||
sqlite:
|
||||
path: /etc/headscale/db.sqlite
|
||||
|
||||
policy:
|
||||
path: /etc/headscale/acl_policy.hujson
|
||||
mode: file
|
||||
|
||||
log:
|
||||
format: text
|
||||
level: info
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { requireAuth, requireRole } from "@/lib/api-auth";
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAuth();
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
const where = user.role === "superadmin" ? {} : { id: user.establishmentId };
|
||||
const establishments = await prisma.establishment.findMany({
|
||||
where,
|
||||
include: { subscription: true, _count: { select: { users: true, classes: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
@@ -11,6 +17,12 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await requireAuth();
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
const denied = requireRole(user, "superadmin");
|
||||
if (denied) return denied;
|
||||
|
||||
const body = await req.json();
|
||||
const { name, slug, adminEmail, adminPassword } = body;
|
||||
|
||||
|
||||
@@ -1,16 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendToNode } from "@/lib/websocket";
|
||||
import { authOptions } from "@/lib/auth-config";
|
||||
|
||||
async function requireAuth() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) return null;
|
||||
return session.user as { id: string; email: string; role: string; establishmentId?: string };
|
||||
}
|
||||
|
||||
function userCanAccessNode(user: { role: string; establishmentId?: string }, node: any) {
|
||||
if (user.role === "superadmin") return true;
|
||||
const establishmentId = node?.student?.class?.establishmentId;
|
||||
return establishmentId && establishmentId === user.establishmentId;
|
||||
}
|
||||
|
||||
function userCanAccessInstance(user: { role: string; establishmentId?: string }, instance: any) {
|
||||
if (user.role === "superadmin") return true;
|
||||
const establishmentId = instance?.node?.student?.class?.establishmentId;
|
||||
return establishmentId && establishmentId === user.establishmentId;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await requireAuth();
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const nodeId = searchParams.get("nodeId");
|
||||
const establishmentId = searchParams.get("establishmentId");
|
||||
const establishmentIdParam = searchParams.get("establishmentId");
|
||||
|
||||
let where: any = {};
|
||||
if (nodeId) where.nodeId = nodeId;
|
||||
if (establishmentId) {
|
||||
const classes = await prisma.class.findMany({ where: { establishmentId }, select: { id: true } });
|
||||
|
||||
if (user.role !== "superadmin") {
|
||||
const classes = await prisma.class.findMany({
|
||||
where: { establishmentId: user.establishmentId },
|
||||
select: { id: true },
|
||||
});
|
||||
const students = await prisma.student.findMany({
|
||||
where: { classId: { in: classes.map((c) => c.id) } },
|
||||
select: { id: true },
|
||||
});
|
||||
const nodes = await prisma.node.findMany({
|
||||
where: { studentId: { in: students.map((s) => s.id) } },
|
||||
select: { id: true },
|
||||
});
|
||||
where.nodeId = { in: nodes.map((n) => n.id) };
|
||||
} else if (establishmentIdParam) {
|
||||
const classes = await prisma.class.findMany({ where: { establishmentId: establishmentIdParam }, select: { id: true } });
|
||||
const students = await prisma.student.findMany({ where: { classId: { in: classes.map((c) => c.id) } }, select: { id: true } });
|
||||
const nodes = await prisma.node.findMany({ where: { studentId: { in: students.map((s) => s.id) } }, select: { id: true } });
|
||||
where.nodeId = { in: nodes.map((n) => n.id) };
|
||||
@@ -39,12 +77,8 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const enriched = instances.map((inst) => {
|
||||
const domain = inst.node.student?.class.establishment?.domain;
|
||||
const publicUrl = domain
|
||||
? `https://${inst.id}.${domain}`
|
||||
: null;
|
||||
const localUrl = inst.node.tailscaleIp
|
||||
? `http://${inst.node.tailscaleIp}:${inst.port}`
|
||||
: null;
|
||||
const publicUrl = domain ? `https://${inst.id}.${domain}` : null;
|
||||
const localUrl = inst.node.tailscaleIp ? `http://${inst.node.tailscaleIp}:${inst.port}` : null;
|
||||
return {
|
||||
...inst,
|
||||
publicUrl,
|
||||
@@ -56,22 +90,32 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await requireAuth();
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const { nodeId, templateId, port } = body;
|
||||
if (!nodeId || !templateId) {
|
||||
return NextResponse.json({ error: "Missing nodeId or templateId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const template = await prisma.template.findUnique({ where: { id: templateId } });
|
||||
if (!template) return NextResponse.json({ error: "Template not found" }, { status: 404 });
|
||||
|
||||
const instance = await prisma.instance.create({
|
||||
data: { nodeId, templateId, port: port || 8080, status: "stopped" },
|
||||
});
|
||||
|
||||
const node = await prisma.node.findUnique({
|
||||
where: { id: nodeId },
|
||||
include: { student: { include: { class: { include: { establishment: true } } } } },
|
||||
});
|
||||
if (!node) return NextResponse.json({ error: "Node not found" }, { status: 404 });
|
||||
if (!userCanAccessNode(user, node)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const domain = node?.student?.class.establishment?.domain;
|
||||
const instance = await prisma.instance.create({
|
||||
data: { nodeId, templateId, port: port || 8080, status: "stopped" },
|
||||
});
|
||||
|
||||
const domain = node.student?.class.establishment?.domain;
|
||||
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
||||
const publicUrl = domain ? `https://${publicDomain}` : null;
|
||||
const sent = sendToNode(nodeId, {
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth, getScopedEstablishmentId, forbidden } from "@/lib/api-auth";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await requireAuth();
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const establishmentId = searchParams.get("establishmentId");
|
||||
const requestedId = searchParams.get("establishmentId");
|
||||
const establishmentId = getScopedEstablishmentId(user, requestedId);
|
||||
if (establishmentId instanceof NextResponse) return establishmentId;
|
||||
|
||||
let where: any = {};
|
||||
if (establishmentId) {
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function getBearerToken(req: NextRequest): string | null {
|
||||
const auth = req.headers.get("authorization") || "";
|
||||
const match = auth.match(/^Bearer\s+(\S+)$/i);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const apiKey = process.env.INTERNAL_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "Internal API key not configured" }, { status: 500 });
|
||||
}
|
||||
const token = getBearerToken(req);
|
||||
if (!token || token !== apiKey) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const subdomain = searchParams.get("subdomain");
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function generateCode(length = 6) {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let code = "";
|
||||
for (let i = 0; i < length; i++) code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
return code;
|
||||
}
|
||||
import { generateUniqueActivationCode } from "@/lib/activation";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
@@ -31,13 +25,15 @@ export async function GET(req: NextRequest) {
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { classId, firstName, lastName, email } = body;
|
||||
const { code, expiresAt } = await generateUniqueActivationCode();
|
||||
const student = await prisma.student.create({
|
||||
data: {
|
||||
classId,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
activationCode: generateCode(),
|
||||
activationCode: code,
|
||||
activationCodeExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(student, { status: 201 });
|
||||
|
||||
@@ -1,25 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth, requireRole, forbidden } from "@/lib/api-auth";
|
||||
|
||||
function templateAccessWhere(user: { role: string; establishmentId?: string }, establishmentId?: string | null) {
|
||||
if (user.role === "superadmin" && establishmentId) {
|
||||
return { OR: [{ isPublic: true }, { establishmentId }] };
|
||||
}
|
||||
if (user.establishmentId) {
|
||||
return { OR: [{ isPublic: true }, { establishmentId: user.establishmentId }] };
|
||||
}
|
||||
return { isPublic: true };
|
||||
}
|
||||
|
||||
async function canManageTemplate(user: { role: string; establishmentId?: string }, id: string) {
|
||||
if (user.role === "superadmin") return true;
|
||||
const template = await prisma.template.findUnique({ where: { id } });
|
||||
if (!template) return false;
|
||||
return template.establishmentId === user.establishmentId;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await requireAuth();
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const establishmentId = searchParams.get("establishmentId");
|
||||
const requestedEst = searchParams.get("establishmentId");
|
||||
|
||||
const where = user.role === "superadmin" && !requestedEst ? {} : templateAccessWhere(user, requestedEst);
|
||||
|
||||
const templates = await prisma.template.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ isPublic: true },
|
||||
...(establishmentId ? [{ establishmentId }] : []),
|
||||
],
|
||||
},
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return NextResponse.json(templates);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await requireAuth();
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
const denied = requireRole(user, "superadmin", "admin");
|
||||
if (denied) return denied;
|
||||
|
||||
const body = await req.json();
|
||||
const { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy } = body;
|
||||
let { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy } = body;
|
||||
|
||||
if (user.role !== "superadmin") {
|
||||
if (establishmentId && establishmentId !== user.establishmentId) {
|
||||
return forbidden();
|
||||
}
|
||||
establishmentId = user.establishmentId;
|
||||
}
|
||||
|
||||
const template = await prisma.template.create({
|
||||
data: { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy },
|
||||
});
|
||||
@@ -27,16 +59,39 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const user = await requireAuth();
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
const denied = requireRole(user, "superadmin", "admin");
|
||||
if (denied) return denied;
|
||||
|
||||
const body = await req.json();
|
||||
const { id, ...data } = body;
|
||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||
|
||||
if (!(await canManageTemplate(user, id))) return forbidden();
|
||||
|
||||
if (user.role !== "superadmin" && data.establishmentId && data.establishmentId !== user.establishmentId) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const template = await prisma.template.update({ where: { id }, data });
|
||||
return NextResponse.json(template);
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const user = await requireAuth();
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
const denied = requireRole(user, "superadmin", "admin");
|
||||
if (denied) return denied;
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||
|
||||
if (!(await canManageTemplate(user, id))) return forbidden();
|
||||
|
||||
await prisma.template.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { requireAuth, requireRole, forbidden } from "@/lib/api-auth";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await requireAuth();
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const establishmentId = searchParams.get("establishmentId");
|
||||
const role = searchParams.get("role");
|
||||
|
||||
if (user.role !== "superadmin") {
|
||||
if (establishmentId && establishmentId !== user.establishmentId) {
|
||||
return forbidden();
|
||||
}
|
||||
}
|
||||
|
||||
const where: any = {};
|
||||
if (establishmentId) where.establishmentId = establishmentId;
|
||||
else if (user.role !== "superadmin") where.establishmentId = user.establishmentId;
|
||||
if (role) where.role = role;
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
@@ -19,23 +30,56 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await requireAuth();
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
const denied = requireRole(user, "superadmin", "admin");
|
||||
if (denied) return denied;
|
||||
|
||||
const body = await req.json();
|
||||
const { email, password, role, establishmentId } = body;
|
||||
const user = await prisma.user.create({
|
||||
|
||||
if (!email || !password || !role) {
|
||||
return NextResponse.json({ error: "Missing email, password or role" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (user.role === "admin") {
|
||||
if (role === "superadmin") return forbidden();
|
||||
if (establishmentId && establishmentId !== user.establishmentId) return forbidden();
|
||||
}
|
||||
|
||||
const finalEstablishmentId = user.role === "superadmin" ? establishmentId : user.establishmentId;
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: await hashPassword(password),
|
||||
role,
|
||||
establishmentId,
|
||||
establishmentId: finalEstablishmentId,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(user, { status: 201 });
|
||||
return NextResponse.json(newUser, { status: 201 });
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const user = await requireAuth();
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
const denied = requireRole(user, "superadmin", "admin");
|
||||
if (denied) return denied;
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
if (user.role === "admin") {
|
||||
if (target.role === "superadmin") return forbidden();
|
||||
if (target.establishmentId !== user.establishmentId) return forbidden();
|
||||
}
|
||||
|
||||
await prisma.user.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -3,17 +3,9 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth-config";
|
||||
import { generateUniqueActivationCode } from "@/lib/activation";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function generateCode(): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let code = "";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function deleteStudent(formData: FormData) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) redirect("/login");
|
||||
@@ -54,9 +46,10 @@ export async function generateActivationCodeAction(formData: FormData) {
|
||||
|
||||
if (!student) return;
|
||||
|
||||
const { code, expiresAt } = await generateUniqueActivationCode();
|
||||
await prisma.student.update({
|
||||
where: { id },
|
||||
data: { activationCode: generateCode() },
|
||||
data: { activationCode: code, activationCodeExpiresAt: expiresAt },
|
||||
});
|
||||
|
||||
redirect(`/dashboard/students/${id}`);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth-config";
|
||||
import { generateUniqueActivationCode } from "@/lib/activation";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -17,15 +18,6 @@ const schema = z.object({
|
||||
classId: z.string().min(1),
|
||||
});
|
||||
|
||||
function generateActivationCode(): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let code = "";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
async function createStudent(formData: FormData) {
|
||||
"use server";
|
||||
const session = await getServerSession(authOptions);
|
||||
@@ -35,13 +27,15 @@ async function createStudent(formData: FormData) {
|
||||
const parsed = schema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsed.success) return;
|
||||
|
||||
const { code, expiresAt } = await generateUniqueActivationCode();
|
||||
await prisma.student.create({
|
||||
data: {
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
email: parsed.data.email,
|
||||
classId: parsed.data.classId,
|
||||
activationCode: generateActivationCode(),
|
||||
activationCode: code,
|
||||
activationCodeExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const CODE_LENGTH = 6;
|
||||
const CODE_TTL_MINUTES = 60;
|
||||
|
||||
export function generateActivationCode(): { code: string; expiresAt: Date } {
|
||||
let code = "";
|
||||
const bytes = randomBytes(CODE_LENGTH);
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
code += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
|
||||
}
|
||||
const expiresAt = new Date(Date.now() + CODE_TTL_MINUTES * 60 * 1000);
|
||||
return { code, expiresAt };
|
||||
}
|
||||
|
||||
export async function generateUniqueActivationCode(retries = 5): Promise<{ code: string; expiresAt: Date }> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
const { code, expiresAt } = generateActivationCode();
|
||||
const existing = await prisma.student.findUnique({ where: { activationCode: code } });
|
||||
if (!existing) return { code, expiresAt };
|
||||
}
|
||||
throw new Error("Failed to generate a unique activation code");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "./auth-config";
|
||||
|
||||
export type ApiUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: "superadmin" | "admin" | "teacher";
|
||||
establishmentId?: string;
|
||||
};
|
||||
|
||||
export async function requireAuth(): Promise<ApiUser | NextResponse> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return session.user as ApiUser;
|
||||
}
|
||||
|
||||
export function requireRole(user: ApiUser, ...allowed: string[]): NextResponse | null {
|
||||
if (!allowed.includes(user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function forbidden(): NextResponse {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
export function getScopedEstablishmentId(user: ApiUser, requested?: string | null): string | undefined | NextResponse {
|
||||
if (user.role === "superadmin") {
|
||||
return requested ?? undefined;
|
||||
}
|
||||
if (requested && requested !== user.establishmentId) {
|
||||
return forbidden();
|
||||
}
|
||||
return user.establishmentId;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
interface HeadscaleUser {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface HeadscalePreAuthKey {
|
||||
key: string;
|
||||
expiration: string;
|
||||
aclTags: string[];
|
||||
}
|
||||
|
||||
export async function getHeadscaleUserId(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
userName: string
|
||||
): Promise<string> {
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/user?name=${encodeURIComponent(userName)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Headscale list users failed: ${res.status} ${await res.text()}`
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as { users: HeadscaleUser[] };
|
||||
const user = data.users.find((u) => u.name === userName);
|
||||
if (!user) {
|
||||
throw new Error(`Headscale user not found: ${userName}`);
|
||||
}
|
||||
return user.id;
|
||||
}
|
||||
|
||||
export async function createEphemeralPreAuthKey(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
userId: string,
|
||||
options: {
|
||||
expirationMinutes?: number;
|
||||
aclTags?: string[];
|
||||
} = {}
|
||||
): Promise<string> {
|
||||
const expirationMinutes = options.expirationMinutes ?? 15;
|
||||
const aclTags = options.aclTags ?? [];
|
||||
|
||||
const expiration = new Date(
|
||||
Date.now() + expirationMinutes * 60 * 1000
|
||||
).toISOString();
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/preauthkey`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user: userId,
|
||||
reusable: false,
|
||||
ephemeral: false,
|
||||
expiration,
|
||||
aclTags,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Headscale create preauthkey failed: ${res.status} ${await res.text()}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { preAuthKey: HeadscalePreAuthKey };
|
||||
return data.preAuthKey.key;
|
||||
}
|
||||
+215
-17
@@ -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 },
|
||||
|
||||
@@ -57,6 +57,7 @@ model Student {
|
||||
lastName String
|
||||
email String
|
||||
activationCode String? @unique
|
||||
activationCodeExpiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
nodes Node[]
|
||||
}
|
||||
@@ -65,6 +66,7 @@ model Node {
|
||||
id String @id
|
||||
studentId String?
|
||||
student Student? @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||
token String? @unique
|
||||
tailscaleIp String?
|
||||
status String @default("offline")
|
||||
lastSeen DateTime?
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user