From 8a9deb8ebc9fbb073ffb8425695ba8a0531fdedd Mon Sep 17 00:00:00 2001 From: EduBox Dev Date: Tue, 23 Jun 2026 10:30:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(agent):=20activation=20z=C3=A9ro-config=20?= =?UTF-8?q?=E2=80=93=20config=20Headscale=20envoy=C3=A9e=20par=20le=20serv?= =?UTF-8?q?eur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agent: URL serveur par défaut, node_id auto-généré, config Headscale vide par défaut - Serveur: lors de l’activation, renvoie headscaleUrl + headscaleAuthKey - Agent: sauvegarde la config reçue et démarre Tailscale automatiquement - docker-compose.yml: passe HEADSCALE_URL et HEADSCALE_AUTH_KEY au service server - Mise à jour du suivi avec le flow zéro-config --- SUIVI_VPN_ONDEMAND.md | 53 ++++++++++++++++-------- agent/build.sh | 14 +++---- agent/config.go | 57 +++++++++++++++++++++++--- agent/main.go | 23 +---------- agent/websocket.go | 91 ++++++++++++++++++++++++++++++++--------- docker-compose.yml | 2 + server/lib/websocket.ts | 8 +++- 7 files changed, 175 insertions(+), 73 deletions(-) diff --git a/SUIVI_VPN_ONDEMAND.md b/SUIVI_VPN_ONDEMAND.md index f9a8160..478f6f1 100644 --- a/SUIVI_VPN_ONDEMAND.md +++ b/SUIVI_VPN_ONDEMAND.md @@ -23,6 +23,11 @@ 5. **Instance WordPress démarrée avec succès** - Le resolver a renvoyé une 302 WordPress via `http://resolver:2020/`. +6. **Activation zéro-config de l’agent (modèle commercialisable)** + - L’agent démarre sans `headscale_url` ni `headscale_auth_key`. + - L’utilisateur entre seulement un code d’activation. + - Le serveur envoie la config Headscale, l’agent la sauvegarde et démarre le VPN automatiquement. + ## ✅ Blocage levé **Rate limit Let’s Encrypt pour `edudeploy.com` est levé.** @@ -181,27 +186,41 @@ cd /opt/studioe5-client-a/agent Le `build.sh` génère automatiquement `studioE5-agent-v0.3.0-windows.zip` et copie les binaires versionnés dans `server/public/`. -### Configuration minimale Windows +### Flow d’activation zéro-config (modèle commercialisable) -1. Extraire `studioE5-agent-v0.3.0-windows.zip` dans `C:\studioE5-agent`. -2. Créer le dossier `C:\studioE5-agent\data`. -3. Créer `C:\studioE5-agent\data\studioE5-config.json` : - ```json - { - "server": "wss://studioe5.edudeploy.com/api/websocket", - "headscale_url": "https://headscale.studioe5.edudeploy.com", - "headscale_auth_key": "CLE_PREAUTH_ICI", - "node_id": "IDENTIFIANT_DU_POSTE", - "data_dir": "C:\\studioE5-agent\\data" - } - ``` -4. Lancer l’agent en mode console : - ```powershell - .\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data - ``` +L’élève/employé n’a **aucune configuration technique** à saisir : + +1. **Télécharger** l’agent Windows (`studioE5-agent-v0.3.0-windows.zip`). +2. **Extraire** et **lancer** `studioE5-agent.exe`. +3. **Entrer le code d’activation** à 6 caractères fourni par l’établissement (affiché dans l’UI locale `http://localhost:7070`). +4. L’agent contacte le serveur, le serveur vérifie le code et renvoie **automatiquement** : + - l’identité de l’élève (`studentName`) + - l’URL Headscale + - la clé pré-auth Headscale +5. L’agent sauvegarde ces informations localement et **démarre automatiquement le VPN**. +6. L’agent est alors visible dans le dashboard et peut recevoir des instances. + +### Configuration manuelle (mode debug / admin) + +Si besoin, on peut toujours forcer une config via `data/studioE5-config.json` : + +```json +{ + "server": "wss://studioe5.edudeploy.com/api/websocket", + "headscale_url": "https://headscale.studioe5.edudeploy.com", + "headscale_auth_key": "CLE_PREAUTH_ICI", + "node_id": "IDENTIFIANT_DU_POSTE", + "data_dir": "C:\\studioE5-agent\\data" +} +``` > ⚠️ `headscale_auth_key` doit être une clé pré-auth réutilisable valide pour le tailnet studioe5. Ne jamais commiter cette clé. +Lancement : +```powershell +.\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data +``` + ## 📋 Prochaines étapes à faire - [x] ~~Attendre la fin du rate limit Let’s Encrypt~~ (levé le 2026-06-23). diff --git a/agent/build.sh b/agent/build.sh index 8daeb30..c07a869 100755 --- a/agent/build.sh +++ b/agent/build.sh @@ -43,23 +43,23 @@ with zipfile.ZipFile("${ZIP_NAME}", 'w', zipfile.ZIP_DEFLATED) as zf: zf.write("${BIN_NAME}.exe", "${BIN_NAME}.exe") for f in ["tailscale.exe", "tailscaled.exe", "wintun.dll"]: zf.write(f"tailscale-bin/windows/{f}", f"tailscale-bin/windows/{f}") - readme = """${APP_NAME} Agent - Windows + readme = r"""${APP_NAME} Agent - Windows ======================= -1. Extract this archive to a folder (e.g. C:\\${APP_NAME}-agent). -2. Create a data folder (e.g. C:\\${APP_NAME}-agent\\data). -3. Create the config file data\\${BIN_NAME}-config.json: +1. Extract this archive to a folder (e.g. C:\${APP_NAME}-agent). +2. Create a data folder (e.g. C:\${APP_NAME}-agent\data). +3. Create the config file data\${BIN_NAME}-config.json: { "server": "wss://studioe5.edudeploy.com/api/websocket", "headscale_url": "https://headscale.studioe5.edudeploy.com", "headscale_auth_key": "YOUR_PREAUTH_KEY", "node_id": "YOUR_NODE_ID", - "data_dir": "C:\\\\\\\\${APP_NAME}-agent\\\\\\\\data" + "data_dir": "C:\\${APP_NAME}-agent\\data" } 4. Run the agent: - ${BIN_NAME}.exe -no-tray -data-dir C:\\${APP_NAME}-agent\\data + ${BIN_NAME}.exe -no-tray -data-dir C:\${APP_NAME}-agent\data Tailscale binaries (tailscale.exe, tailscaled.exe, wintun.dll) are bundled -in tailscale-bin\\windows\\ and used automatically by the agent. +in tailscale-bin\windows\ and used automatically by the agent. """ zf.writestr("README-Windows.txt", readme) print(f" ${ZIP_NAME}") diff --git a/agent/config.go b/agent/config.go index fd52327..9384070 100644 --- a/agent/config.go +++ b/agent/config.go @@ -1,7 +1,10 @@ package main import ( + "crypto/rand" + "encoding/hex" "encoding/json" + "fmt" "os" "path/filepath" ) @@ -17,17 +20,55 @@ type AgentConfig struct { const configFileName = "studioE5-config.json" +// defaultServerURL is the production WebSocket endpoint baked into the agent. +// It can be overridden by the config file for self-hosted or test setups. +const defaultServerURL = "wss://studioe5.edudeploy.com/api/websocket" + +// uniqueNodeID returns a stable-ish unique identifier for this machine. +// It combines the hostname with a short random suffix so every install is distinct. +func uniqueNodeID() string { + h, err := os.Hostname() + if err != nil || h == "" { + h = "node" + } + b := make([]byte, 4) + if _, err := rand.Read(b); err == nil { + return fmt.Sprintf("%s-%s", h, hex.EncodeToString(b)) + } + return fmt.Sprintf("%s-%d", h, os.Getpid()) +} + // defaultConfig returns sensible defaults for a first run. +// The user only needs to provide an activation code; Headscale credentials are +// delivered by the server during activation. func defaultConfig(dataDir string) *AgentConfig { return &AgentConfig{ - Server: "ws://localhost:3001", + Server: defaultServerURL, HeadscaleURL: "", HeadscaleAuthKey: "", - NodeID: defaultNodeID(), + NodeID: uniqueNodeID(), DataDir: dataDir, } } +// mergeWithDefaults fills missing fields from disk with sensible defaults. +func mergeWithDefaults(cfg *AgentConfig, dataDir string) *AgentConfig { + defaults := defaultConfig(dataDir) + if cfg == nil { + return defaults + } + if cfg.Server == "" { + cfg.Server = defaults.Server + } + if cfg.NodeID == "" { + cfg.NodeID = defaults.NodeID + } + if cfg.DataDir == "" { + cfg.DataDir = defaults.DataDir + } + return cfg +} + // configPath returns the absolute path to the config file. func configPath(dataDir string) string { return filepath.Join(dataDir, configFileName) @@ -43,11 +84,12 @@ func loadOrCreateConfig(dataDir string) (*AgentConfig, bool, error) { if err != nil { return nil, false, err } - var cfg AgentConfig - if err := json.Unmarshal(data, &cfg); err != nil { + cfg := &AgentConfig{} + if err := json.Unmarshal(data, cfg); err != nil { return nil, false, err } - return &cfg, false, nil + cfg = mergeWithDefaults(cfg, dataDir) + return cfg, false, nil } cfg := defaultConfig(dataDir) @@ -60,9 +102,12 @@ func loadOrCreateConfig(dataDir string) (*AgentConfig, bool, error) { // saveConfig writes the config file to disk. func saveConfig(dataDir string, cfg *AgentConfig) error { cp := configPath(dataDir) + if err := os.MkdirAll(dataDir, 0755); err != nil { + return err + } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } - return os.WriteFile(cp, data, 0644) + return os.WriteFile(cp, data, 0600) } diff --git a/agent/main.go b/agent/main.go index 2973666..980be6d 100644 --- a/agent/main.go +++ b/agent/main.go @@ -23,14 +23,6 @@ var ( noTray = flag.Bool("no-tray", false, "Désactiver l'icône dans la barre système (mode console)") ) -func defaultNodeID() string { - h, err := os.Hostname() - if err != nil { - return "unknown" - } - return h -} - func main() { flag.Parse() @@ -50,21 +42,11 @@ func main() { log.Fatalf("Cannot create data-dir: %v", err) } - cfg, created, err := loadOrCreateConfig(*dataDir) + cfg, _, err := loadOrCreateConfig(*dataDir) if err != nil { log.Fatalf("Cannot load config: %v", err) } - if cfg.Server == "" { - cfg.Server = "ws://localhost:3001" - } - if cfg.NodeID == "" { - cfg.NodeID = defaultNodeID() - } - if cfg.DataDir == "" { - cfg.DataDir = *dataDir - } - if err := saveConfig(*dataDir, cfg); err != nil { log.Fatalf("Cannot save config: %v", err) } @@ -73,9 +55,6 @@ func main() { if *uiEnabled { go startUI(*dataDir, cfg.NodeID, cfg.Server) - if created { - go openBrowser(uiURL + "#settings") - } } go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey) diff --git a/agent/websocket.go b/agent/websocket.go index c6c5a06..a60d85f 100644 --- a/agent/websocket.go +++ b/agent/websocket.go @@ -10,17 +10,19 @@ import ( ) type WSMessage struct { - Action string `json:"action"` - NodeID string `json:"nodeId,omitempty"` - Code string `json:"code,omitempty"` - InstanceID string `json:"instanceId,omitempty"` - Type string `json:"type,omitempty"` - Port int `json:"port,omitempty"` - ComposeConfig string `json:"composeConfig,omitempty"` - StudentId string `json:"studentId,omitempty"` - StudentName string `json:"studentName,omitempty"` - Error string `json:"error,omitempty"` - TailscaleIP string `json:"tailscaleIp,omitempty"` + Action string `json:"action"` + NodeID string `json:"nodeId,omitempty"` + Code string `json:"code,omitempty"` + InstanceID string `json:"instanceId,omitempty"` + Type string `json:"type,omitempty"` + Port int `json:"port,omitempty"` + ComposeConfig string `json:"composeConfig,omitempty"` + StudentId string `json:"studentId,omitempty"` + StudentName string `json:"studentName,omitempty"` + Error string `json:"error,omitempty"` + TailscaleIP string `json:"tailscaleIp,omitempty"` + HeadscaleURL string `json:"headscaleUrl,omitempty"` + HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"` } var ( @@ -28,6 +30,27 @@ var ( mainConnMu sync.Mutex ) +// headscale config received from the server during activation. +// These are mutable because activation may happen after the agent starts. +var ( + currentHeadscaleURL string + currentHeadscaleAuthKey string + headscaleConfigMu sync.Mutex +) + +func setHeadscaleConfig(url, authKey string) { + headscaleConfigMu.Lock() + currentHeadscaleURL = url + currentHeadscaleAuthKey = authKey + headscaleConfigMu.Unlock() +} + +func getHeadscaleConfig() (string, string) { + headscaleConfigMu.Lock() + defer headscaleConfigMu.Unlock() + return currentHeadscaleURL, currentHeadscaleAuthKey +} + func sendMessage(msg WSMessage) error { mainConnMu.Lock() defer mainConnMu.Unlock() @@ -81,6 +104,8 @@ func notifyUI(msg map[string]interface{}) { } func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey string) { + setHeadscaleConfig(headscaleURL, headscaleAuthKey) + for { conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil) if err != nil { @@ -111,6 +136,11 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey log.Println("Waiting for activation...") } else { log.Printf("Already activated as %s", act.StudentName) + // If already activated and we have credentials, ensure VPN is up. + hsURL, hsKey := getHeadscaleConfig() + if hsURL != "" && hsKey != "" { + go startTailscaleAndReport(dataDir, nodeID, hsURL, hsKey) + } } // Heartbeat goroutine @@ -138,7 +168,7 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey break } log.Printf("WS received from server: action=%s", msg.Action) - handleMessage(conn, msg, dataDir, nodeID, headscaleURL, headscaleAuthKey) + handleMessage(conn, msg, dataDir, nodeID) } close(done) @@ -151,7 +181,7 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey } } -func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headscaleURL, headscaleAuthKey string) { +func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) { switch msg.Action { case "activated": log.Printf("handleMessage: activated received, student=%s", msg.StudentName) @@ -163,6 +193,25 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headsca log.Printf("Activated as %s", act.StudentName) } } + + // The server also sends Headscale credentials on activation. + if msg.HeadscaleURL != "" && msg.HeadscaleAuthKey != "" { + setHeadscaleConfig(msg.HeadscaleURL, msg.HeadscaleAuthKey) + cfg, _, err := loadOrCreateConfig(dataDir) + if err != nil { + log.Printf("loadOrCreateConfig error: %v", err) + } else { + cfg.HeadscaleURL = msg.HeadscaleURL + cfg.HeadscaleAuthKey = msg.HeadscaleAuthKey + if err := saveConfig(dataDir, cfg); err != nil { + log.Printf("saveConfig error: %v", err) + } else { + log.Printf("Saved Headscale config received from server") + } + } + go startTailscaleAndReport(dataDir, nodeID, msg.HeadscaleURL, msg.HeadscaleAuthKey) + } + notifyUI(map[string]interface{}{ "action": "activated", "studentName": msg.StudentName, @@ -172,13 +221,14 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headsca return case "start_vpn": log.Printf("Server requested VPN start") - if headscaleURL == "" || headscaleAuthKey == "" { + hsURL, hsKey := getHeadscaleConfig() + if hsURL == "" || hsKey == "" { log.Printf("Cannot start VPN: headscale config missing") sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"}) return } go func() { - ip, err := startTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey) + ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey) if err != nil { log.Printf("start_vpn error: %v", err) sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()}) @@ -236,7 +286,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headsca } }() // Ensure Tailscale is running so the server can reach the node - go ensureTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey, msg.Port) + 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}) @@ -282,7 +332,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headsca } }() // Ensure Tailscale is running so the server can reach the node - go ensureTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey, msg.Port) + 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}) @@ -293,8 +343,9 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headsca } } -func ensureTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey string, port int) { - if headscaleURL == "" || headscaleAuthKey == "" { +func ensureTailscale(dataDir, nodeID string, port int) { + hsURL, hsKey := getHeadscaleConfig() + if hsURL == "" || hsKey == "" { log.Printf("Cannot ensure Tailscale: headscale config missing") return } @@ -302,7 +353,7 @@ func ensureTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey string, por return } log.Printf("Tailscale not running, starting it for instance port %d", port) - ip, err := startTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey) + ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey) if err != nil { log.Printf("ensureTailscale start error: %v", err) return diff --git a/docker-compose.yml b/docker-compose.yml index ac26fbb..62e1d4e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,8 @@ services: SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL} SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD} MAIN_DOMAIN: ${MAIN_DOMAIN} + HEADSCALE_URL: ${HEADSCALE_URL} + HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY} depends_on: postgres: condition: service_healthy diff --git a/server/lib/websocket.ts b/server/lib/websocket.ts index 26a4d50..324fcdb 100644 --- a/server/lib/websocket.ts +++ b/server/lib/websocket.ts @@ -54,7 +54,13 @@ export function initWebSocketServer(wss: WebSocketServer) { create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() }, }); console.log("[WS] Activated:", student.firstName, student.lastName, "on", nodeId); - ws.send(JSON.stringify({ action: "activated", studentId: student.id, studentName: `${student.firstName} ${student.lastName}` })); + ws.send(JSON.stringify({ + action: "activated", + studentId: student.id, + studentName: `${student.firstName} ${student.lastName}`, + headscaleUrl: process.env.HEADSCALE_URL, + headscaleAuthKey: process.env.HEADSCALE_AUTH_KEY, + })); return; }