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
+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