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
+36 -17
View File
@@ -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 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é
**Rate limit Lets 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 dactivation 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 lagent en mode console :
```powershell
.\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data
```
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` :
```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 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")
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}")
+51 -6
View File
@@ -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)
}
+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)")
)
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)
+71 -20
View File
@@ -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
+2
View File
@@ -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
+7 -1
View File
@@ -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;
}