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_EMAIL=admin@edudeploy.fr
|
||||||
SUPERADMIN_PASSWORD=CHANGE_ME
|
SUPERADMIN_PASSWORD=CHANGE_ME
|
||||||
HEADSCALE_URL=http://headscale:8080
|
HEADSCALE_URL=http://headscale:8080
|
||||||
|
# Legacy reusable pre-auth key (kept for manual/debug setups).
|
||||||
HEADSCALE_AUTH_KEY=CHANGE_ME
|
HEADSCALE_AUTH_KEY=CHANGE_ME
|
||||||
|
# Headscale API key used by the server to generate ephemeral pre-auth keys.
|
||||||
|
HEADSCALE_API_KEY=CHANGE_ME
|
||||||
|
HEADSCALE_RESOLVER_AUTH_KEY=CHANGE_ME
|
||||||
|
INTERNAL_API_KEY=CHANGE_ME
|
||||||
GITEA_URL=http://gitea:3000
|
GITEA_URL=http://gitea:3000
|
||||||
GITEA_TOKEN=CHANGE_ME
|
GITEA_TOKEN=CHANGE_ME
|
||||||
|
|||||||
@@ -26,3 +26,5 @@ headscale/*.key
|
|||||||
headscale/*.state
|
headscale/*.state
|
||||||
agent/resolv.conf
|
agent/resolv.conf
|
||||||
agent/tailscale-bin/
|
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)
|
.\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
|
## 🛠️ Commandes utiles pour reprendre
|
||||||
|
|
||||||
### Voir l’agent de test
|
### 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
|
### 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`.
|
- 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/`.
|
- 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
|
### Builder / préparer les binaires
|
||||||
|
|
||||||
@@ -208,13 +261,13 @@ cd /opt/studioe5-client-a/agent
|
|||||||
./build.sh
|
./build.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Le `build.sh` génère automatiquement `studioE5-agent-v0.3.0-windows.zip` et copie les binaires versionnés dans `server/public/`.
|
Le `build.sh` génère automatiquement `studioE5-agent-v0.3.5-windows.zip` et copie les binaires versionnés dans `server/public/`.
|
||||||
|
|
||||||
### Flow d’activation zéro-config (modèle commercialisable)
|
### Flow d’activation zéro-config (modèle commercialisable)
|
||||||
|
|
||||||
L’élève/employé n’a **aucune configuration technique** à saisir :
|
L’élève/employé n’a **aucune configuration technique** à saisir :
|
||||||
|
|
||||||
1. **Télécharger** l’agent Windows (`studioE5-agent-v0.3.0-windows.zip`).
|
1. **Télécharger** l’agent Windows (`studioE5-agent-v0.3.4-windows.zip`).
|
||||||
2. **Extraire** et **lancer** `studioE5-agent.exe`.
|
2. **Extraire** et **lancer** `studioE5-agent.exe`.
|
||||||
3. **Entrer le code d’activation** à 6 caractères fourni par l’établissement (affiché dans l’UI locale `http://localhost:7070`).
|
3. **Entrer le code d’activation** à 6 caractères fourni par l’établissement (affiché dans l’UI locale `http://localhost:7070`).
|
||||||
4. L’agent contacte le serveur, le serveur vérifie le code et renvoie **automatiquement** :
|
4. L’agent contacte le serveur, le serveur vérifie le code et renvoie **automatiquement** :
|
||||||
@@ -245,47 +298,352 @@ Lancement :
|
|||||||
.\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data
|
.\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 🔒 Durcissement du code d’activation
|
||||||
|
|
||||||
|
### Génération
|
||||||
|
|
||||||
|
- Les codes sont générés avec `crypto.randomBytes` (au lieu de `Math.random`).
|
||||||
|
- Longueur conservée à 6 caractères, alphabet sans ambiguïté (`ABCDEFGHJKLMNPQRSTUVWXYZ23456789`).
|
||||||
|
- Un champ `activationCodeExpiresAt` a été ajouté au modèle `Student` ; les codes expirent après **60 minutes**.
|
||||||
|
|
||||||
|
### Rate-limiting
|
||||||
|
|
||||||
|
- Maximum de **5 tentatives d’activation par code** sur une fenêtre de **15 minutes**.
|
||||||
|
- Maximum de **5 tentatives par `nodeId`** sur la même fenêtre.
|
||||||
|
- Au-delà, le serveur répond `activation_failed` avec `Too many attempts`.
|
||||||
|
|
||||||
|
### Cycle de vie
|
||||||
|
|
||||||
|
- Le code est **invalide après une activation réussie** (`activationCode` et `activationCodeExpiresAt` mis à `null`).
|
||||||
|
- Un code expiré renvoie `Code expired`.
|
||||||
|
- Un code déjà utilisé renvoie `Invalid code`.
|
||||||
|
|
||||||
|
### Tests validés
|
||||||
|
|
||||||
|
- Activation valide → `activated` + token node reçu.
|
||||||
|
- Code expiré → `Code expired`.
|
||||||
|
- Code déjà utilisé → `Invalid code`.
|
||||||
|
- 5+ tentatives invalides → `Too many attempts`.
|
||||||
|
|
||||||
|
## 🔒 ACL Headscale (isolation du tailnet)
|
||||||
|
|
||||||
|
### Objectif
|
||||||
|
|
||||||
|
Par défaut, tous les nœuds du tailnet peuvent communiquer entre eux. Les ACL restreignent la connectivité au strict nécessaire :
|
||||||
|
- les agents élèves ne peuvent pas se parler entre eux ;
|
||||||
|
- le resolver peut atteindre les agents sur leurs ports d’instance ;
|
||||||
|
- les agents peuvent joindre le resolver sur son port HTTP interne.
|
||||||
|
|
||||||
|
### Mise en œuvre
|
||||||
|
|
||||||
|
- Fichier de politique : `headscale/acl_policy.hujson`.
|
||||||
|
- `headscale/config.yaml` pointe vers ce fichier via `policy.path`.
|
||||||
|
- Le resolver a été déplacé dans un utilisateur Headscale dédié `resolver` (clé `HEADSCALE_RESOLVER_AUTH_KEY`).
|
||||||
|
- Les agents utilisent l’utilisateur `studioe5` et sont tagués `tag:student-agent`.
|
||||||
|
- Les nouveaux agents recevront automatiquement le tag via la nouvelle clé pré-auth `HEADSCALE_AUTH_KEY` (créée avec `--tags tag:student-agent`).
|
||||||
|
|
||||||
|
### Contenu de la politique
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"groups": {
|
||||||
|
"group:agents": ["studioe5@studioe5.local"],
|
||||||
|
"group:resolvers": ["resolver@studioe5.local"]
|
||||||
|
},
|
||||||
|
"tagOwners": {
|
||||||
|
"tag:student-agent": ["studioe5@studioe5.local"],
|
||||||
|
"tag:resolver": ["resolver@studioe5.local"]
|
||||||
|
},
|
||||||
|
"acls": [
|
||||||
|
{ "action": "accept", "src": ["tag:resolver"], "dst": ["tag:student-agent:*"] },
|
||||||
|
{ "action": "accept", "src": ["tag:student-agent"], "dst": ["tag:resolver:2020"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests validés
|
||||||
|
|
||||||
|
| Test | Résultat |
|
||||||
|
|------|----------|
|
||||||
|
| `resolver` ping agent | ✅ OK |
|
||||||
|
| Agent → agent (port instance) | ❌ bloqué (timeout) |
|
||||||
|
| Agent → resolver:2020 | ✅ OK |
|
||||||
|
| Flux HTTPS public | ✅ HTTP 200 |
|
||||||
|
|
||||||
|
## 🔒 Authentification du canal serveur → agent
|
||||||
|
|
||||||
|
### Token d’authentification par nœud
|
||||||
|
|
||||||
|
- Le modèle `Node` dispose d’un champ `token` unique.
|
||||||
|
- L’agent envoie son token dans l’en-tête `Authorization: Bearer <token>` lors de la connexion WebSocket.
|
||||||
|
- Le serveur rejette toute connexion/register dont le token ne correspond pas au `nodeId` (fermeture `1008`).
|
||||||
|
- Lors de l’activation, le serveur génère un token aléatoire (32 octets hex) et le renvoie dans le message `activated` ; l’agent le sauvegarde dans `<data-dir>/node.token` (permissions `0600`).
|
||||||
|
- Pour les nœuds existants sans token, le serveur en génère un à la première connexion et l’envoie via `set_token`.
|
||||||
|
|
||||||
|
### Endpoint `/api/internal/send-to-node`
|
||||||
|
|
||||||
|
- Protégé par la variable d’environnement `INTERNAL_API_KEY`.
|
||||||
|
- Requiert l’en-tête `Authorization: Bearer <INTERNAL_API_KEY>`.
|
||||||
|
- Appel sans clé → `401 Unauthorized`.
|
||||||
|
|
||||||
|
### Routes API métier
|
||||||
|
|
||||||
|
- Les routes de gestion des instances (`/api/instances`) requièrent une session NextAuth valide.
|
||||||
|
- Un administrateur ne peut agir que sur les ressources de son établissement ; le `superadmin` peut tout voir/tout faire.
|
||||||
|
|
||||||
|
### Endpoint `/api/resolve`
|
||||||
|
|
||||||
|
- Protégé par la même clé `INTERNAL_API_KEY`.
|
||||||
|
- Requiert l’en-tête `Authorization: Bearer <INTERNAL_API_KEY>`.
|
||||||
|
- Le resolver (`resolver:2020`) ne l’utilise pas ; il interroge directement PostgreSQL. Cette route est donc réservée aux outils/scripts internes authentifiés.
|
||||||
|
|
||||||
|
### Exemples de commandes avec la clé interne
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KEY=$(grep INTERNAL_API_KEY /opt/studioe5-client-a/.env | cut -d= -f2)
|
||||||
|
|
||||||
|
curl -sS -X POST https://studioe5.edudeploy.com/api/internal/send-to-node \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $KEY" \
|
||||||
|
-d '{"nodeId":"vps-8fc665eb","message":{"action":"start_vpn"}}'
|
||||||
|
|
||||||
|
curl -sS -H "Authorization: Bearer $KEY" \
|
||||||
|
"https://studioe5.edudeploy.com/api/resolve?subdomain=test-wp-001"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Clés pré-auth Headscale éphémères
|
||||||
|
|
||||||
|
### Principe
|
||||||
|
|
||||||
|
À l’activation zero-config, le serveur génère désormais une **clé pré-auth unique et à usage unique** pour chaque agent, au lieu d’envoyer la clé réutilisable `HEADSCALE_AUTH_KEY`.
|
||||||
|
|
||||||
|
Avantages :
|
||||||
|
- une clé compromise ne permet pas d’enregistrer d’autres nœuds ;
|
||||||
|
- traçabilité directe entre une activation et une clé Headscale ;
|
||||||
|
- expiration courte (15 min) ;
|
||||||
|
- la clé n’est **pas persistée** dans `studioE5-config.json` côté agent.
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
| Composant | Changement |
|
||||||
|
|-----------|------------|
|
||||||
|
| `server/lib/headscale.ts` | Nouveau helper : `getHeadscaleUserId()` + `createEphemeralPreAuthKey()` appelant `POST /api/v1/preauthkey`. |
|
||||||
|
| `server/lib/websocket.ts` | Sur `activate`, génère une clé éphémère taguée `tag:student-agent` pour l’utilisateur `studioe5`. Fallback sur `HEADSCALE_AUTH_KEY` si `HEADSCALE_API_KEY` n’est pas configurée. |
|
||||||
|
| `agent/websocket.go` | La clé reçue est utilisée immédiatement mais **n’est plus écrite** dans `studioE5-config.json`. |
|
||||||
|
| `agent/tailscale.go` | `tailscale up` fonctionne sans `--authkey` quand le state Tailscale existe déjà (reconnexion). |
|
||||||
|
| `.env.example` / `docker-compose.yml` | Ajout de `HEADSCALE_API_KEY` pour le service `server`. |
|
||||||
|
|
||||||
|
### Configuration requise
|
||||||
|
|
||||||
|
Générer une clé API Headscale (depuis le conteneur ou la CLI) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/studioe5-client-a
|
||||||
|
# Clé valable 10 ans (87600h) pour éviter un renouvellement fréquent.
|
||||||
|
docker compose exec headscale headscale apikeys create -e 87600h
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis l’ajouter dans `.env` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HEADSCALE_API_KEY=hskey-api-...
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ La clé API Headscale a une expiration par défaut de 90 jours. La clé de production a été créée avec une expiration de **10 ans** (`-e 87600h`). Les anciennes clés ont été révoquées.
|
||||||
|
|
||||||
|
### Rotation / renouvellement
|
||||||
|
|
||||||
|
Si la clé doit être changée :
|
||||||
|
|
||||||
|
1. Créer une nouvelle clé API :
|
||||||
|
```bash
|
||||||
|
docker compose exec headscale headscale apikeys create -e 87600h
|
||||||
|
```
|
||||||
|
2. Mettre à jour `.env` :
|
||||||
|
```bash
|
||||||
|
HEADSCALE_API_KEY=<nouvelle_clé>
|
||||||
|
```
|
||||||
|
3. Redémarrer le serveur :
|
||||||
|
```bash
|
||||||
|
docker compose up -d server
|
||||||
|
```
|
||||||
|
4. Révoquer l’ancienne clé :
|
||||||
|
```bash
|
||||||
|
docker compose exec headscale headscale apikeys expire --id <id_ancienne>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Déploiement effectué
|
||||||
|
|
||||||
|
- Clé API créée et ajoutée au `.env` de production.
|
||||||
|
- Image serveur rebuildée et redémarrée.
|
||||||
|
- Agents Linux/Windows rebuildés en v0.3.4 et copiés dans `server/public/`.
|
||||||
|
|
||||||
|
## 🔒 Sécurité — points restants à traiter
|
||||||
|
|
||||||
|
> Le certificat wildcard `*.studioe5.edudeploy.com` est désormais du ressort du **deployeur** (voir `docs/ONBOARDING_CLIENT.md`). Les points ci-dessous concernent l’application studioE5 proprement dite.
|
||||||
|
|
||||||
|
### Gestion et rotation des secrets
|
||||||
|
|
||||||
|
| Secret | Où ? | Action |
|
||||||
|
|--------|------|--------|
|
||||||
|
| `INTERNAL_API_KEY` | `.env` serveur | Prévoir une procédure de rotation régulière. |
|
||||||
|
| `HEADSCALE_API_KEY` | `.env` serveur | Rotation tous les 10 ans max, stockage sécurisé. |
|
||||||
|
| `NEXTAUTH_SECRET` | `.env` serveur | Génération robuste, rotation si suspicion de fuite. |
|
||||||
|
| `DATABASE_URL` | `.env` serveur | Utilisateur DB dédié, mot de passe fort. |
|
||||||
|
| `node.token` | `<data-dir>/node.token` | Vérifier permissions `0600` sur tous les OS. |
|
||||||
|
|
||||||
|
### Durcissement des conteneurs
|
||||||
|
|
||||||
|
- Limiter les `cap_add` au strict minimum.
|
||||||
|
- Faire tourner les services avec un utilisateur non-root quand possible.
|
||||||
|
- Mettre à jour régulièrement les images de base (Caddy, Node, Postgres, Headscale).
|
||||||
|
- Scanner les images Docker pour les CVE.
|
||||||
|
|
||||||
|
### Mises à jour de sécurité
|
||||||
|
|
||||||
|
- Mise à jour des binaires Tailscale (Windows et Linux).
|
||||||
|
- Mise à jour des images Docker (`server`, `resolver`, `caddy`, `postgres`, `headscale`).
|
||||||
|
- Mise à jour de l’OS des VPS et des postes agents.
|
||||||
|
- Mécanisme de mise à jour automatique ou notification de l’agent.
|
||||||
|
|
||||||
|
### Logs d’audit
|
||||||
|
|
||||||
|
- Tracer la création / suppression d’instances.
|
||||||
|
- Tracer la génération et l’usage des codes d’activation.
|
||||||
|
- Tracer les actions admin (connexion, création d’élève, démarrage/arrêt VPN).
|
||||||
|
- Conservation et consultation des logs d’audit.
|
||||||
|
|
||||||
|
### Backups et reprise d’activité
|
||||||
|
|
||||||
|
- Backup régulier de la base PostgreSQL.
|
||||||
|
- Backup du state Headscale.
|
||||||
|
- Backup des states Tailscale côté agents.
|
||||||
|
- Procédure de restauration documentée et testée.
|
||||||
|
|
||||||
|
### Sécurité du build et distribution de l’agent
|
||||||
|
|
||||||
|
- Vérifier l’intégrité des binaires Tailscale téléchargés (checksum / signature).
|
||||||
|
- Signer l’exécutable Windows `studioE5-agent.exe` pour éviter les alertes Defender.
|
||||||
|
- Fournir un hash SHA256 des archives d’agent.
|
||||||
|
|
||||||
|
### RGPD et données personnelles
|
||||||
|
|
||||||
|
- Justifier la conservation des noms/prénoms des élèves.
|
||||||
|
- Gérer les droits d’accès, la suppression de compte et l’export de données.
|
||||||
|
- Définir la durée de conservation des logs et historiques.
|
||||||
|
|
||||||
|
### Sécurité réseau complémentaire
|
||||||
|
|
||||||
|
- Restreindre l’accès à `/api/internal/send-to-node` par IP source si possible.
|
||||||
|
- Vérifier l’exposition publique du dashboard Headscale et la durcir si nécessaire.
|
||||||
|
- Évaluer si `headscale.studioe5.edudeploy.com` doit rester public.
|
||||||
|
|
||||||
|
### Rate limiting et quotas
|
||||||
|
|
||||||
|
- Rate-limiting global sur les routes publiques (`/api/auth/*`, activation, création d’instance).
|
||||||
|
- Limitation du nombre d’instances par élève et par établissement.
|
||||||
|
- Protection contre les abus sur la génération de codes d’activation.
|
||||||
|
|
||||||
|
### Tests de sécurité
|
||||||
|
|
||||||
|
- Tests d’intrusion légers (agent → agent, accès aux endpoints internes sans clé, accès à une instance d’un autre élève).
|
||||||
|
- Tests automatisés du flux complet avant chaque release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📋 Prochaines étapes à faire
|
## 📋 Prochaines étapes à faire
|
||||||
|
|
||||||
- [x] ~~Attendre la fin du rate limit Let’s Encrypt~~ (levé le 2026-06-23).
|
### ✅ Terminé
|
||||||
- [x] ~~Relancer un test HTTPS sur `https://test-wp-001.studioe5.edudeploy.com/`~~ → **OK** (HTTP/2 200).
|
|
||||||
- [x] ~~Créer une branche dédiée et commiter les modifications VPN on-demand~~ → branche `feat/studioe5-vpn-ondemand`, commit `124543d`. Push vers Gitea à faire dès que le remote sera accessible (actuellement `localhost:3001` et `gitea.alfrednobel.edudeploy.com` injoignables).
|
|
||||||
- [x] ~~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.).
|
|
||||||
|
|
||||||
## 💡 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 :
|
## 💡 Améliorations UI
|
||||||
- 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.).
|
|
||||||
|
|
||||||
Implémentation :
|
### ✅ Console / log intégrée dans l’agent (v0.3.5)
|
||||||
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.
|
|
||||||
|
|
||||||
### 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 |
|
| Étape | Poids |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| Connexion au serveur | 10 % |
|
| Préparation de l’application | 10 % |
|
||||||
| Démarrage du VPN | 25 % |
|
| Configuration de l’application | 30 % |
|
||||||
| Téléchargement de l’image Docker | 50 % |
|
| Application en cours de démarrage | 60 % |
|
||||||
| Création de la base de données | 70 % |
|
| Connexion sécurisée active | 80 % |
|
||||||
| Installation de PrestaShop/WordPress | 90 % |
|
| Finalisation de l’installation | 90 % |
|
||||||
| Instance prête | 100 % |
|
| 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`
|
## 🔒 Étude certificat wildcard `*.studioe5.edudeploy.com`
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
VERSION="0.3.3"
|
VERSION="0.3.5"
|
||||||
APP_NAME="studioE5"
|
APP_NAME="studioE5"
|
||||||
BIN_NAME="studioE5-agent"
|
BIN_NAME="studioE5-agent"
|
||||||
LDFLAGS="-X main.version=${VERSION}"
|
LDFLAGS="-X main.version=${VERSION}"
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ func dockerComposeDown(dataDir, instanceID string) error {
|
|||||||
return cmd.Run()
|
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 {
|
func dockerComposeRm(dataDir, instanceID string) error {
|
||||||
dir := instanceDir(dataDir, instanceID)
|
dir := instanceDir(dataDir, instanceID)
|
||||||
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v")
|
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v")
|
||||||
|
|||||||
+64
-1
@@ -2,10 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,7 +46,7 @@ func main() {
|
|||||||
// Redirect agent logs to a file so the console can be hidden on Windows.
|
// Redirect agent logs to a file so the console can be hidden on Windows.
|
||||||
agentLogPath := filepath.Join(*dataDir, "agent.log")
|
agentLogPath := filepath.Join(*dataDir, "agent.log")
|
||||||
if agentLogFile, err := os.OpenFile(agentLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
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 {
|
} else {
|
||||||
log.Printf("Cannot open agent log file %s: %v", agentLogPath, err)
|
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)
|
go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey)
|
||||||
|
|
||||||
shutdownCh := make(chan struct{})
|
shutdownCh := make(chan struct{})
|
||||||
|
|
||||||
|
// Capture Ctrl+C / SIGTERM so a console window close or service stop
|
||||||
|
// triggers the same cleanup path as the tray "Quit" menu.
|
||||||
|
go func() {
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
log.Println("Shutdown signal received")
|
||||||
|
close(shutdownCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var cleanupWg sync.WaitGroup
|
||||||
|
cleanupWg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer cleanupWg.Done()
|
||||||
|
<-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 {
|
if *noTray {
|
||||||
log.Printf("[%s Agent] running in console mode (no tray)", APP_NAME)
|
log.Printf("[%s Agent] running in console mode (no tray)", APP_NAME)
|
||||||
<-shutdownCh
|
<-shutdownCh
|
||||||
|
cleanupWg.Wait()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +123,7 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
<-shutdownCh
|
<-shutdownCh
|
||||||
|
cleanupWg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
|
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)
|
log.Printf("Sent tailscale_ip to server: %s", ip)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reconfigure tailscale serve for any instances that were left running
|
||||||
|
// (e.g. after an agent restart while containers kept running).
|
||||||
|
if inst, err := loadInstances(dataDir); err == nil {
|
||||||
|
for id, info := range inst {
|
||||||
|
if info.Status == "running" {
|
||||||
|
log.Printf("Reconfiguring tailscale serve for running instance %s on port %d", id, info.Port)
|
||||||
|
if err := setupTailscaleServe(info.Port); err != nil {
|
||||||
|
log.Printf("setupTailscaleServe error for %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the local UI that the service status has changed.
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-1
@@ -9,6 +9,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -61,6 +62,9 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro
|
|||||||
if err := os.MkdirAll(tsDataDir, 0700); err != nil {
|
if err := os.MkdirAll(tsDataDir, 0700); err != nil {
|
||||||
return "", fmt.Errorf("create tailscale dir: %w", err)
|
return "", fmt.Errorf("create tailscale dir: %w", err)
|
||||||
}
|
}
|
||||||
|
// Make sure a previous tailscaled (e.g. left behind after a crash or
|
||||||
|
// force-kill) does not block the new daemon on the same socket/state.
|
||||||
|
killStaleTailscaled(tsDataDir)
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
// Windows uses named pipes for tailscaled IPC, not Unix sockets.
|
// Windows uses named pipes for tailscaled IPC, not Unix sockets.
|
||||||
tsSocket = `\\.\pipe\studioe5-tailscaled`
|
tsSocket = `\\.\pipe\studioe5-tailscaled`
|
||||||
@@ -88,6 +92,9 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro
|
|||||||
tsCmd = nil
|
tsCmd = nil
|
||||||
return "", fmt.Errorf("start tailscaled: %w", err)
|
return "", fmt.Errorf("start tailscaled: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tsDataDir, "tailscaled.pid"), []byte(strconv.Itoa(tsCmd.Process.Pid)), 0644); err != nil {
|
||||||
|
log.Printf("Cannot write tailscaled pid file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Give tailscaled a moment to start listening.
|
// Give tailscaled a moment to start listening.
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
@@ -99,11 +106,14 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro
|
|||||||
upArgs := []string{
|
upArgs := []string{
|
||||||
"--socket=" + tsSocket,
|
"--socket=" + tsSocket,
|
||||||
"up",
|
"up",
|
||||||
"--authkey=" + authKey,
|
|
||||||
"--login-server=" + headscaleURL,
|
"--login-server=" + headscaleURL,
|
||||||
"--hostname=" + nodeID,
|
"--hostname=" + nodeID,
|
||||||
"--accept-dns=false",
|
"--accept-dns=false",
|
||||||
}
|
}
|
||||||
|
// The auth key is omitted on reconnect: Tailscale reuses the existing state.
|
||||||
|
if authKey != "" {
|
||||||
|
upArgs = append(upArgs, "--authkey="+authKey)
|
||||||
|
}
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
// On Windows, keep the VPN up even after the tailscale.exe CLI client disconnects.
|
// On Windows, keep the VPN up even after the tailscale.exe CLI client disconnects.
|
||||||
upArgs = append(upArgs, "--unattended")
|
upArgs = append(upArgs, "--unattended")
|
||||||
@@ -181,6 +191,9 @@ func stopTailscaleLocked() {
|
|||||||
_ = tsCmd.Wait()
|
_ = tsCmd.Wait()
|
||||||
tsCmd = nil
|
tsCmd = nil
|
||||||
tsIP = ""
|
tsIP = ""
|
||||||
|
if tsDataDir != "" {
|
||||||
|
_ = os.Remove(filepath.Join(tsDataDir, "tailscaled.pid"))
|
||||||
|
}
|
||||||
log.Printf("Tailscale stopped")
|
log.Printf("Tailscale stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,4 +213,70 @@ func getTailscaleIP() string {
|
|||||||
return tsIP
|
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"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
@@ -17,6 +21,25 @@ var uiHTML string
|
|||||||
|
|
||||||
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
||||||
|
|
||||||
|
// uiConnections holds active WebSocket connections from local UI clients.
|
||||||
|
var (
|
||||||
|
uiConnections = make(map[*websocket.Conn]bool)
|
||||||
|
uiConnectionsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// uiLogWriter intercepts log output and forwards it to connected UI clients.
|
||||||
|
type uiLogWriter struct{}
|
||||||
|
|
||||||
|
func (w uiLogWriter) Write(p []byte) (n int, err error) {
|
||||||
|
line := strings.TrimSpace(string(p))
|
||||||
|
if line != "" {
|
||||||
|
sendUILog(line)
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func startUI(dataDir, nodeID, serverAddr string) {
|
func startUI(dataDir, nodeID, serverAddr string) {
|
||||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
@@ -32,8 +55,16 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
|||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Do not expose the auth key in plain GET unless requested; for local UI it is fine.
|
// Expose a merged view with the agent version for the UI.
|
||||||
json.NewEncoder(w).Encode(cfg)
|
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:
|
case http.MethodPost:
|
||||||
var cfg AgentConfig
|
var cfg AgentConfig
|
||||||
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||||
@@ -80,23 +111,33 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
|
uiConnectionsMu.Lock()
|
||||||
|
uiConnections[conn] = true
|
||||||
|
uiConnectionsMu.Unlock()
|
||||||
log.Printf("UI client connected from %s", r.RemoteAddr)
|
log.Printf("UI client connected from %s", r.RemoteAddr)
|
||||||
|
|
||||||
|
// Send current status immediately.
|
||||||
|
sendUIStatus(conn, dataDir)
|
||||||
|
|
||||||
// Register notifier to forward activation results from main WS to this UI connection
|
// Register notifier to forward activation results from main WS to this UI connection
|
||||||
notifierID := registerUINotifier(func(msg map[string]interface{}) {
|
notifierID := registerUINotifier(func(msg map[string]interface{}) {
|
||||||
log.Printf("UI notifier forwarding to browser: %+v", msg)
|
|
||||||
if err := conn.WriteJSON(msg); err != nil {
|
if err := conn.WriteJSON(msg); err != nil {
|
||||||
log.Printf("UI notify error: %v", err)
|
log.Printf("UI notify error: %v", err)
|
||||||
} else {
|
|
||||||
log.Printf("UI notifier sent successfully")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
defer unregisterUINotifier(notifierID)
|
defer func() {
|
||||||
|
unregisterUINotifier(notifierID)
|
||||||
|
uiConnectionsMu.Lock()
|
||||||
|
delete(uiConnections, conn)
|
||||||
|
uiConnectionsMu.Unlock()
|
||||||
|
log.Printf("UI client disconnected")
|
||||||
|
}()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
var msg map[string]interface{}
|
var msg map[string]interface{}
|
||||||
if err := conn.ReadJSON(&msg); err != nil {
|
if err := conn.ReadJSON(&msg); err != nil {
|
||||||
log.Printf("UI client disconnected: %v", err)
|
log.Printf("UI client read error: %v", err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
action, _ := msg["action"].(string)
|
action, _ := msg["action"].(string)
|
||||||
@@ -120,6 +161,42 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
|||||||
}
|
}
|
||||||
case "instances":
|
case "instances":
|
||||||
listInstances(dataDir, conn)
|
listInstances(dataDir, conn)
|
||||||
|
case "get_status":
|
||||||
|
sendUIStatus(conn, dataDir)
|
||||||
|
case "run_diagnostic":
|
||||||
|
sendUIStatus(conn, dataDir)
|
||||||
|
conn.WriteJSON(map[string]interface{}{
|
||||||
|
"action": "diagnostic_result",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
"message": "Diagnostic terminé",
|
||||||
|
})
|
||||||
|
case "get_logs":
|
||||||
|
// Logs are streamed as they are produced; no persistent buffer yet.
|
||||||
|
conn.WriteJSON(map[string]interface{}{
|
||||||
|
"action": "log",
|
||||||
|
"message": "Console prête. Les nouveaux logs apparaîtront ici.",
|
||||||
|
"level": "info",
|
||||||
|
})
|
||||||
|
case "start_instance":
|
||||||
|
instanceID, _ := msg["instanceId"].(string)
|
||||||
|
if instanceID != "" {
|
||||||
|
go uiStartInstance(dataDir, nodeID, instanceID)
|
||||||
|
}
|
||||||
|
case "stop_instance":
|
||||||
|
instanceID, _ := msg["instanceId"].(string)
|
||||||
|
if instanceID != "" {
|
||||||
|
go uiStopInstance(dataDir, instanceID)
|
||||||
|
}
|
||||||
|
case "delete_instance":
|
||||||
|
instanceID, _ := msg["instanceId"].(string)
|
||||||
|
if instanceID != "" {
|
||||||
|
go uiDeleteInstance(dataDir, instanceID)
|
||||||
|
}
|
||||||
|
case "reset_instance":
|
||||||
|
instanceID, _ := msg["instanceId"].(string)
|
||||||
|
if instanceID != "" {
|
||||||
|
go uiResetInstance(dataDir, nodeID, instanceID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -139,7 +216,7 @@ func listInstances(dataDir string, conn *websocket.Conn) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var list []map[string]interface{}
|
list := []map[string]interface{}{}
|
||||||
for _, inst := range instances {
|
for _, inst := range instances {
|
||||||
status := getInstanceStatus(dataDir, inst.ID)
|
status := getInstanceStatus(dataDir, inst.ID)
|
||||||
if status != inst.Status {
|
if status != inst.Status {
|
||||||
@@ -149,6 +226,7 @@ func listInstances(dataDir string, conn *websocket.Conn) {
|
|||||||
list = append(list, map[string]interface{}{
|
list = append(list, map[string]interface{}{
|
||||||
"id": inst.ID,
|
"id": inst.ID,
|
||||||
"templateName": inst.TemplateName,
|
"templateName": inst.TemplateName,
|
||||||
|
"type": inst.TemplateName,
|
||||||
"port": inst.Port,
|
"port": inst.Port,
|
||||||
"status": inst.Status,
|
"status": inst.Status,
|
||||||
"url": instanceURL(inst),
|
"url": instanceURL(inst),
|
||||||
@@ -157,3 +235,225 @@ func listInstances(dataDir string, conn *websocket.Conn) {
|
|||||||
|
|
||||||
conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": list})
|
conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": list})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendUILog broadcasts a log line to all connected UI clients.
|
||||||
|
func sendUILog(message string) {
|
||||||
|
uiConnectionsMu.RLock()
|
||||||
|
conns := make([]*websocket.Conn, 0, len(uiConnections))
|
||||||
|
for conn := range uiConnections {
|
||||||
|
conns = append(conns, conn)
|
||||||
|
}
|
||||||
|
uiConnectionsMu.RUnlock()
|
||||||
|
|
||||||
|
msg := map[string]interface{}{
|
||||||
|
"action": "log",
|
||||||
|
"message": message,
|
||||||
|
"level": "info",
|
||||||
|
}
|
||||||
|
for _, conn := range conns {
|
||||||
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ type WSMessage struct {
|
|||||||
TailscaleIP string `json:"tailscaleIp,omitempty"`
|
TailscaleIP string `json:"tailscaleIp,omitempty"`
|
||||||
HeadscaleURL string `json:"headscaleUrl,omitempty"`
|
HeadscaleURL string `json:"headscaleUrl,omitempty"`
|
||||||
HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"`
|
HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -107,14 +109,20 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
|
|||||||
setHeadscaleConfig(headscaleURL, headscaleAuthKey)
|
setHeadscaleConfig(headscaleURL, headscaleAuthKey)
|
||||||
|
|
||||||
for {
|
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 {
|
if err != nil {
|
||||||
log.Printf("WS connect error: %v, retrying in 5s...", err)
|
log.Printf("WS connect error: %v, retrying in 5s...", err)
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("WS connected to %s", serverAddr)
|
log.Printf("WS connected to %s (token=%v)", serverAddr, token != "")
|
||||||
|
|
||||||
mainConnMu.Lock()
|
mainConnMu.Lock()
|
||||||
mainConn = conn
|
mainConn = conn
|
||||||
@@ -136,9 +144,11 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
|
|||||||
log.Println("Waiting for activation...")
|
log.Println("Waiting for activation...")
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Already activated as %s", act.StudentName)
|
log.Printf("Already activated as %s", act.StudentName)
|
||||||
// If already activated and we have credentials, ensure VPN is up.
|
// If already activated, ensure VPN is up. The pre-auth key is
|
||||||
|
// one-time only, so on restart we rely on the persisted tailscaled
|
||||||
|
// state; tailscale up without an authkey reuses existing state.
|
||||||
hsURL, hsKey := getHeadscaleConfig()
|
hsURL, hsKey := getHeadscaleConfig()
|
||||||
if hsURL != "" && hsKey != "" {
|
if hsURL != "" {
|
||||||
go startTailscaleAndReport(dataDir, nodeID, hsURL, hsKey)
|
go startTailscaleAndReport(dataDir, nodeID, hsURL, hsKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,8 +193,23 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
|
|||||||
|
|
||||||
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) {
|
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) {
|
||||||
switch msg.Action {
|
switch msg.Action {
|
||||||
|
case "set_token":
|
||||||
|
if msg.Token != "" {
|
||||||
|
if err := saveNodeToken(dataDir, msg.Token); err != nil {
|
||||||
|
log.Printf("saveNodeToken error: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Node token saved")
|
||||||
|
}
|
||||||
|
}
|
||||||
case "activated":
|
case "activated":
|
||||||
log.Printf("handleMessage: activated received, student=%s", msg.StudentName)
|
log.Printf("handleMessage: activated received, student=%s", msg.StudentName)
|
||||||
|
if msg.Token != "" {
|
||||||
|
if err := saveNodeToken(dataDir, msg.Token); err != nil {
|
||||||
|
log.Printf("saveNodeToken error: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Node token saved on activation")
|
||||||
|
}
|
||||||
|
}
|
||||||
if msg.StudentName != "" {
|
if msg.StudentName != "" {
|
||||||
act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code}
|
act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code}
|
||||||
if err := saveActivation(dataDir, act); err != nil {
|
if err := saveActivation(dataDir, act); err != nil {
|
||||||
@@ -194,7 +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 != "" {
|
if msg.HeadscaleURL != "" && msg.HeadscaleAuthKey != "" {
|
||||||
setHeadscaleConfig(msg.HeadscaleURL, msg.HeadscaleAuthKey)
|
setHeadscaleConfig(msg.HeadscaleURL, msg.HeadscaleAuthKey)
|
||||||
cfg, _, err := loadOrCreateConfig(dataDir)
|
cfg, _, err := loadOrCreateConfig(dataDir)
|
||||||
@@ -202,11 +229,11 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
log.Printf("loadOrCreateConfig error: %v", err)
|
log.Printf("loadOrCreateConfig error: %v", err)
|
||||||
} else {
|
} else {
|
||||||
cfg.HeadscaleURL = msg.HeadscaleURL
|
cfg.HeadscaleURL = msg.HeadscaleURL
|
||||||
cfg.HeadscaleAuthKey = msg.HeadscaleAuthKey
|
// Intentionally do not save HeadscaleAuthKey: it is one-time only.
|
||||||
if err := saveConfig(dataDir, cfg); err != nil {
|
if err := saveConfig(dataDir, cfg); err != nil {
|
||||||
log.Printf("saveConfig error: %v", err)
|
log.Printf("saveConfig error: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Saved Headscale config received from server")
|
log.Printf("Saved Headscale URL received from server (auth key not persisted)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
go startTailscaleAndReport(dataDir, nodeID, msg.HeadscaleURL, msg.HeadscaleAuthKey)
|
go startTailscaleAndReport(dataDir, nodeID, msg.HeadscaleURL, msg.HeadscaleAuthKey)
|
||||||
@@ -232,6 +259,10 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("start_vpn error: %v", err)
|
log.Printf("start_vpn error: %v", err)
|
||||||
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()})
|
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()})
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
@@ -243,6 +274,10 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
log.Printf("Sent tailscale_ip to server: %s", ip)
|
log.Printf("Sent tailscale_ip to server: %s", ip)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
})
|
||||||
}()
|
}()
|
||||||
case "stop_vpn":
|
case "stop_vpn":
|
||||||
log.Printf("Server requested VPN stop")
|
log.Printf("Server requested VPN stop")
|
||||||
@@ -256,44 +291,12 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
})
|
})
|
||||||
case "start":
|
case "start":
|
||||||
log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port)
|
log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port)
|
||||||
if err := upsertInstance(dataDir, &InstanceInfo{
|
go handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.Port)
|
||||||
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"})
|
|
||||||
case "stop":
|
case "stop":
|
||||||
log.Printf("Stop instance %s", msg.InstanceID)
|
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 {
|
if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil {
|
||||||
log.Printf("dockerComposeDown error: %v", err)
|
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"})
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
case "delete":
|
case "delete":
|
||||||
log.Printf("Delete instance %s", msg.InstanceID)
|
log.Printf("Delete instance %s", msg.InstanceID)
|
||||||
|
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
|
||||||
|
removeTailscaleServe(inst[msg.InstanceID].Port)
|
||||||
|
}
|
||||||
dockerComposeRm(dataDir, msg.InstanceID)
|
dockerComposeRm(dataDir, msg.InstanceID)
|
||||||
removeInstance(dataDir, msg.InstanceID)
|
removeInstance(dataDir, msg.InstanceID)
|
||||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
case "reset":
|
case "reset":
|
||||||
log.Printf("Reset instance %s", msg.InstanceID)
|
log.Printf("Reset instance %s", msg.InstanceID)
|
||||||
dockerComposeRm(dataDir, msg.InstanceID)
|
dockerComposeRm(dataDir, msg.InstanceID)
|
||||||
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); err != nil {
|
go handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.Port)
|
||||||
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"})
|
|
||||||
default:
|
default:
|
||||||
log.Printf("Unknown action: %s", msg.Action)
|
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) {
|
func ensureTailscale(dataDir, nodeID string, port int) {
|
||||||
hsURL, hsKey := getHeadscaleConfig()
|
hsURL, hsKey := getHeadscaleConfig()
|
||||||
if hsURL == "" || hsKey == "" {
|
if hsURL == "" || hsKey == "" {
|
||||||
@@ -356,6 +392,10 @@ func ensureTailscale(dataDir, nodeID string, port int) {
|
|||||||
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
|
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ensureTailscale start error: %v", err)
|
log.Printf("ensureTailscale start error: %v", err)
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
@@ -367,4 +407,8 @@ func ensureTailscale(dataDir, nodeID string, port int) {
|
|||||||
log.Printf("Sent tailscale_ip to server: %s", ip)
|
log.Printf("Sent tailscale_ip to server: %s", ip)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -34,6 +34,8 @@ services:
|
|||||||
MAIN_DOMAIN: ${MAIN_DOMAIN}
|
MAIN_DOMAIN: ${MAIN_DOMAIN}
|
||||||
HEADSCALE_URL: ${HEADSCALE_URL}
|
HEADSCALE_URL: ${HEADSCALE_URL}
|
||||||
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
|
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
|
||||||
|
HEADSCALE_API_KEY: ${HEADSCALE_API_KEY}
|
||||||
|
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -66,7 +68,7 @@ services:
|
|||||||
devices:
|
devices:
|
||||||
- /dev/net/tun:/dev/net/tun
|
- /dev/net/tun:/dev/net/tun
|
||||||
environment:
|
environment:
|
||||||
TS_AUTHKEY: ${HEADSCALE_AUTH_KEY}
|
TS_AUTHKEY: ${HEADSCALE_RESOLVER_AUTH_KEY}
|
||||||
TS_LOGIN_SERVER: ${HEADSCALE_URL}
|
TS_LOGIN_SERVER: ${HEADSCALE_URL}
|
||||||
TS_EXTRA_ARGS: --login-server=${HEADSCALE_URL}
|
TS_EXTRA_ARGS: --login-server=${HEADSCALE_URL}
|
||||||
TS_STATE_DIR: /var/lib/tailscale
|
TS_STATE_DIR: /var/lib/tailscale
|
||||||
|
|||||||
@@ -0,0 +1,549 @@
|
|||||||
|
# Deployeur studioE5 — Onboarding d’un nouvel établissement
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Ce document décrit le fonctionnement du **deployeur studioE5**, c’est-à-dire l’application / l’outil qui provisionne et configure un nouvel environnement client (un établissement) prêt à accueillir l’application studioE5.
|
||||||
|
|
||||||
|
L’application studioE5 elle-même (agent, VPN on-demand, resolver, Caddy, Headscale, etc.) est documentée dans `SUIVI_VPN_ONDEMAND.md`. Le deployeur est l’outil qui **déploie** cette application sur un VPS dédié au client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Public cible
|
||||||
|
|
||||||
|
- Équipe produit / développement du deployeur
|
||||||
|
- Équipe ops / déploiement
|
||||||
|
- Référents techniques du client A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Glossaire
|
||||||
|
|
||||||
|
| Terme | Définition |
|
||||||
|
|-------|------------|
|
||||||
|
| **Deployeur** | Application qui provisionne un VPS, déploie la stack studioE5 et configure le DNS/certificats pour un nouvel établissement. |
|
||||||
|
| **Hub central** | Dashboard superadmin studioE5 qui orchestre les déploiements et la gestion multi-clients. |
|
||||||
|
| **Établissement** | Entité client (école, lycée, université, entreprise). |
|
||||||
|
| **Tag établissement** | Slug unique et court identifiant l’établissement dans les URLs et le DNS. |
|
||||||
|
| **Domaine géré** | Sous-domaine fourni par studioE5 : `*.tag.edudeploy.com`. |
|
||||||
|
| **Domaine propre** | Domaine détenu par l’établissement : `*.tag.monetablissement.fr`. |
|
||||||
|
| **Application studioE5** | Stack complète déployée sur le VPS : `server`, `resolver`, `resolver-vpn`, `caddy`, `headscale`, `postgres`, etc. |
|
||||||
|
| **Agent générique** | Binaire agent unique, capable de se connecter à n’importe quel serveur studioE5 via résolution d’URL à l’activation. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture : deployeur vs application studioE5
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Hub central studioE5 │
|
||||||
|
│ (superadmin, gestion des établissements, monitoring) │
|
||||||
|
└───────────────────────┬─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Deployeur studioE5 │
|
||||||
|
│ (provisionning VPS, DNS, certificats, déploiement stack) │
|
||||||
|
└───────────────────────┬─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Application studioE5 (un par client) │
|
||||||
|
│ Caddy — Resolver — Resolver-VPN — Headscale — Server — DB │
|
||||||
|
│ ▲ │
|
||||||
|
│ │ WebSocket / VPN on-demand │
|
||||||
|
│ ▼ │
|
||||||
|
│ Agent élève (Windows/Linux) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flux d’onboarding par le deployeur (vue d’ensemble)
|
||||||
|
|
||||||
|
```
|
||||||
|
Création de l’établissement dans le hub
|
||||||
|
↓
|
||||||
|
Choix du domaine (géré ou propre)
|
||||||
|
↓
|
||||||
|
Génération du tag établissement
|
||||||
|
↓
|
||||||
|
Provisionning du VPS
|
||||||
|
↓
|
||||||
|
Configuration DNS wildcard
|
||||||
|
↓
|
||||||
|
Génération du certificat wildcard
|
||||||
|
↓
|
||||||
|
Déploiement de la stack studioE5 (Docker Compose)
|
||||||
|
↓
|
||||||
|
Initialisation de Headscale et création des clés
|
||||||
|
↓
|
||||||
|
Création du compte administrateur de l’établissement
|
||||||
|
↓
|
||||||
|
Génération des codes d’activation
|
||||||
|
↓
|
||||||
|
Build et mise à disposition de l’agent dédié
|
||||||
|
↓
|
||||||
|
Activation de l’agent par un élève
|
||||||
|
↓
|
||||||
|
Création d’une première instance (validation du déploiement)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Création de l’établissement dans le hub
|
||||||
|
|
||||||
|
Le superadmin crée un nouvel établissement dans le hub central.
|
||||||
|
|
||||||
|
Données minimales :
|
||||||
|
|
||||||
|
- Nom officiel
|
||||||
|
- Type d’établissement (école, lycée, université, entreprise)
|
||||||
|
- Pays / fuseau horaire
|
||||||
|
- Contact administrateur
|
||||||
|
- Choix du mode de domaine (`managed` ou `custom`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Choix du domaine
|
||||||
|
|
||||||
|
### Option A — Domaine géré par studioE5 (MVP)
|
||||||
|
|
||||||
|
Le deployeur crée automatiquement un sous-domaine du domaine maître :
|
||||||
|
|
||||||
|
```
|
||||||
|
*.tag.edudeploy.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Le hub gère le DNS chez le registrar studioE5 (actuellement Infomaniak).
|
||||||
|
|
||||||
|
### Option B — Domaine propre de l’établissement (évolution)
|
||||||
|
|
||||||
|
L’établissement fournit son propre domaine :
|
||||||
|
|
||||||
|
```
|
||||||
|
*.tag.monetablissement.fr
|
||||||
|
```
|
||||||
|
|
||||||
|
Prérequis :
|
||||||
|
|
||||||
|
- Le client pointe son DNS wildcard vers l’IP du VPS provisionné.
|
||||||
|
- Le deployeur dispose d’un token API du registrar du client pour le challenge DNS-01.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Génération du tag établissement
|
||||||
|
|
||||||
|
Le tag est un slug court, unique au niveau du hub, utilisé dans les URLs et le DNS.
|
||||||
|
|
||||||
|
### Règles
|
||||||
|
|
||||||
|
- Uniquement `[a-z0-9-]`
|
||||||
|
- Pas de tiret au début ni à la fin
|
||||||
|
- Longueur conseillée : 2 à 20 caractères
|
||||||
|
- Vérification d’unicité en base
|
||||||
|
|
||||||
|
### Exemples
|
||||||
|
|
||||||
|
| Nom d’établissement | Tag |
|
||||||
|
|---------------------|-----|
|
||||||
|
| Lycée Jules Ferry | `ljf` |
|
||||||
|
| Institut Supérieur du Digital | `isd` |
|
||||||
|
| École Notre-Dame | `end` |
|
||||||
|
|
||||||
|
### Gestion des collisions
|
||||||
|
|
||||||
|
- `ljf` → `ljf-2`, `ljf-3`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Provisionning du VPS
|
||||||
|
|
||||||
|
Le deployeur provisionne un VPS dédié pour l’établissement.
|
||||||
|
|
||||||
|
### Prérequis sur le VPS vierge
|
||||||
|
|
||||||
|
- OS Linux (Ubuntu LTS recommandé)
|
||||||
|
- Docker + Docker Compose installés
|
||||||
|
- Accès SSH avec clé
|
||||||
|
- Ports ouverts : 22, 80, 443
|
||||||
|
|
||||||
|
### Actions automatisées par le deployeur
|
||||||
|
|
||||||
|
1. Installation de Docker et Docker Compose si absent.
|
||||||
|
2. Création de la structure de dossiers (`/opt/studioe5-<tag>/`).
|
||||||
|
3. Génération des secrets (`.env`) :
|
||||||
|
- `INTERNAL_API_KEY`
|
||||||
|
- `HEADSCALE_API_KEY`
|
||||||
|
- `HEADSCALE_AUTH_KEY` (réutilisable, taguée `tag:student-agent`)
|
||||||
|
- `HEADSCALE_RESOLVER_AUTH_KEY`
|
||||||
|
- `INFOMANIAK_API_TOKEN` (si domaine géré)
|
||||||
|
- `NEXTAUTH_SECRET`, `DATABASE_URL`, etc.
|
||||||
|
4. Récupération des images Docker depuis le registry privé (ou build sur place en attendant).
|
||||||
|
5. Génération des fichiers de configuration (`Caddyfile`, `docker-compose.yml`, `headscale/config.yaml`, `headscale/acl_policy.hujson`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Configuration DNS wildcard
|
||||||
|
|
||||||
|
### Domaine géré
|
||||||
|
|
||||||
|
Le deployeur appelle l’API du registrar pour créer :
|
||||||
|
|
||||||
|
```dns
|
||||||
|
*.tag.edudeploy.com A <IP_DU_VPS>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domaine propre
|
||||||
|
|
||||||
|
Le deployeur vérifie que l’enregistrement existe :
|
||||||
|
|
||||||
|
```dns
|
||||||
|
*.tag.monetablissement.fr A <IP_DU_VPS>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Certificat wildcard
|
||||||
|
|
||||||
|
### Principe
|
||||||
|
|
||||||
|
Un seul certificat wildcard couvre toutes les instances futures de l’établissement.
|
||||||
|
|
||||||
|
### Mise en œuvre avec Caddy
|
||||||
|
|
||||||
|
Le deployeur génère le `Caddyfile` :
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
*.tag.edudeploy.com {
|
||||||
|
tls {
|
||||||
|
dns infomaniak {env.INFOMANIAK_API_TOKEN}
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy resolver:2020 {
|
||||||
|
header_up Host {host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour un domaine propre, le provider DNS est celui du client.
|
||||||
|
|
||||||
|
### Renouvellement
|
||||||
|
|
||||||
|
Géré automatiquement par Caddy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Déploiement de la stack studioE5
|
||||||
|
|
||||||
|
Le deployeur lance la stack Docker Compose complète :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/studioe5-<tag>
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Services déployés :
|
||||||
|
|
||||||
|
- `server` : API + WebSocket + UI Next.js
|
||||||
|
- `resolver` : reverse proxy interne vers les instances
|
||||||
|
- `resolver-vpn` : sidecar Tailscale dans le netns du resolver
|
||||||
|
- `caddy` : reverse proxy public + TLS
|
||||||
|
- `headscale` : contrôleur Tailscale
|
||||||
|
- `postgres` : base de données
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Initialisation de Headscale
|
||||||
|
|
||||||
|
Le deployeur initialise Headscale et crée les clés nécessaires :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Création de l’utilisateur dédié au resolver
|
||||||
|
docker compose exec headscale headscale users create resolver
|
||||||
|
|
||||||
|
# Création de la clé pré-auth réutilisable pour les agents
|
||||||
|
docker compose exec headscale headscale preauthkeys create \
|
||||||
|
--user studioe5 \
|
||||||
|
--reusable \
|
||||||
|
--tags tag:student-agent \
|
||||||
|
-e 87600h
|
||||||
|
|
||||||
|
# Création de la clé pré-auth pour le resolver
|
||||||
|
docker compose exec headscale headscale preauthkeys create \
|
||||||
|
--user resolver \
|
||||||
|
--tags tag:resolver \
|
||||||
|
-e 87600h
|
||||||
|
|
||||||
|
# Création d’une clé API Headscale valable 10 ans
|
||||||
|
docker compose exec headscale headscale apikeys create -e 87600h
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces secrets sont stockés dans le `.env` du serveur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Création du compte administrateur de l’établissement
|
||||||
|
|
||||||
|
Une fois la stack déployée, le deployeur (ou le hub) crée le premier compte administrateur de l’établissement via l’API du serveur nouvellement déployé.
|
||||||
|
|
||||||
|
Rôles :
|
||||||
|
|
||||||
|
- `admin` : gestion des élèves, instances, agents.
|
||||||
|
- `teacher` : gestion limitée à certaines classes/groupes.
|
||||||
|
- `superadmin` (studioE5) : accès transverse.
|
||||||
|
|
||||||
|
L’administrateur reçoit un lien d’activation sécurisé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Génération des codes d’activation
|
||||||
|
|
||||||
|
Le deployeur configure le serveur pour permettre la génération de codes d’activation.
|
||||||
|
|
||||||
|
### Règles de sécurité (implémentées côté application studioE5)
|
||||||
|
|
||||||
|
- Génération avec `crypto.randomBytes`
|
||||||
|
- Alphabet sans ambiguïté : `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`
|
||||||
|
- 6 caractères
|
||||||
|
- Expiration après 60 minutes
|
||||||
|
- Invalidation après usage
|
||||||
|
- Rate-limiting : 5 tentatives par code / 5 tentatives par `nodeId` sur 15 minutes
|
||||||
|
|
||||||
|
### Flux
|
||||||
|
|
||||||
|
1. L’administrateur génère un code pour un élève.
|
||||||
|
2. L’élève saisit le code dans l’agent.
|
||||||
|
3. Le serveur valide et renvoie :
|
||||||
|
- l’identité de l’élève
|
||||||
|
- l’URL Headscale
|
||||||
|
- une clé pré-auth Headscale éphémère
|
||||||
|
4. L’agent démarre automatiquement le VPN.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Build et mise à disposition de l’agent
|
||||||
|
|
||||||
|
### Principe
|
||||||
|
|
||||||
|
L’agent est un binaire générique, mais il doit être capable de se connecter au bon serveur. Le deployeur génère un agent pré-configuré ou un installeur qui embarque l’URL du serveur de l’établissement.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/studioe5-<tag>/agent
|
||||||
|
./download-tailscale-bins.sh 1.98.4
|
||||||
|
./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Artifacts générés :
|
||||||
|
|
||||||
|
- `studioE5-agent-vX.Y.Z-windows.zip`
|
||||||
|
- `studioE5-agent-vX.Y.Z.exe`
|
||||||
|
- `studioE5-agent-vX.Y.Z` (Linux)
|
||||||
|
|
||||||
|
### Mise à disposition
|
||||||
|
|
||||||
|
Les fichiers sont servis par Caddy depuis `server/public/agent/` :
|
||||||
|
|
||||||
|
```
|
||||||
|
https://tag.edudeploy.com/studioE5-agent-vX.Y.Z-windows.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Activation de l’agent
|
||||||
|
|
||||||
|
### Activation zéro-config
|
||||||
|
|
||||||
|
1. L’élève télécharge l’agent depuis l’URL de l’établissement.
|
||||||
|
2. Il extrait l’archive et lance `studioE5-agent.exe`.
|
||||||
|
3. Il ouvre `http://localhost:7070`.
|
||||||
|
4. Il saisit le code d’activation à 6 caractères.
|
||||||
|
5. L’agent contacte le serveur, récupère la configuration et démarre le VPN.
|
||||||
|
|
||||||
|
> Les détails techniques du VPN on-demand (named pipes Windows, logs, ACL, tokens, clés éphémères) sont documentés dans `SUIVI_VPN_ONDEMAND.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Création d’une instance et construction de l’URL (validation)
|
||||||
|
|
||||||
|
Le deployeur ou l’administrateur crée une première instance pour valider le déploiement.
|
||||||
|
|
||||||
|
### Format d’URL
|
||||||
|
|
||||||
|
```
|
||||||
|
<appli>-<initiales><id-court>.<tag>.<domaine>
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemple :
|
||||||
|
|
||||||
|
```
|
||||||
|
wp-jd47.ljf.edudeploy.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Avec :
|
||||||
|
|
||||||
|
- `wp` : type d’application
|
||||||
|
- `jd` : initiales de l’élève
|
||||||
|
- `47` : identifiant court unique
|
||||||
|
- `ljf` : tag de l’établissement
|
||||||
|
- `edudeploy.com` : domaine de base
|
||||||
|
|
||||||
|
### Mapping type d’application → préfixe
|
||||||
|
|
||||||
|
| Application | Préfixe |
|
||||||
|
|-------------|---------|
|
||||||
|
| WordPress | `wp` |
|
||||||
|
| PrestaShop | `ps` |
|
||||||
|
| Moodle | `mdl` |
|
||||||
|
| Nextcloud | `nc` |
|
||||||
|
|
||||||
|
### Protection de l’identité
|
||||||
|
|
||||||
|
- L’URL ne contient pas le nom complet de l’élève.
|
||||||
|
- Seules les initiales + un identifiant court opaque sont exposées.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Modèles de données du deployeur
|
||||||
|
|
||||||
|
### Table / modèle `Organization` (établissement dans le hub)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "Lycée Jules Ferry",
|
||||||
|
"tag": "ljf",
|
||||||
|
"domainMode": "managed",
|
||||||
|
"baseDomain": "edudeploy.com",
|
||||||
|
"adminEmail": "admin@ljf.fr",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-06-25T17:28:07Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table / modèle `Deployment` (déploiement sur un VPS)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"organizationId": "uuid",
|
||||||
|
"serverIp": "203.0.113.10",
|
||||||
|
"serverHostname": "ljf.studioe5.edudeploy.com",
|
||||||
|
"wildcardDnsConfigured": true,
|
||||||
|
"wildcardCertificateReady": true,
|
||||||
|
"dnsProvider": "infomaniak",
|
||||||
|
"dnsProviderTokenRef": "env:INFOMANIAK_TOKEN_LJF",
|
||||||
|
"headscaleApiKeyRef": "env:HEADSCALE_API_KEY_LJF",
|
||||||
|
"status": "ready",
|
||||||
|
"deployedAt": "2026-06-25T17:28:07Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table / modèle `Student` (dans l’application studioE5 déployée)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"organizationId": "uuid",
|
||||||
|
"firstName": "Jean",
|
||||||
|
"lastName": "Dupont",
|
||||||
|
"initials": "jd",
|
||||||
|
"activationCode": "AB3D9F",
|
||||||
|
"activationCodeExpiresAt": "2026-06-25T18:28:07Z",
|
||||||
|
"nodeId": "vps-8fc665eb",
|
||||||
|
"nodeToken": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table / modèle `Instance` (dans l’application studioE5 déployée)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "cmqqgrur20001lw67t2bdgzkg",
|
||||||
|
"organizationId": "uuid",
|
||||||
|
"studentId": "uuid",
|
||||||
|
"nodeId": "vps-8fc665eb",
|
||||||
|
"templateId": "wordpress-wordpress-latest",
|
||||||
|
"applicationPrefix": "wp",
|
||||||
|
"shortId": "47",
|
||||||
|
"subdomain": "wp-jd47",
|
||||||
|
"fqdn": "wp-jd47.ljf.edudeploy.com",
|
||||||
|
"port": 8001,
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Sécurité et RGPD
|
||||||
|
|
||||||
|
### Protection de l’identité de l’élève
|
||||||
|
|
||||||
|
- L’URL publique ne contient pas le nom complet de l’élève.
|
||||||
|
- Seules les initiales + un identifiant court opaque sont exposées.
|
||||||
|
|
||||||
|
### Isolation réseau
|
||||||
|
|
||||||
|
- Les agents élèves ne peuvent pas communiquer entre eux (ACL Headscale).
|
||||||
|
- Le resolver est le seul service autorisé à joindre les agents sur leurs ports d’instance.
|
||||||
|
|
||||||
|
### Authentification
|
||||||
|
|
||||||
|
- Token unique par agent (`node.token`).
|
||||||
|
- Clé API interne pour les endpoints serveur → agent.
|
||||||
|
- Sessions NextAuth sur les routes API métier.
|
||||||
|
|
||||||
|
### Clés pré-auth Headscale
|
||||||
|
|
||||||
|
- Éphémères, à usage unique, 15 minutes d’expiration.
|
||||||
|
- Non persistées côté agent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Checklist de validation du deployeur
|
||||||
|
|
||||||
|
À l’issue d’un onboarding, les points suivants doivent être validés :
|
||||||
|
|
||||||
|
- [ ] L’établissement est créé dans le hub avec un tag unique.
|
||||||
|
- [ ] Le VPS est provisionné et accessible en SSH.
|
||||||
|
- [ ] Docker et Docker Compose sont installés.
|
||||||
|
- [ ] Le DNS wildcard est résolu (`*.tag.edudeploy.com` → IP du VPS).
|
||||||
|
- [ ] Le certificat wildcard est obtenu et valide.
|
||||||
|
- [ ] La stack studioE5 est démarrée (`docker compose ps`).
|
||||||
|
- [ ] Headscale est initialisé avec les utilisateurs et clés nécessaires.
|
||||||
|
- [ ] Le compte administrateur de l’établissement est créé.
|
||||||
|
- [ ] Un code d’activation peut être généré pour un élève.
|
||||||
|
- [ ] L’agent est buildé et téléchargeable depuis le serveur de l’établissement.
|
||||||
|
- [ ] L’agent s’active avec le code zéro-config.
|
||||||
|
- [ ] Une instance peut être créée et son URL est accessible en HTTPS.
|
||||||
|
- [ ] Deux instances différentes reçoivent des URL uniques.
|
||||||
|
- [ ] Le flux HTTPS complet retourne bien HTTP 200.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Roadmap du deployeur
|
||||||
|
|
||||||
|
### Court terme (MVP)
|
||||||
|
|
||||||
|
- Déploiement manuel ou semi-automatisé d’un nouvel établissement sur un VPS.
|
||||||
|
- Domaine géré par studioE5 uniquement.
|
||||||
|
- Build des images sur le VPS cible.
|
||||||
|
- Agent avec URL serveur hardcodée ou fournie à l’activation.
|
||||||
|
|
||||||
|
### Moyen terme
|
||||||
|
|
||||||
|
- **Agent générique** : déterminer l’URL serveur cible à l’activation (code structuré, hub de résolution, ou champ URL).
|
||||||
|
- **Script de provisionning** : installation Docker, déploiement stack, génération secrets, DNS wildcard.
|
||||||
|
- **Registry d’images privé** : builder une fois, déployer partout.
|
||||||
|
- Support de domaines propres à l’établissement.
|
||||||
|
- Support multi-registrar DNS.
|
||||||
|
|
||||||
|
### Long terme
|
||||||
|
|
||||||
|
- **Hub central multi-clients** : dashboard superadmin, gestion des versions, logs distants.
|
||||||
|
- **Mises à jour à distance** : pousser une nouvelle version du serveur et de l’agent sur tous les déploiements.
|
||||||
|
- **Monitoring / support** : alertes serveur down, certificat expiré, agent hors ligne.
|
||||||
|
- **Tests automatisés** : validation du flux activation → VPN → instance → HTTPS public à chaque déploiement.
|
||||||
|
- **Console/log intégré et barre de progression** dans l’agent.
|
||||||
|
- Génération automatique de codes d’activation par import CSV.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"tagOwners": {
|
||||||
|
"tag:student-agent": ["studioe5@studioe5.local"],
|
||||||
|
"tag:resolver": ["resolver@studioe5.local"]
|
||||||
|
},
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["tag:resolver"],
|
||||||
|
"dst": ["tag:student-agent:*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["tag:student-agent"],
|
||||||
|
"dst": ["tag:resolver:2020"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@ database:
|
|||||||
sqlite:
|
sqlite:
|
||||||
path: /etc/headscale/db.sqlite
|
path: /etc/headscale/db.sqlite
|
||||||
|
|
||||||
|
policy:
|
||||||
|
path: /etc/headscale/acl_policy.hujson
|
||||||
|
mode: file
|
||||||
|
|
||||||
log:
|
log:
|
||||||
format: text
|
format: text
|
||||||
level: info
|
level: info
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth, requireRole, getScopedEstablishmentId, forbidden } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const establishmentId = searchParams.get("establishmentId");
|
const requestedId = searchParams.get("establishmentId");
|
||||||
if (!establishmentId) return NextResponse.json({ error: "Missing establishmentId" }, { status: 400 });
|
const establishmentId = getScopedEstablishmentId(user, requestedId);
|
||||||
|
if (establishmentId instanceof NextResponse) return establishmentId;
|
||||||
|
|
||||||
|
const where = establishmentId ? { establishmentId } : {};
|
||||||
|
|
||||||
const classes = await prisma.class.findMany({
|
const classes = await prisma.class.findMany({
|
||||||
where: { establishmentId },
|
where,
|
||||||
include: { _count: { select: { students: true } } },
|
include: { _count: { select: { students: true } } },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
@@ -15,8 +22,19 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { establishmentId, name, level } = body;
|
const requestedId = body.establishmentId;
|
||||||
|
const establishmentId = getScopedEstablishmentId(user, requestedId);
|
||||||
|
if (establishmentId instanceof NextResponse) return establishmentId;
|
||||||
|
if (!establishmentId) return forbidden();
|
||||||
|
|
||||||
|
const { name, level } = body;
|
||||||
const cls = await prisma.class.create({
|
const cls = await prisma.class.create({
|
||||||
data: { establishmentId, name, level },
|
data: { establishmentId, name, level },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
const AGENT_VERSION = "0.3.0";
|
const AGENT_VERSION = "0.3.4";
|
||||||
const AGENT_BIN_NAME = "studioE5-agent";
|
const AGENT_BIN_NAME = "studioE5-agent";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { hashPassword } from "@/lib/auth";
|
import { hashPassword } from "@/lib/auth";
|
||||||
|
import { requireAuth, requireRole } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const where = user.role === "superadmin" ? {} : { id: user.establishmentId };
|
||||||
const establishments = await prisma.establishment.findMany({
|
const establishments = await prisma.establishment.findMany({
|
||||||
|
where,
|
||||||
include: { subscription: true, _count: { select: { users: true, classes: true } } },
|
include: { subscription: true, _count: { select: { users: true, classes: true } } },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
@@ -11,6 +17,12 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { name, slug, adminEmail, adminPassword } = body;
|
const { name, slug, adminEmail, adminPassword } = body;
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,54 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendToNode } from "@/lib/websocket";
|
import { sendToNode } from "@/lib/websocket";
|
||||||
|
import { authOptions } from "@/lib/auth-config";
|
||||||
|
|
||||||
|
async function requireAuth() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) return null;
|
||||||
|
return session.user as { id: string; email: string; role: string; establishmentId?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function userCanAccessNode(user: { role: string; establishmentId?: string }, node: any) {
|
||||||
|
if (user.role === "superadmin") return true;
|
||||||
|
const establishmentId = node?.student?.class?.establishmentId;
|
||||||
|
return establishmentId && establishmentId === user.establishmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function userCanAccessInstance(user: { role: string; establishmentId?: string }, instance: any) {
|
||||||
|
if (user.role === "superadmin") return true;
|
||||||
|
const establishmentId = instance?.node?.student?.class?.establishmentId;
|
||||||
|
return establishmentId && establishmentId === user.establishmentId;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const nodeId = searchParams.get("nodeId");
|
const nodeId = searchParams.get("nodeId");
|
||||||
const establishmentId = searchParams.get("establishmentId");
|
const establishmentIdParam = searchParams.get("establishmentId");
|
||||||
|
|
||||||
let where: any = {};
|
let where: any = {};
|
||||||
if (nodeId) where.nodeId = nodeId;
|
if (nodeId) where.nodeId = nodeId;
|
||||||
if (establishmentId) {
|
|
||||||
const classes = await prisma.class.findMany({ where: { establishmentId }, select: { id: true } });
|
if (user.role !== "superadmin") {
|
||||||
|
const classes = await prisma.class.findMany({
|
||||||
|
where: { establishmentId: user.establishmentId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const students = await prisma.student.findMany({
|
||||||
|
where: { classId: { in: classes.map((c) => c.id) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const nodes = await prisma.node.findMany({
|
||||||
|
where: { studentId: { in: students.map((s) => s.id) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
where.nodeId = { in: nodes.map((n) => n.id) };
|
||||||
|
} else if (establishmentIdParam) {
|
||||||
|
const classes = await prisma.class.findMany({ where: { establishmentId: establishmentIdParam }, select: { id: true } });
|
||||||
const students = await prisma.student.findMany({ where: { classId: { in: classes.map((c) => c.id) } }, select: { id: true } });
|
const students = await prisma.student.findMany({ where: { classId: { in: classes.map((c) => c.id) } }, select: { id: true } });
|
||||||
const nodes = await prisma.node.findMany({ where: { studentId: { in: students.map((s) => s.id) } }, select: { id: true } });
|
const nodes = await prisma.node.findMany({ where: { studentId: { in: students.map((s) => s.id) } }, select: { id: true } });
|
||||||
where.nodeId = { in: nodes.map((n) => n.id) };
|
where.nodeId = { in: nodes.map((n) => n.id) };
|
||||||
@@ -39,12 +77,8 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const enriched = instances.map((inst) => {
|
const enriched = instances.map((inst) => {
|
||||||
const domain = inst.node.student?.class.establishment?.domain;
|
const domain = inst.node.student?.class.establishment?.domain;
|
||||||
const publicUrl = domain
|
const publicUrl = domain ? `https://${inst.id}.${domain}` : null;
|
||||||
? `https://${inst.id}.${domain}`
|
const localUrl = inst.node.tailscaleIp ? `http://${inst.node.tailscaleIp}:${inst.port}` : null;
|
||||||
: null;
|
|
||||||
const localUrl = inst.node.tailscaleIp
|
|
||||||
? `http://${inst.node.tailscaleIp}:${inst.port}`
|
|
||||||
: null;
|
|
||||||
return {
|
return {
|
||||||
...inst,
|
...inst,
|
||||||
publicUrl,
|
publicUrl,
|
||||||
@@ -56,22 +90,32 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { nodeId, templateId, port } = body;
|
const { nodeId, templateId, port } = body;
|
||||||
|
if (!nodeId || !templateId) {
|
||||||
|
return NextResponse.json({ error: "Missing nodeId or templateId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const template = await prisma.template.findUnique({ where: { id: templateId } });
|
const template = await prisma.template.findUnique({ where: { id: templateId } });
|
||||||
if (!template) return NextResponse.json({ error: "Template not found" }, { status: 404 });
|
if (!template) return NextResponse.json({ error: "Template not found" }, { status: 404 });
|
||||||
|
|
||||||
const instance = await prisma.instance.create({
|
|
||||||
data: { nodeId, templateId, port: port || 8080, status: "stopped" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const node = await prisma.node.findUnique({
|
const node = await prisma.node.findUnique({
|
||||||
where: { id: nodeId },
|
where: { id: nodeId },
|
||||||
include: { student: { include: { class: { include: { establishment: true } } } } },
|
include: { student: { include: { class: { include: { establishment: true } } } } },
|
||||||
});
|
});
|
||||||
|
if (!node) return NextResponse.json({ error: "Node not found" }, { status: 404 });
|
||||||
|
if (!userCanAccessNode(user, node)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const domain = node?.student?.class.establishment?.domain;
|
const instance = await prisma.instance.create({
|
||||||
|
data: { nodeId, templateId, port: port || 8080, status: "stopped" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const domain = node.student?.class.establishment?.domain;
|
||||||
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
||||||
const publicUrl = domain ? `https://${publicDomain}` : null;
|
const publicUrl = domain ? `https://${publicDomain}` : null;
|
||||||
const sent = sendToNode(nodeId, {
|
const sent = sendToNode(nodeId, {
|
||||||
@@ -94,14 +138,28 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest) {
|
export async function PATCH(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { id, action } = body;
|
const { id, action } = body;
|
||||||
const instance = await prisma.instance.findUnique({ where: { id }, include: { template: true, node: { include: { student: { include: { class: { include: { establishment: true } } } } } } } });
|
if (!id || !action) {
|
||||||
|
return NextResponse.json({ error: "Missing id or action" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = await prisma.instance.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { template: true, node: { include: { student: { include: { class: { include: { establishment: true } } } } } } },
|
||||||
|
});
|
||||||
if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
if (!userCanAccessInstance(user, instance)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const domain = instance.node.student?.class.establishment?.domain;
|
const domain = instance.node.student?.class.establishment?.domain;
|
||||||
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
||||||
const publicUrl = domain ? `https://${publicDomain}` : null;
|
const publicUrl = domain ? `https://${publicDomain}` : null;
|
||||||
|
|
||||||
if (action === "stop") {
|
if (action === "stop") {
|
||||||
sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
|
sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
|
||||||
await prisma.instance.update({ where: { id }, data: { status: "stopped" } });
|
await prisma.instance.update({ where: { id }, data: { status: "stopped" } });
|
||||||
@@ -131,16 +189,30 @@ export async function PATCH(req: NextRequest) {
|
|||||||
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
||||||
});
|
});
|
||||||
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
const instance = await prisma.instance.findUnique({ where: { id } });
|
|
||||||
|
const instance = await prisma.instance.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { node: { include: { student: { include: { class: { include: { establishment: true } } } } } } },
|
||||||
|
});
|
||||||
|
if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
if (!userCanAccessInstance(user, instance)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
if (instance) sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
|
if (instance) sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
|
||||||
await prisma.instance.delete({ where: { id } });
|
await prisma.instance.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { sendToNode } from "@/lib/websocket";
|
import { sendToNode } from "@/lib/websocket";
|
||||||
|
|
||||||
|
function getBearerToken(req: NextRequest): string | null {
|
||||||
|
const auth = req.headers.get("authorization") || "";
|
||||||
|
const match = auth.match(/^Bearer\s+(\S+)$/i);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const apiKey = process.env.INTERNAL_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json({ error: "Internal API key not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
const token = getBearerToken(req);
|
||||||
|
if (!token || token !== apiKey) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { nodeId, message } = body;
|
const { nodeId, message } = body;
|
||||||
if (!nodeId || !message) {
|
if (!nodeId || !message) {
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth, getScopedEstablishmentId, forbidden } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const establishmentId = searchParams.get("establishmentId");
|
const requestedId = searchParams.get("establishmentId");
|
||||||
|
const establishmentId = getScopedEstablishmentId(user, requestedId);
|
||||||
|
if (establishmentId instanceof NextResponse) return establishmentId;
|
||||||
|
|
||||||
let where: any = {};
|
let where: any = {};
|
||||||
if (establishmentId) {
|
if (establishmentId) {
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
function getBearerToken(req: NextRequest): string | null {
|
||||||
|
const auth = req.headers.get("authorization") || "";
|
||||||
|
const match = auth.match(/^Bearer\s+(\S+)$/i);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const apiKey = process.env.INTERNAL_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json({ error: "Internal API key not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
const token = getBearerToken(req);
|
||||||
|
if (!token || token !== apiKey) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const subdomain = searchParams.get("subdomain");
|
const subdomain = searchParams.get("subdomain");
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { generateUniqueActivationCode } from "@/lib/activation";
|
||||||
function generateCode(length = 6) {
|
|
||||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
let code = "";
|
|
||||||
for (let i = 0; i < length; i++) code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
@@ -31,13 +25,15 @@ export async function GET(req: NextRequest) {
|
|||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { classId, firstName, lastName, email } = body;
|
const { classId, firstName, lastName, email } = body;
|
||||||
|
const { code, expiresAt } = await generateUniqueActivationCode();
|
||||||
const student = await prisma.student.create({
|
const student = await prisma.student.create({
|
||||||
data: {
|
data: {
|
||||||
classId,
|
classId,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
email,
|
email,
|
||||||
activationCode: generateCode(),
|
activationCode: code,
|
||||||
|
activationCodeExpiresAt: expiresAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json(student, { status: 201 });
|
return NextResponse.json(student, { status: 201 });
|
||||||
|
|||||||
@@ -1,25 +1,57 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth, requireRole, forbidden } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
function templateAccessWhere(user: { role: string; establishmentId?: string }, establishmentId?: string | null) {
|
||||||
|
if (user.role === "superadmin" && establishmentId) {
|
||||||
|
return { OR: [{ isPublic: true }, { establishmentId }] };
|
||||||
|
}
|
||||||
|
if (user.establishmentId) {
|
||||||
|
return { OR: [{ isPublic: true }, { establishmentId: user.establishmentId }] };
|
||||||
|
}
|
||||||
|
return { isPublic: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canManageTemplate(user: { role: string; establishmentId?: string }, id: string) {
|
||||||
|
if (user.role === "superadmin") return true;
|
||||||
|
const template = await prisma.template.findUnique({ where: { id } });
|
||||||
|
if (!template) return false;
|
||||||
|
return template.establishmentId === user.establishmentId;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const establishmentId = searchParams.get("establishmentId");
|
const requestedEst = searchParams.get("establishmentId");
|
||||||
|
|
||||||
|
const where = user.role === "superadmin" && !requestedEst ? {} : templateAccessWhere(user, requestedEst);
|
||||||
|
|
||||||
const templates = await prisma.template.findMany({
|
const templates = await prisma.template.findMany({
|
||||||
where: {
|
where,
|
||||||
OR: [
|
|
||||||
{ isPublic: true },
|
|
||||||
...(establishmentId ? [{ establishmentId }] : []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return NextResponse.json(templates);
|
return NextResponse.json(templates);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy } = body;
|
let { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy } = body;
|
||||||
|
|
||||||
|
if (user.role !== "superadmin") {
|
||||||
|
if (establishmentId && establishmentId !== user.establishmentId) {
|
||||||
|
return forbidden();
|
||||||
|
}
|
||||||
|
establishmentId = user.establishmentId;
|
||||||
|
}
|
||||||
|
|
||||||
const template = await prisma.template.create({
|
const template = await prisma.template.create({
|
||||||
data: { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy },
|
data: { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy },
|
||||||
});
|
});
|
||||||
@@ -27,16 +59,39 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(req: NextRequest) {
|
export async function PUT(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { id, ...data } = body;
|
const { id, ...data } = body;
|
||||||
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
|
|
||||||
|
if (!(await canManageTemplate(user, id))) return forbidden();
|
||||||
|
|
||||||
|
if (user.role !== "superadmin" && data.establishmentId && data.establishmentId !== user.establishmentId) {
|
||||||
|
return forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
const template = await prisma.template.update({ where: { id }, data });
|
const template = await prisma.template.update({ where: { id }, data });
|
||||||
return NextResponse.json(template);
|
return NextResponse.json(template);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
|
|
||||||
|
if (!(await canManageTemplate(user, id))) return forbidden();
|
||||||
|
|
||||||
await prisma.template.delete({ where: { id } });
|
await prisma.template.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { hashPassword } from "@/lib/auth";
|
import { hashPassword } from "@/lib/auth";
|
||||||
|
import { requireAuth, requireRole, forbidden } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const establishmentId = searchParams.get("establishmentId");
|
const establishmentId = searchParams.get("establishmentId");
|
||||||
const role = searchParams.get("role");
|
const role = searchParams.get("role");
|
||||||
|
|
||||||
|
if (user.role !== "superadmin") {
|
||||||
|
if (establishmentId && establishmentId !== user.establishmentId) {
|
||||||
|
return forbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
if (establishmentId) where.establishmentId = establishmentId;
|
if (establishmentId) where.establishmentId = establishmentId;
|
||||||
|
else if (user.role !== "superadmin") where.establishmentId = user.establishmentId;
|
||||||
if (role) where.role = role;
|
if (role) where.role = role;
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
@@ -19,23 +30,56 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { email, password, role, establishmentId } = body;
|
const { email, password, role, establishmentId } = body;
|
||||||
const user = await prisma.user.create({
|
|
||||||
|
if (!email || !password || !role) {
|
||||||
|
return NextResponse.json({ error: "Missing email, password or role" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === "admin") {
|
||||||
|
if (role === "superadmin") return forbidden();
|
||||||
|
if (establishmentId && establishmentId !== user.establishmentId) return forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalEstablishmentId = user.role === "superadmin" ? establishmentId : user.establishmentId;
|
||||||
|
|
||||||
|
const newUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
password: await hashPassword(password),
|
password: await hashPassword(password),
|
||||||
role,
|
role,
|
||||||
establishmentId,
|
establishmentId: finalEstablishmentId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json(user, { status: 201 });
|
return NextResponse.json(newUser, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
|
|
||||||
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
if (user.role === "admin") {
|
||||||
|
if (target.role === "superadmin") return forbidden();
|
||||||
|
if (target.establishmentId !== user.establishmentId) return forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.user.delete({ where: { id } });
|
await prisma.user.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,9 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { authOptions } from "@/lib/auth-config";
|
import { authOptions } from "@/lib/auth-config";
|
||||||
|
import { generateUniqueActivationCode } from "@/lib/activation";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
function generateCode(): string {
|
|
||||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
let code = "";
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
}
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteStudent(formData: FormData) {
|
export async function deleteStudent(formData: FormData) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user) redirect("/login");
|
if (!session?.user) redirect("/login");
|
||||||
@@ -54,9 +46,10 @@ export async function generateActivationCodeAction(formData: FormData) {
|
|||||||
|
|
||||||
if (!student) return;
|
if (!student) return;
|
||||||
|
|
||||||
|
const { code, expiresAt } = await generateUniqueActivationCode();
|
||||||
await prisma.student.update({
|
await prisma.student.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { activationCode: generateCode() },
|
data: { activationCode: code, activationCodeExpiresAt: expiresAt },
|
||||||
});
|
});
|
||||||
|
|
||||||
redirect(`/dashboard/students/${id}`);
|
redirect(`/dashboard/students/${id}`);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { authOptions } from "@/lib/auth-config";
|
import { authOptions } from "@/lib/auth-config";
|
||||||
|
import { generateUniqueActivationCode } from "@/lib/activation";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -17,15 +18,6 @@ const schema = z.object({
|
|||||||
classId: z.string().min(1),
|
classId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
function generateActivationCode(): string {
|
|
||||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
let code = "";
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
}
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createStudent(formData: FormData) {
|
async function createStudent(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@@ -35,13 +27,15 @@ async function createStudent(formData: FormData) {
|
|||||||
const parsed = schema.safeParse(Object.fromEntries(formData));
|
const parsed = schema.safeParse(Object.fromEntries(formData));
|
||||||
if (!parsed.success) return;
|
if (!parsed.success) return;
|
||||||
|
|
||||||
|
const { code, expiresAt } = await generateUniqueActivationCode();
|
||||||
await prisma.student.create({
|
await prisma.student.create({
|
||||||
data: {
|
data: {
|
||||||
firstName: parsed.data.firstName,
|
firstName: parsed.data.firstName,
|
||||||
lastName: parsed.data.lastName,
|
lastName: parsed.data.lastName,
|
||||||
email: parsed.data.email,
|
email: parsed.data.email,
|
||||||
classId: parsed.data.classId,
|
classId: parsed.data.classId,
|
||||||
activationCode: generateActivationCode(),
|
activationCode: code,
|
||||||
|
activationCodeExpiresAt: expiresAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
const CODE_LENGTH = 6;
|
||||||
|
const CODE_TTL_MINUTES = 60;
|
||||||
|
|
||||||
|
export function generateActivationCode(): { code: string; expiresAt: Date } {
|
||||||
|
let code = "";
|
||||||
|
const bytes = randomBytes(CODE_LENGTH);
|
||||||
|
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||||
|
code += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
|
||||||
|
}
|
||||||
|
const expiresAt = new Date(Date.now() + CODE_TTL_MINUTES * 60 * 1000);
|
||||||
|
return { code, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateUniqueActivationCode(retries = 5): Promise<{ code: string; expiresAt: Date }> {
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
const { code, expiresAt } = generateActivationCode();
|
||||||
|
const existing = await prisma.student.findUnique({ where: { activationCode: code } });
|
||||||
|
if (!existing) return { code, expiresAt };
|
||||||
|
}
|
||||||
|
throw new Error("Failed to generate a unique activation code");
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 { WebSocketServer, WebSocket } from "ws";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import type { IncomingMessage } from "http";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
import { createEphemeralPreAuthKey, getHeadscaleUserId } from "./headscale";
|
||||||
|
|
||||||
interface NodeMessage {
|
interface NodeMessage {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -12,14 +15,68 @@ interface NodeMessage {
|
|||||||
studentName?: string;
|
studentName?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
tailscaleIp?: string;
|
tailscaleIp?: string;
|
||||||
|
token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes = new Map<string, WebSocket>();
|
const nodes = new Map<string, WebSocket>();
|
||||||
|
|
||||||
|
interface AttemptWindow {
|
||||||
|
count: number;
|
||||||
|
firstAttempt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activationAttemptsByCode = new Map<string, AttemptWindow>();
|
||||||
|
const activationAttemptsByNode = new Map<string, AttemptWindow>();
|
||||||
|
const MAX_ACTIVATION_ATTEMPTS = 5;
|
||||||
|
const ACTIVATION_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
const HEADSCALE_USER = "studioe5";
|
||||||
|
const HEADSCALE_AGENT_TAG = "tag:student-agent";
|
||||||
|
const HEADSCALE_KEY_EXPIRATION_MINUTES = 15;
|
||||||
|
|
||||||
|
let headscaleUserIdCache: string | null = null;
|
||||||
|
|
||||||
|
function recordActivationAttempt(map: Map<string, AttemptWindow>, key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const win = map.get(key);
|
||||||
|
if (!win || now - win.firstAttempt > ACTIVATION_WINDOW_MS) {
|
||||||
|
map.set(key, { count: 1, firstAttempt: now });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
win.count++;
|
||||||
|
return win.count <= MAX_ACTIVATION_ATTEMPTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActivationAttempts(code: string, nodeId: string) {
|
||||||
|
activationAttemptsByCode.delete(code);
|
||||||
|
activationAttemptsByNode.delete(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateNodeToken(): string {
|
||||||
|
return randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBearerToken(req: IncomingMessage): string | null {
|
||||||
|
const auth = req.headers.authorization || "";
|
||||||
|
const match = auth.match(/^Bearer\s+(\S+)$/i);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(ws: WebSocket, code: number, reason: string) {
|
||||||
|
try {
|
||||||
|
ws.close(code, reason);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function initWebSocketServer(wss: WebSocketServer) {
|
export function initWebSocketServer(wss: WebSocketServer) {
|
||||||
wss.on("connection", (ws: WebSocket) => {
|
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
|
||||||
let nodeId: string | null = null;
|
let nodeId: string | null = null;
|
||||||
console.log("[WS] New connection");
|
let authenticated = false;
|
||||||
|
const token = getBearerToken(req);
|
||||||
|
|
||||||
|
console.log("[WS] New connection", token ? "(token provided)" : "(no token)");
|
||||||
|
|
||||||
ws.on("message", async (raw) => {
|
ws.on("message", async (raw) => {
|
||||||
try {
|
try {
|
||||||
@@ -27,19 +84,69 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
|||||||
console.log("[WS] Received:", msg.action, "from", msg.nodeId || nodeId);
|
console.log("[WS] Received:", msg.action, "from", msg.nodeId || nodeId);
|
||||||
|
|
||||||
if (msg.action === "register" && msg.nodeId) {
|
if (msg.action === "register" && msg.nodeId) {
|
||||||
nodeId = msg.nodeId;
|
const id = msg.nodeId;
|
||||||
nodes.set(nodeId, ws);
|
const existing = await prisma.node.findUnique({ where: { id } });
|
||||||
await prisma.node.upsert({
|
|
||||||
where: { id: nodeId },
|
if (token) {
|
||||||
update: { status: "online", lastSeen: new Date() },
|
// Token supplied: it must match the stored token for this node.
|
||||||
create: { id: nodeId, status: "online", lastSeen: new Date() },
|
if (!existing || existing.token !== token) {
|
||||||
|
console.log("[WS] Invalid token for node", id);
|
||||||
|
close(ws, 1008, "invalid token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
authenticated = true;
|
||||||
|
} else if (existing && existing.token) {
|
||||||
|
// Existing node has a token but none was supplied.
|
||||||
|
console.log("[WS] Missing token for node", id);
|
||||||
|
close(ws, 1008, "missing token");
|
||||||
|
return;
|
||||||
|
} else if (existing) {
|
||||||
|
// Migration path: existing node without a token gets one on first register.
|
||||||
|
const newToken = generateNodeToken();
|
||||||
|
await prisma.node.update({
|
||||||
|
where: { id },
|
||||||
|
data: { token: newToken, status: "online", lastSeen: new Date() },
|
||||||
});
|
});
|
||||||
|
ws.send(JSON.stringify({ action: "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" }));
|
ws.send(JSON.stringify({ action: "registered" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.action === "activate" && msg.code && msg.nodeId) {
|
if (msg.action === "activate" && msg.code && msg.nodeId) {
|
||||||
nodeId = msg.nodeId;
|
const id = msg.nodeId;
|
||||||
|
nodeId = id;
|
||||||
|
|
||||||
|
if (!recordActivationAttempt(activationAttemptsByCode, msg.code) ||
|
||||||
|
!recordActivationAttempt(activationAttemptsByNode, id)) {
|
||||||
|
console.log("[WS] Too many activation attempts for code/node", msg.code, id);
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Too many attempts" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.node.findUnique({ where: { id } });
|
||||||
|
if (existing && existing.token && (!authenticated || nodeId !== id)) {
|
||||||
|
console.log("[WS] Node already activated and not authenticated:", id);
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Node already activated" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const student = await prisma.student.findUnique({
|
const student = await prisma.student.findUnique({
|
||||||
where: { activationCode: msg.code },
|
where: { activationCode: msg.code },
|
||||||
});
|
});
|
||||||
@@ -48,23 +155,97 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
|||||||
ws.send(JSON.stringify({ action: "activation_failed", error: "Invalid code" }));
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Invalid code" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!student.activationCodeExpiresAt || student.activationCodeExpiresAt < new Date()) {
|
||||||
|
console.log("[WS] Expired code:", msg.code);
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Code expired" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newToken = generateNodeToken();
|
||||||
await prisma.node.upsert({
|
await prisma.node.upsert({
|
||||||
where: { id: nodeId },
|
where: { id },
|
||||||
update: { studentId: student.id, status: "online", lastSeen: new Date() },
|
update: {
|
||||||
create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() },
|
studentId: student.id,
|
||||||
|
status: "online",
|
||||||
|
lastSeen: new Date(),
|
||||||
|
token: newToken,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id,
|
||||||
|
studentId: student.id,
|
||||||
|
status: "online",
|
||||||
|
lastSeen: new Date(),
|
||||||
|
token: newToken,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
console.log("[WS] Activated:", student.firstName, student.lastName, "on", nodeId);
|
|
||||||
|
// Invalidate the activation code so it cannot be reused.
|
||||||
|
await prisma.student.update({
|
||||||
|
where: { id: student.id },
|
||||||
|
data: { activationCode: null, activationCodeExpiresAt: null },
|
||||||
|
});
|
||||||
|
clearActivationAttempts(msg.code, id);
|
||||||
|
|
||||||
|
authenticated = true;
|
||||||
|
const previous = nodes.get(id);
|
||||||
|
if (previous && previous !== ws && previous.readyState === WebSocket.OPEN) {
|
||||||
|
console.log("[WS] Superseding previous connection for", id);
|
||||||
|
previous.close(1008, "superseded");
|
||||||
|
}
|
||||||
|
nodes.set(id, ws);
|
||||||
|
const headscaleUrl = process.env.HEADSCALE_URL;
|
||||||
|
const headscaleApiKey = process.env.HEADSCALE_API_KEY;
|
||||||
|
const reusableAuthKey = process.env.HEADSCALE_AUTH_KEY;
|
||||||
|
|
||||||
|
if (!headscaleUrl) {
|
||||||
|
console.log("[WS] HEADSCALE_URL missing");
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Server misconfiguration" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let headscaleAuthKey: string;
|
||||||
|
try {
|
||||||
|
if (headscaleApiKey) {
|
||||||
|
if (!headscaleUserIdCache) {
|
||||||
|
headscaleUserIdCache = await getHeadscaleUserId(headscaleUrl, headscaleApiKey, HEADSCALE_USER);
|
||||||
|
}
|
||||||
|
headscaleAuthKey = await createEphemeralPreAuthKey(headscaleUrl, headscaleApiKey, headscaleUserIdCache, {
|
||||||
|
expirationMinutes: HEADSCALE_KEY_EXPIRATION_MINUTES,
|
||||||
|
aclTags: [HEADSCALE_AGENT_TAG],
|
||||||
|
});
|
||||||
|
console.log("[WS] Generated ephemeral Headscale key for", id);
|
||||||
|
} else if (reusableAuthKey) {
|
||||||
|
console.log("[WS] HEADSCALE_API_KEY not set, falling back to reusable HEADSCALE_AUTH_KEY");
|
||||||
|
headscaleAuthKey = reusableAuthKey;
|
||||||
|
} else {
|
||||||
|
console.log("[WS] No Headscale key available");
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Server misconfiguration" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[WS] Failed to create ephemeral Headscale key:", err);
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Failed to create VPN key" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[WS] Activated:", student.firstName, student.lastName, "on", id);
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
action: "activated",
|
action: "activated",
|
||||||
studentId: student.id,
|
studentId: student.id,
|
||||||
studentName: `${student.firstName} ${student.lastName}`,
|
studentName: `${student.firstName} ${student.lastName}`,
|
||||||
headscaleUrl: process.env.HEADSCALE_URL,
|
headscaleUrl,
|
||||||
headscaleAuthKey: process.env.HEADSCALE_AUTH_KEY,
|
headscaleAuthKey,
|
||||||
|
token: newToken,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.action === "heartbeat" && nodeId) {
|
if (!authenticated || !nodeId) {
|
||||||
|
console.log("[WS] Unauthenticated message", msg.action, "ignored");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.action === "heartbeat") {
|
||||||
await prisma.node.upsert({
|
await prisma.node.upsert({
|
||||||
where: { id: nodeId },
|
where: { id: nodeId },
|
||||||
update: { lastSeen: new Date() },
|
update: { lastSeen: new Date() },
|
||||||
@@ -73,7 +254,7 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.action === "tailscale_ip" && nodeId && msg.tailscaleIp) {
|
if (msg.action === "tailscale_ip" && msg.tailscaleIp) {
|
||||||
await prisma.node.update({
|
await prisma.node.update({
|
||||||
where: { id: nodeId },
|
where: { id: nodeId },
|
||||||
data: { tailscaleIp: msg.tailscaleIp },
|
data: { tailscaleIp: msg.tailscaleIp },
|
||||||
@@ -90,6 +271,23 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
|||||||
return;
|
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) {
|
if (msg.action === "instance_error" && msg.instanceId) {
|
||||||
await prisma.instance.update({
|
await prisma.instance.update({
|
||||||
where: { id: msg.instanceId },
|
where: { id: msg.instanceId },
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ model Student {
|
|||||||
lastName String
|
lastName String
|
||||||
email String
|
email String
|
||||||
activationCode String? @unique
|
activationCode String? @unique
|
||||||
|
activationCodeExpiresAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
nodes Node[]
|
nodes Node[]
|
||||||
}
|
}
|
||||||
@@ -65,6 +66,7 @@ model Node {
|
|||||||
id String @id
|
id String @id
|
||||||
studentId String?
|
studentId String?
|
||||||
student Student? @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
student Student? @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||||
|
token String? @unique
|
||||||
tailscaleIp String?
|
tailscaleIp String?
|
||||||
status String @default("offline")
|
status String @default("offline")
|
||||||
lastSeen DateTime?
|
lastSeen DateTime?
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user