feat(agent): activation zéro-config – config Headscale envoyée par le serveur

- 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
This commit is contained in:
EduBox Dev
2026-06-23 10:30:19 +00:00
parent df77caf64a
commit 8a9deb8ebc
7 changed files with 175 additions and 73 deletions
+26 -7
View File
@@ -23,6 +23,11 @@
5. **Instance WordPress démarrée avec succès** 5. **Instance WordPress démarrée avec succès**
- Le resolver a renvoyé une 302 WordPress via `http://resolver:2020/`. - Le resolver a renvoyé une 302 WordPress via `http://resolver:2020/`.
6. **Activation zéro-config de lagent (modèle commercialisable)**
- Lagent démarre sans `headscale_url` ni `headscale_auth_key`.
- Lutilisateur entre seulement un code dactivation.
- Le serveur envoie la config Headscale, lagent la sauvegarde et démarre le VPN automatiquement.
## ✅ Blocage levé ## ✅ Blocage levé
**Rate limit Lets Encrypt pour `edudeploy.com` est levé.** **Rate limit Lets Encrypt pour `edudeploy.com` est levé.**
@@ -181,11 +186,24 @@ 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/`. 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 dactivation zéro-config (modèle commercialisable)
L’élève/employé na **aucune configuration technique** à saisir :
1. **Télécharger** lagent Windows (`studioE5-agent-v0.3.0-windows.zip`).
2. **Extraire** et **lancer** `studioE5-agent.exe`.
3. **Entrer le code dactivation** à 6 caractères fourni par l’établissement (affiché dans lUI locale `http://localhost:7070`).
4. Lagent contacte le serveur, le serveur vérifie le code et renvoie **automatiquement** :
- lidentité de l’élève (`studentName`)
- lURL Headscale
- la clé pré-auth Headscale
5. Lagent sauvegarde ces informations localement et **démarre automatiquement le VPN**.
6. Lagent 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` :
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 ```json
{ {
"server": "wss://studioe5.edudeploy.com/api/websocket", "server": "wss://studioe5.edudeploy.com/api/websocket",
@@ -195,13 +213,14 @@ Le `build.sh` génère automatiquement `studioE5-agent-v0.3.0-windows.zip` et co
"data_dir": "C:\\studioE5-agent\\data" "data_dir": "C:\\studioE5-agent\\data"
} }
``` ```
4. Lancer lagent en mode console :
> ⚠️ `headscale_auth_key` doit être une clé pré-auth réutilisable valide pour le tailnet studioe5. Ne jamais commiter cette clé.
Lancement :
```powershell ```powershell
.\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data .\studioE5-agent.exe -no-tray -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é.
## 📋 Prochaines étapes à faire ## 📋 Prochaines étapes à faire
- [x] ~~Attendre la fin du rate limit Lets Encrypt~~ (levé le 2026-06-23). - [x] ~~Attendre la fin du rate limit Lets Encrypt~~ (levé le 2026-06-23).
+7 -7
View File
@@ -43,23 +43,23 @@ with zipfile.ZipFile("${ZIP_NAME}", 'w', zipfile.ZIP_DEFLATED) as zf:
zf.write("${BIN_NAME}.exe", "${BIN_NAME}.exe") zf.write("${BIN_NAME}.exe", "${BIN_NAME}.exe")
for f in ["tailscale.exe", "tailscaled.exe", "wintun.dll"]: for f in ["tailscale.exe", "tailscaled.exe", "wintun.dll"]:
zf.write(f"tailscale-bin/windows/{f}", f"tailscale-bin/windows/{f}") 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). 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). 2. Create a data folder (e.g. C:\${APP_NAME}-agent\data).
3. Create the config file data\\${BIN_NAME}-config.json: 3. Create the config file data\${BIN_NAME}-config.json:
{ {
"server": "wss://studioe5.edudeploy.com/api/websocket", "server": "wss://studioe5.edudeploy.com/api/websocket",
"headscale_url": "https://headscale.studioe5.edudeploy.com", "headscale_url": "https://headscale.studioe5.edudeploy.com",
"headscale_auth_key": "YOUR_PREAUTH_KEY", "headscale_auth_key": "YOUR_PREAUTH_KEY",
"node_id": "YOUR_NODE_ID", "node_id": "YOUR_NODE_ID",
"data_dir": "C:\\\\\\\\${APP_NAME}-agent\\\\\\\\data" "data_dir": "C:\\${APP_NAME}-agent\\data"
} }
4. Run the agent: 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 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) zf.writestr("README-Windows.txt", readme)
print(f" ${ZIP_NAME}") print(f" ${ZIP_NAME}")
+51 -6
View File
@@ -1,7 +1,10 @@
package main package main
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
) )
@@ -17,17 +20,55 @@ type AgentConfig struct {
const configFileName = "studioE5-config.json" 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. // 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 { func defaultConfig(dataDir string) *AgentConfig {
return &AgentConfig{ return &AgentConfig{
Server: "ws://localhost:3001", Server: defaultServerURL,
HeadscaleURL: "", HeadscaleURL: "",
HeadscaleAuthKey: "", HeadscaleAuthKey: "",
NodeID: defaultNodeID(), NodeID: uniqueNodeID(),
DataDir: dataDir, 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. // configPath returns the absolute path to the config file.
func configPath(dataDir string) string { func configPath(dataDir string) string {
return filepath.Join(dataDir, configFileName) return filepath.Join(dataDir, configFileName)
@@ -43,11 +84,12 @@ func loadOrCreateConfig(dataDir string) (*AgentConfig, bool, error) {
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
var cfg AgentConfig cfg := &AgentConfig{}
if err := json.Unmarshal(data, &cfg); err != nil { if err := json.Unmarshal(data, cfg); err != nil {
return nil, false, err return nil, false, err
} }
return &cfg, false, nil cfg = mergeWithDefaults(cfg, dataDir)
return cfg, false, nil
} }
cfg := defaultConfig(dataDir) cfg := defaultConfig(dataDir)
@@ -60,9 +102,12 @@ func loadOrCreateConfig(dataDir string) (*AgentConfig, bool, error) {
// saveConfig writes the config file to disk. // saveConfig writes the config file to disk.
func saveConfig(dataDir string, cfg *AgentConfig) error { func saveConfig(dataDir string, cfg *AgentConfig) error {
cp := configPath(dataDir) cp := configPath(dataDir)
if err := os.MkdirAll(dataDir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ") data, err := json.MarshalIndent(cfg, "", " ")
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(cp, data, 0644) return os.WriteFile(cp, data, 0600)
} }
+1 -22
View File
@@ -23,14 +23,6 @@ var (
noTray = flag.Bool("no-tray", false, "Désactiver l'icône dans la barre système (mode console)") 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() { func main() {
flag.Parse() flag.Parse()
@@ -50,21 +42,11 @@ func main() {
log.Fatalf("Cannot create data-dir: %v", err) log.Fatalf("Cannot create data-dir: %v", err)
} }
cfg, created, err := loadOrCreateConfig(*dataDir) cfg, _, err := loadOrCreateConfig(*dataDir)
if err != nil { if err != nil {
log.Fatalf("Cannot load config: %v", err) 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 { if err := saveConfig(*dataDir, cfg); err != nil {
log.Fatalf("Cannot save config: %v", err) log.Fatalf("Cannot save config: %v", err)
} }
@@ -73,9 +55,6 @@ func main() {
if *uiEnabled { if *uiEnabled {
go startUI(*dataDir, cfg.NodeID, cfg.Server) go startUI(*dataDir, cfg.NodeID, cfg.Server)
if created {
go openBrowser(uiURL + "#settings")
}
} }
go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey) go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey)
+60 -9
View File
@@ -21,6 +21,8 @@ type WSMessage struct {
StudentName string `json:"studentName,omitempty"` StudentName string `json:"studentName,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
TailscaleIP string `json:"tailscaleIp,omitempty"` TailscaleIP string `json:"tailscaleIp,omitempty"`
HeadscaleURL string `json:"headscaleUrl,omitempty"`
HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"`
} }
var ( var (
@@ -28,6 +30,27 @@ var (
mainConnMu sync.Mutex 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 { func sendMessage(msg WSMessage) error {
mainConnMu.Lock() mainConnMu.Lock()
defer mainConnMu.Unlock() defer mainConnMu.Unlock()
@@ -81,6 +104,8 @@ func notifyUI(msg map[string]interface{}) {
} }
func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey string) { func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey string) {
setHeadscaleConfig(headscaleURL, headscaleAuthKey)
for { for {
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil) conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil)
if err != nil { if err != nil {
@@ -111,6 +136,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.
hsURL, hsKey := getHeadscaleConfig()
if hsURL != "" && hsKey != "" {
go startTailscaleAndReport(dataDir, nodeID, hsURL, hsKey)
}
} }
// Heartbeat goroutine // Heartbeat goroutine
@@ -138,7 +168,7 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
break break
} }
log.Printf("WS received from server: action=%s", msg.Action) log.Printf("WS received from server: action=%s", msg.Action)
handleMessage(conn, msg, dataDir, nodeID, headscaleURL, headscaleAuthKey) handleMessage(conn, msg, dataDir, nodeID)
} }
close(done) 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 { switch msg.Action {
case "activated": case "activated":
log.Printf("handleMessage: activated received, student=%s", msg.StudentName) 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) 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{}{ notifyUI(map[string]interface{}{
"action": "activated", "action": "activated",
"studentName": msg.StudentName, "studentName": msg.StudentName,
@@ -172,13 +221,14 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headsca
return return
case "start_vpn": case "start_vpn":
log.Printf("Server requested VPN start") log.Printf("Server requested VPN start")
if headscaleURL == "" || headscaleAuthKey == "" { hsURL, hsKey := getHeadscaleConfig()
if hsURL == "" || hsKey == "" {
log.Printf("Cannot start VPN: headscale config missing") log.Printf("Cannot start VPN: headscale config missing")
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"}) sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"})
return return
} }
go func() { go func() {
ip, err := startTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey) ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
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()})
@@ -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 // 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) status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status}) _ = 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 // 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) status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status}) _ = 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) { func ensureTailscale(dataDir, nodeID string, port int) {
if headscaleURL == "" || headscaleAuthKey == "" { hsURL, hsKey := getHeadscaleConfig()
if hsURL == "" || hsKey == "" {
log.Printf("Cannot ensure Tailscale: headscale config missing") log.Printf("Cannot ensure Tailscale: headscale config missing")
return return
} }
@@ -302,7 +353,7 @@ func ensureTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey string, por
return return
} }
log.Printf("Tailscale not running, starting it for instance port %d", port) 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 { if err != nil {
log.Printf("ensureTailscale start error: %v", err) log.Printf("ensureTailscale start error: %v", err)
return return
+2
View File
@@ -32,6 +32,8 @@ services:
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL} SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL}
SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD} SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD}
MAIN_DOMAIN: ${MAIN_DOMAIN} MAIN_DOMAIN: ${MAIN_DOMAIN}
HEADSCALE_URL: ${HEADSCALE_URL}
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
+7 -1
View File
@@ -54,7 +54,13 @@ export function initWebSocketServer(wss: WebSocketServer) {
create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() }, create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() },
}); });
console.log("[WS] Activated:", student.firstName, student.lastName, "on", nodeId); 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; return;
} }