feat(agent): v0.3.5 Windows inbound forwarding, UI actions, lifecycle

- Configure tailscale serve automatically for each instance on Windows userspace networking.
- Add local UI buttons: start/stop/reset/delete instances (stop/start preserve volumes).
- Clean shutdown: stop tailscaled and instances, notify server with instance_stopped.
- Restart tailscaled on agent boot using persisted state when pre-auth key is absent.
- Sync instance stopped/deleted status to dashboard (server/lib/websocket.ts).
- Security: include prior authz/scoping changes across API routes, ephemeral pre-auth keys, ACL policy, internal API key.
- Update SUIVI_VPN_ONDEMAND.md and docs/ONBOARDING_CLIENT.md.
- Bump agent version to 0.3.5.
This commit is contained in:
EduBox Dev
2026-06-25 22:59:09 +00:00
parent 331187e9b5
commit a414f03a59
33 changed files with 3075 additions and 340 deletions
+115 -71
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"log"
"net/http"
"sync"
"time"
@@ -23,6 +24,7 @@ type WSMessage struct {
TailscaleIP string `json:"tailscaleIp,omitempty"`
HeadscaleURL string `json:"headscaleUrl,omitempty"`
HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"`
Token string `json:"token,omitempty"`
}
var (
@@ -107,14 +109,20 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
setHeadscaleConfig(headscaleURL, headscaleAuthKey)
for {
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil)
token, _ := loadNodeToken(dataDir)
headers := http.Header{}
if token != "" {
headers.Set("Authorization", "Bearer "+token)
}
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, headers)
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)
log.Printf("WS connected to %s (token=%v)", serverAddr, token != "")
mainConnMu.Lock()
mainConn = conn
@@ -136,9 +144,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.
// If already activated, ensure VPN is up. The pre-auth key is
// one-time only, so on restart we rely on the persisted tailscaled
// state; tailscale up without an authkey reuses existing state.
hsURL, hsKey := getHeadscaleConfig()
if hsURL != "" && hsKey != "" {
if hsURL != "" {
go startTailscaleAndReport(dataDir, nodeID, hsURL, hsKey)
}
}
@@ -183,8 +193,23 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) {
switch msg.Action {
case "set_token":
if msg.Token != "" {
if err := saveNodeToken(dataDir, msg.Token); err != nil {
log.Printf("saveNodeToken error: %v", err)
} else {
log.Printf("Node token saved")
}
}
case "activated":
log.Printf("handleMessage: activated received, student=%s", msg.StudentName)
if msg.Token != "" {
if err := saveNodeToken(dataDir, msg.Token); err != nil {
log.Printf("saveNodeToken error: %v", err)
} else {
log.Printf("Node token saved on activation")
}
}
if msg.StudentName != "" {
act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code}
if err := saveActivation(dataDir, act); err != nil {
@@ -194,7 +219,9 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
}
}
// The server also sends Headscale credentials on activation.
// The server sends Headscale credentials on activation.
// The pre-auth key is ephemeral and must be used immediately;
// it is intentionally NOT persisted to the config file.
if msg.HeadscaleURL != "" && msg.HeadscaleAuthKey != "" {
setHeadscaleConfig(msg.HeadscaleURL, msg.HeadscaleAuthKey)
cfg, _, err := loadOrCreateConfig(dataDir)
@@ -202,11 +229,11 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
log.Printf("loadOrCreateConfig error: %v", err)
} else {
cfg.HeadscaleURL = msg.HeadscaleURL
cfg.HeadscaleAuthKey = msg.HeadscaleAuthKey
// Intentionally do not save HeadscaleAuthKey: it is one-time only.
if err := saveConfig(dataDir, cfg); err != nil {
log.Printf("saveConfig error: %v", err)
} else {
log.Printf("Saved Headscale config received from server")
log.Printf("Saved Headscale URL received from server (auth key not persisted)")
}
}
go startTailscaleAndReport(dataDir, nodeID, msg.HeadscaleURL, msg.HeadscaleAuthKey)
@@ -232,6 +259,10 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
if err != nil {
log.Printf("start_vpn error: %v", err)
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()})
broadcastUI(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
})
return
}
for {
@@ -243,6 +274,10 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
log.Printf("Sent tailscale_ip to server: %s", ip)
break
}
broadcastUI(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
})
}()
case "stop_vpn":
log.Printf("Server requested VPN stop")
@@ -256,44 +291,12 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
})
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"})
go handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.Port)
case "stop":
log.Printf("Stop instance %s", msg.InstanceID)
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
removeTailscaleServe(inst[msg.InstanceID].Port)
}
if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil {
log.Printf("dockerComposeDown error: %v", err)
}
@@ -304,45 +307,78 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
notifyUI(map[string]interface{}{"action": "instances_updated"})
case "delete":
log.Printf("Delete instance %s", msg.InstanceID)
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
removeTailscaleServe(inst[msg.InstanceID].Port)
}
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"})
go handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.Port)
default:
log.Printf("Unknown action: %s", msg.Action)
}
}
func handleStartInstance(dataDir, nodeID, instanceID, instanceType, composeConfig string, port int) {
notifyInstanceProgress := func(percent, message string) {
sendInstanceProgress(instanceID, "start", percent, message)
}
_ = upsertInstance(dataDir, &InstanceInfo{
ID: instanceID,
TemplateName: instanceType,
Port: port,
Status: "starting",
})
notifyUI(map[string]interface{}{"action": "instances_updated"})
notifyInstanceProgress("10", "Préparation de l'application...")
if err := writeCompose(dataDir, instanceID, composeConfig, port); err != nil {
log.Printf("writeCompose error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: instanceID, Error: err.Error()})
notifyInstanceProgress("0", "Erreur de préparation")
notifyUI(map[string]interface{}{"action": "instances_updated"})
return
}
notifyInstanceProgress("30", "Configuration de l'application...")
if err := dockerComposeUp(dataDir, instanceID); err != nil {
log.Printf("dockerComposeUp error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: instanceID, Error: err.Error()})
notifyInstanceProgress("0", "Erreur de démarrage")
notifyUI(map[string]interface{}{"action": "instances_updated"})
return
}
notifyInstanceProgress("60", "Application en cours de démarrage...")
ensureTailscale(dataDir, nodeID, port)
if err := setupTailscaleServe(port); err != nil {
log.Printf("setupTailscaleServe error: %v", err)
// Non-fatal: the instance may still work on Linux or if Windows
// userspace forwarding happens to function.
}
notifyInstanceProgress("80", "Connexion sécurisée active...")
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
// so the studioE5 mu-plugin can compute the public URL from the Host header.
time.Sleep(2 * time.Second)
if err := stripWordPressHardcodedURLs(dataDir, instanceID); err != nil {
log.Printf("stripWordPressHardcodedURLs error: %v", err)
}
notifyInstanceProgress("90", "Finalisation de l'installation...")
status := getInstanceStatus(dataDir, instanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: status})
sendMessage(WSMessage{Action: "instance_started", InstanceID: instanceID, Port: port})
notifyInstanceProgress("100", "Application prête")
notifyUI(map[string]interface{}{"action": "instances_updated"})
}
func ensureTailscale(dataDir, nodeID string, port int) {
hsURL, hsKey := getHeadscaleConfig()
if hsURL == "" || hsKey == "" {
@@ -356,6 +392,10 @@ func ensureTailscale(dataDir, nodeID string, port int) {
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
if err != nil {
log.Printf("ensureTailscale start error: %v", err)
broadcastUI(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
})
return
}
for {
@@ -367,4 +407,8 @@ func ensureTailscale(dataDir, nodeID string, port int) {
log.Printf("Sent tailscale_ip to server: %s", ip)
break
}
broadcastUI(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
})
}