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:
+36
-17
@@ -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).
|
||||
|
||||
+7
-7
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user