8a9deb8ebc
- 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
371 lines
12 KiB
Go
371 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
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"`
|
|
HeadscaleURL string `json:"headscaleUrl,omitempty"`
|
|
HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"`
|
|
}
|
|
|
|
var (
|
|
mainConn *websocket.Conn
|
|
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()
|
|
if mainConn == nil {
|
|
return fmt.Errorf("not connected to server")
|
|
}
|
|
if msg.Action != "heartbeat" {
|
|
log.Printf("sendMessage: sending %+v", msg)
|
|
}
|
|
return mainConn.WriteJSON(msg)
|
|
}
|
|
|
|
// UI notifier system: broadcast activation results to all connected UI clients
|
|
type uiNotifier func(msg map[string]interface{})
|
|
|
|
var (
|
|
uiNotifiers = make(map[int]uiNotifier)
|
|
uiNotifiersMu sync.Mutex
|
|
uiNotifierID int
|
|
)
|
|
|
|
func registerUINotifier(fn uiNotifier) int {
|
|
uiNotifiersMu.Lock()
|
|
defer uiNotifiersMu.Unlock()
|
|
id := uiNotifierID
|
|
uiNotifierID++
|
|
uiNotifiers[id] = fn
|
|
log.Printf("registerUINotifier: registered ID %d (total: %d)", id, len(uiNotifiers))
|
|
return id
|
|
}
|
|
|
|
func unregisterUINotifier(id int) {
|
|
uiNotifiersMu.Lock()
|
|
defer uiNotifiersMu.Unlock()
|
|
delete(uiNotifiers, id)
|
|
log.Printf("unregisterUINotifier: removed ID %d (total: %d)", id, len(uiNotifiers))
|
|
}
|
|
|
|
func notifyUI(msg map[string]interface{}) {
|
|
uiNotifiersMu.Lock()
|
|
notifiers := make([]uiNotifier, 0, len(uiNotifiers))
|
|
for _, fn := range uiNotifiers {
|
|
notifiers = append(notifiers, fn)
|
|
}
|
|
uiNotifiersMu.Unlock()
|
|
|
|
log.Printf("notifyUI: broadcasting to %d UI clients", len(notifiers))
|
|
for _, fn := range notifiers {
|
|
go fn(msg)
|
|
}
|
|
}
|
|
|
|
func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey string) {
|
|
setHeadscaleConfig(headscaleURL, headscaleAuthKey)
|
|
|
|
for {
|
|
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil)
|
|
if err != nil {
|
|
log.Printf("WS connect error: %v, retrying in 5s...", err)
|
|
time.Sleep(5 * time.Second)
|
|
continue
|
|
}
|
|
|
|
log.Printf("WS connected to %s", serverAddr)
|
|
|
|
mainConnMu.Lock()
|
|
mainConn = conn
|
|
mainConnMu.Unlock()
|
|
|
|
// Register
|
|
if err := conn.WriteJSON(WSMessage{Action: "register", NodeID: nodeID}); err != nil {
|
|
log.Printf("WS register error: %v", err)
|
|
conn.Close()
|
|
mainConnMu.Lock()
|
|
mainConn = nil
|
|
mainConnMu.Unlock()
|
|
continue
|
|
}
|
|
|
|
// Activation flow
|
|
act, err := loadActivation(dataDir)
|
|
if err != nil || !act.Activated {
|
|
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
|
|
done := make(chan struct{})
|
|
go func() {
|
|
ticker := time.NewTicker(10 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
if err := sendMessage(WSMessage{Action: "heartbeat", NodeID: nodeID}); err != nil {
|
|
return
|
|
}
|
|
case <-done:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Read loop
|
|
for {
|
|
var msg WSMessage
|
|
if err := conn.ReadJSON(&msg); err != nil {
|
|
log.Printf("WS read error: %v", err)
|
|
break
|
|
}
|
|
log.Printf("WS received from server: action=%s", msg.Action)
|
|
handleMessage(conn, msg, dataDir, nodeID)
|
|
}
|
|
|
|
close(done)
|
|
conn.Close()
|
|
mainConnMu.Lock()
|
|
mainConn = nil
|
|
mainConnMu.Unlock()
|
|
log.Println("WS disconnected, reconnecting in 5s...")
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
}
|
|
|
|
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) {
|
|
switch msg.Action {
|
|
case "activated":
|
|
log.Printf("handleMessage: activated received, student=%s", msg.StudentName)
|
|
if msg.StudentName != "" {
|
|
act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code}
|
|
if err := saveActivation(dataDir, act); err != nil {
|
|
log.Printf("saveActivation error: %v", err)
|
|
} else {
|
|
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,
|
|
})
|
|
case "registered":
|
|
// Server acknowledged our register message; nothing to do.
|
|
return
|
|
case "start_vpn":
|
|
log.Printf("Server requested VPN start")
|
|
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, hsURL, hsKey)
|
|
if err != nil {
|
|
log.Printf("start_vpn error: %v", err)
|
|
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()})
|
|
return
|
|
}
|
|
for {
|
|
if err := sendMessage(WSMessage{Action: "tailscale_ip", NodeID: nodeID, TailscaleIP: ip}); err != nil {
|
|
log.Printf("Waiting for WebSocket to send tailscale_ip...")
|
|
time.Sleep(1 * time.Second)
|
|
continue
|
|
}
|
|
log.Printf("Sent tailscale_ip to server: %s", ip)
|
|
break
|
|
}
|
|
}()
|
|
case "stop_vpn":
|
|
log.Printf("Server requested VPN stop")
|
|
stopTailscale()
|
|
sendMessage(WSMessage{Action: "vpn_stopped", NodeID: nodeID})
|
|
case "activation_failed":
|
|
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
|
|
notifyUI(map[string]interface{}{
|
|
"action": "activation_failed",
|
|
"error": msg.Error,
|
|
})
|
|
case "start":
|
|
log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port)
|
|
if err := upsertInstance(dataDir, &InstanceInfo{
|
|
ID: msg.InstanceID,
|
|
TemplateName: msg.Type,
|
|
Port: msg.Port,
|
|
Status: "starting",
|
|
}); err != nil {
|
|
log.Printf("upsertInstance error: %v", err)
|
|
}
|
|
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); err != nil {
|
|
log.Printf("writeCompose error: %v", err)
|
|
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
|
|
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
|
|
return
|
|
}
|
|
if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil {
|
|
log.Printf("dockerComposeUp error: %v", err)
|
|
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
|
|
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
|
|
return
|
|
}
|
|
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
|
|
// so the studioE5 mu-plugin can compute the public URL from the Host header.
|
|
go func() {
|
|
// Give the container a moment to be ready before touching wp-config.php
|
|
time.Sleep(2 * time.Second)
|
|
if err := stripWordPressHardcodedURLs(dataDir, msg.InstanceID); err != nil {
|
|
log.Printf("stripWordPressHardcodedURLs error: %v", err)
|
|
}
|
|
}()
|
|
// Ensure Tailscale is running so the server can reach the node
|
|
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})
|
|
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
|
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
|
case "stop":
|
|
log.Printf("Stop instance %s", msg.InstanceID)
|
|
if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil {
|
|
log.Printf("dockerComposeDown error: %v", err)
|
|
}
|
|
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
|
|
inst[msg.InstanceID].Status = "stopped"
|
|
_ = saveInstances(dataDir, inst)
|
|
}
|
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
|
case "delete":
|
|
log.Printf("Delete instance %s", msg.InstanceID)
|
|
dockerComposeRm(dataDir, msg.InstanceID)
|
|
removeInstance(dataDir, msg.InstanceID)
|
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
|
case "reset":
|
|
log.Printf("Reset instance %s", msg.InstanceID)
|
|
dockerComposeRm(dataDir, msg.InstanceID)
|
|
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); err != nil {
|
|
log.Printf("writeCompose error: %v", err)
|
|
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
|
|
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
|
|
return
|
|
}
|
|
if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil {
|
|
log.Printf("dockerComposeUp error: %v", err)
|
|
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
|
|
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
|
|
return
|
|
}
|
|
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
|
|
// so the studioE5 mu-plugin can compute the public URL from the Host header.
|
|
go func() {
|
|
// Give the container a moment to be ready before touching wp-config.php
|
|
time.Sleep(2 * time.Second)
|
|
if err := stripWordPressHardcodedURLs(dataDir, msg.InstanceID); err != nil {
|
|
log.Printf("stripWordPressHardcodedURLs error: %v", err)
|
|
}
|
|
}()
|
|
// Ensure Tailscale is running so the server can reach the node
|
|
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})
|
|
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
|
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
|
default:
|
|
log.Printf("Unknown action: %s", msg.Action)
|
|
}
|
|
}
|
|
|
|
func ensureTailscale(dataDir, nodeID string, port int) {
|
|
hsURL, hsKey := getHeadscaleConfig()
|
|
if hsURL == "" || hsKey == "" {
|
|
log.Printf("Cannot ensure Tailscale: headscale config missing")
|
|
return
|
|
}
|
|
if isTailscaleRunning() {
|
|
return
|
|
}
|
|
log.Printf("Tailscale not running, starting it for instance port %d", port)
|
|
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
|
|
if err != nil {
|
|
log.Printf("ensureTailscale start error: %v", err)
|
|
return
|
|
}
|
|
for {
|
|
if err := sendMessage(WSMessage{Action: "tailscale_ip", NodeID: nodeID, TailscaleIP: ip}); err != nil {
|
|
log.Printf("Waiting for WebSocket to send tailscale_ip...")
|
|
time.Sleep(1 * time.Second)
|
|
continue
|
|
}
|
|
log.Printf("Sent tailscale_ip to server: %s", ip)
|
|
break
|
|
}
|
|
}
|