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
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash
set -e
VERSION="0.3.3"
VERSION="0.3.5"
APP_NAME="studioE5"
BIN_NAME="studioE5-agent"
LDFLAGS="-X main.version=${VERSION}"
+14
View File
@@ -62,6 +62,20 @@ func dockerComposeDown(dataDir, instanceID string) error {
return cmd.Run()
}
func dockerComposeStop(dataDir, instanceID string) error {
dir := instanceDir(dataDir, instanceID)
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "stop")
configureEngineCmd(cmd, dir)
return cmd.Run()
}
func dockerComposeStart(dataDir, instanceID string) error {
dir := instanceDir(dataDir, instanceID)
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "start")
configureEngineCmd(cmd, dir)
return cmd.Run()
}
func dockerComposeRm(dataDir, instanceID string) error {
dir := instanceDir(dataDir, instanceID)
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v")
+64 -1
View File
@@ -2,10 +2,14 @@ package main
import (
"flag"
"io"
"log"
"os"
"os/signal"
"path/filepath"
"runtime"
"sync"
"syscall"
"time"
)
@@ -42,7 +46,7 @@ func main() {
// Redirect agent logs to a file so the console can be hidden on Windows.
agentLogPath := filepath.Join(*dataDir, "agent.log")
if agentLogFile, err := os.OpenFile(agentLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
log.SetOutput(agentLogFile)
log.SetOutput(io.MultiWriter(agentLogFile, uiLogWriter{}))
} else {
log.Printf("Cannot open agent log file %s: %v", agentLogPath, err)
}
@@ -65,9 +69,48 @@ func main() {
go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey)
shutdownCh := make(chan struct{})
// Capture Ctrl+C / SIGTERM so a console window close or service stop
// triggers the same cleanup path as the tray "Quit" menu.
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh
log.Println("Shutdown signal received")
close(shutdownCh)
}()
var cleanupWg sync.WaitGroup
cleanupWg.Add(1)
go func() {
defer cleanupWg.Done()
<-shutdownCh
log.Println("Cleaning up before exit...")
// Stop Tailscale so the next agent start does not conflict on the
// same socket/state.
stopTailscale()
// Stop any running instances so containers are not left behind, but keep
// their volumes intact so data survives the next agent start.
if inst, err := loadInstances(*dataDir); err == nil {
for id, info := range inst {
if info.Status == "running" {
log.Printf("Stopping instance %s", id)
_ = dockerComposeStop(*dataDir, id)
info.Status = "stopped"
_ = sendMessage(WSMessage{Action: "instance_stopped", InstanceID: id})
}
}
_ = saveInstances(*dataDir, inst)
}
log.Println("Cleanup complete")
}()
if *noTray {
log.Printf("[%s Agent] running in console mode (no tray)", APP_NAME)
<-shutdownCh
cleanupWg.Wait()
return
}
@@ -80,6 +123,7 @@ func main() {
}()
<-shutdownCh
cleanupWg.Wait()
}
func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
@@ -99,4 +143,23 @@ func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
log.Printf("Sent tailscale_ip to server: %s", ip)
break
}
// Reconfigure tailscale serve for any instances that were left running
// (e.g. after an agent restart while containers kept running).
if inst, err := loadInstances(dataDir); err == nil {
for id, info := range inst {
if info.Status == "running" {
log.Printf("Reconfiguring tailscale serve for running instance %s on port %d", id, info.Port)
if err := setupTailscaleServe(info.Port); err != nil {
log.Printf("setupTailscaleServe error for %s: %v", id, err)
}
}
}
}
// Notify the local UI that the service status has changed.
broadcastUI(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
})
}
+80 -1
View File
@@ -9,6 +9,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strconv"
"sync"
"syscall"
"time"
@@ -61,6 +62,9 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro
if err := os.MkdirAll(tsDataDir, 0700); err != nil {
return "", fmt.Errorf("create tailscale dir: %w", err)
}
// Make sure a previous tailscaled (e.g. left behind after a crash or
// force-kill) does not block the new daemon on the same socket/state.
killStaleTailscaled(tsDataDir)
if runtime.GOOS == "windows" {
// Windows uses named pipes for tailscaled IPC, not Unix sockets.
tsSocket = `\\.\pipe\studioe5-tailscaled`
@@ -88,6 +92,9 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro
tsCmd = nil
return "", fmt.Errorf("start tailscaled: %w", err)
}
if err := os.WriteFile(filepath.Join(tsDataDir, "tailscaled.pid"), []byte(strconv.Itoa(tsCmd.Process.Pid)), 0644); err != nil {
log.Printf("Cannot write tailscaled pid file: %v", err)
}
// Give tailscaled a moment to start listening.
time.Sleep(1 * time.Second)
@@ -99,11 +106,14 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro
upArgs := []string{
"--socket=" + tsSocket,
"up",
"--authkey=" + authKey,
"--login-server=" + headscaleURL,
"--hostname=" + nodeID,
"--accept-dns=false",
}
// The auth key is omitted on reconnect: Tailscale reuses the existing state.
if authKey != "" {
upArgs = append(upArgs, "--authkey="+authKey)
}
if runtime.GOOS == "windows" {
// On Windows, keep the VPN up even after the tailscale.exe CLI client disconnects.
upArgs = append(upArgs, "--unattended")
@@ -181,6 +191,9 @@ func stopTailscaleLocked() {
_ = tsCmd.Wait()
tsCmd = nil
tsIP = ""
if tsDataDir != "" {
_ = os.Remove(filepath.Join(tsDataDir, "tailscaled.pid"))
}
log.Printf("Tailscale stopped")
}
@@ -200,4 +213,70 @@ func getTailscaleIP() string {
return tsIP
}
// setupTailscaleServe configures Tailscale to proxy inbound Tailnet traffic
// on the given TCP port to localhost:<port>. This is required on Windows
// because userspace networking does not forward incoming connections to
// loopback by default.
func setupTailscaleServe(port int) error {
tsCmdMu.Lock()
defer tsCmdMu.Unlock()
if tsSocket == "" {
return fmt.Errorf("tailscale socket not initialized")
}
portStr := strconv.Itoa(port)
// Clean up any stale config for this port first.
offCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "off")
hideWindow(offCmd)
_ = offCmd.Run()
serveCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "tcp://localhost:"+portStr)
hideWindow(serveCmd)
out, err := serveCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("tailscale serve: %w: %s", err, string(out))
}
log.Printf("Tailscale serve configured for port %s", portStr)
return nil
}
// removeTailscaleServe removes the Tailscale serve proxy for a port when an
// instance is stopped or deleted.
func removeTailscaleServe(port int) {
tsCmdMu.Lock()
defer tsCmdMu.Unlock()
if tsSocket == "" {
return
}
portStr := strconv.Itoa(port)
offCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "off")
hideWindow(offCmd)
_ = offCmd.Run()
log.Printf("Tailscale serve removed for port %s", portStr)
}
// killStaleTailscaled terminates a previously started tailscaled process that
// may have been left running after the agent was force-killed.
func killStaleTailscaled(tsDataDir string) {
pidFile := filepath.Join(tsDataDir, "tailscaled.pid")
data, err := os.ReadFile(pidFile)
if err != nil {
return
}
var pid int
if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil {
return
}
proc, err := os.FindProcess(pid)
if err != nil {
return
}
if err := proc.Signal(syscall.Signal(0)); err == nil {
log.Printf("Killing stale tailscaled process %d", pid)
_ = proc.Kill()
_, _ = proc.Wait()
}
_ = os.Remove(pidFile)
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"os"
"path/filepath"
)
const nodeTokenFileName = "node.token"
func nodeTokenPath(dataDir string) string {
return filepath.Join(dataDir, nodeTokenFileName)
}
// loadNodeToken reads the persisted node authentication token, if any.
func loadNodeToken(dataDir string) (string, error) {
path := nodeTokenPath(dataDir)
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
// saveNodeToken persists the node authentication token with restrictive permissions.
func saveNodeToken(dataDir string, token string) error {
if err := os.MkdirAll(dataDir, 0755); err != nil {
return err
}
path := nodeTokenPath(dataDir)
return os.WriteFile(path, []byte(token), 0600)
}
+308 -8
View File
@@ -8,6 +8,10 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
@@ -17,6 +21,25 @@ var uiHTML string
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
// uiConnections holds active WebSocket connections from local UI clients.
var (
uiConnections = make(map[*websocket.Conn]bool)
uiConnectionsMu sync.RWMutex
)
// uiLogWriter intercepts log output and forwards it to connected UI clients.
type uiLogWriter struct{}
func (w uiLogWriter) Write(p []byte) (n int, err error) {
line := strings.TrimSpace(string(p))
if line != "" {
sendUILog(line)
}
return len(p), nil
}
func startUI(dataDir, nodeID, serverAddr string) {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
@@ -32,8 +55,16 @@ func startUI(dataDir, nodeID, serverAddr string) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Do not expose the auth key in plain GET unless requested; for local UI it is fine.
json.NewEncoder(w).Encode(cfg)
// Expose a merged view with the agent version for the UI.
response := map[string]interface{}{
"server": cfg.Server,
"headscale_url": cfg.HeadscaleURL,
"headscale_auth_key": cfg.HeadscaleAuthKey,
"node_id": cfg.NodeID,
"data_dir": cfg.DataDir,
"version": version,
}
json.NewEncoder(w).Encode(response)
case http.MethodPost:
var cfg AgentConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
@@ -80,23 +111,33 @@ func startUI(dataDir, nodeID, serverAddr string) {
return
}
defer conn.Close()
uiConnectionsMu.Lock()
uiConnections[conn] = true
uiConnectionsMu.Unlock()
log.Printf("UI client connected from %s", r.RemoteAddr)
// Send current status immediately.
sendUIStatus(conn, dataDir)
// Register notifier to forward activation results from main WS to this UI connection
notifierID := registerUINotifier(func(msg map[string]interface{}) {
log.Printf("UI notifier forwarding to browser: %+v", msg)
if err := conn.WriteJSON(msg); err != nil {
log.Printf("UI notify error: %v", err)
} else {
log.Printf("UI notifier sent successfully")
}
})
defer unregisterUINotifier(notifierID)
defer func() {
unregisterUINotifier(notifierID)
uiConnectionsMu.Lock()
delete(uiConnections, conn)
uiConnectionsMu.Unlock()
log.Printf("UI client disconnected")
}()
for {
var msg map[string]interface{}
if err := conn.ReadJSON(&msg); err != nil {
log.Printf("UI client disconnected: %v", err)
log.Printf("UI client read error: %v", err)
break
}
action, _ := msg["action"].(string)
@@ -120,6 +161,42 @@ func startUI(dataDir, nodeID, serverAddr string) {
}
case "instances":
listInstances(dataDir, conn)
case "get_status":
sendUIStatus(conn, dataDir)
case "run_diagnostic":
sendUIStatus(conn, dataDir)
conn.WriteJSON(map[string]interface{}{
"action": "diagnostic_result",
"status": buildUIStatus(dataDir),
"message": "Diagnostic terminé",
})
case "get_logs":
// Logs are streamed as they are produced; no persistent buffer yet.
conn.WriteJSON(map[string]interface{}{
"action": "log",
"message": "Console prête. Les nouveaux logs apparaîtront ici.",
"level": "info",
})
case "start_instance":
instanceID, _ := msg["instanceId"].(string)
if instanceID != "" {
go uiStartInstance(dataDir, nodeID, instanceID)
}
case "stop_instance":
instanceID, _ := msg["instanceId"].(string)
if instanceID != "" {
go uiStopInstance(dataDir, instanceID)
}
case "delete_instance":
instanceID, _ := msg["instanceId"].(string)
if instanceID != "" {
go uiDeleteInstance(dataDir, instanceID)
}
case "reset_instance":
instanceID, _ := msg["instanceId"].(string)
if instanceID != "" {
go uiResetInstance(dataDir, nodeID, instanceID)
}
}
}
})
@@ -139,7 +216,7 @@ func listInstances(dataDir string, conn *websocket.Conn) {
return
}
var list []map[string]interface{}
list := []map[string]interface{}{}
for _, inst := range instances {
status := getInstanceStatus(dataDir, inst.ID)
if status != inst.Status {
@@ -149,6 +226,7 @@ func listInstances(dataDir string, conn *websocket.Conn) {
list = append(list, map[string]interface{}{
"id": inst.ID,
"templateName": inst.TemplateName,
"type": inst.TemplateName,
"port": inst.Port,
"status": inst.Status,
"url": instanceURL(inst),
@@ -157,3 +235,225 @@ func listInstances(dataDir string, conn *websocket.Conn) {
conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": list})
}
// sendUILog broadcasts a log line to all connected UI clients.
func sendUILog(message string) {
uiConnectionsMu.RLock()
conns := make([]*websocket.Conn, 0, len(uiConnections))
for conn := range uiConnections {
conns = append(conns, conn)
}
uiConnectionsMu.RUnlock()
msg := map[string]interface{}{
"action": "log",
"message": message,
"level": "info",
}
for _, conn := range conns {
if err := conn.WriteJSON(msg); err != nil {
// Client may have disconnected; ignore.
}
}
}
// sendInstanceProgress broadcasts a progress update for a specific instance.
func sendInstanceProgress(instanceID, step, percent, message string) {
broadcastUI(map[string]interface{}{
"action": "progress",
"instanceId": instanceID,
"step": step,
"percent": percent,
"message": message,
})
}
// broadcastUI sends a message to all connected UI clients.
func broadcastUI(msg map[string]interface{}) {
uiConnectionsMu.RLock()
conns := make([]*websocket.Conn, 0, len(uiConnections))
for conn := range uiConnections {
conns = append(conns, conn)
}
uiConnectionsMu.RUnlock()
for _, conn := range conns {
if err := conn.WriteJSON(msg); err != nil {
// Ignore write errors for disconnected clients.
}
}
}
// sendUIStatus sends the current services status to a single UI connection.
func sendUIStatus(conn *websocket.Conn, dataDir string) {
if err := conn.WriteJSON(map[string]interface{}{
"action": "status",
"status": buildUIStatus(dataDir),
}); err != nil {
log.Printf("sendUIStatus error: %v", err)
}
}
// buildUIStatus constructs a user-friendly status snapshot.
func buildUIStatus(dataDir string) map[string]interface{} {
// Connection to the school server.
connectionState := "pending"
connectionDetail := "Connexion en cours..."
mainConnMu.Lock()
connected := mainConn != nil
mainConnMu.Unlock()
if connected {
connectionState = "ok"
connectionDetail = "Connecté au serveur de l'établissement"
} else {
connectionState = "error"
connectionDetail = "Non connecté au serveur de l'établissement"
}
// Application service (Docker/Podman + VPN).
appServiceState := "pending"
appServiceDetail := "Vérification du service d'applications..."
engine := getContainerEngine()
if engineAvailable(engine) {
if isTailscaleRunning() {
appServiceState = "ok"
appServiceDetail = "Service d'applications prêt"
} else {
appServiceState = "warn"
appServiceDetail = "Service d'applications disponible, connexion sécurisée en cours"
}
} else {
appServiceState = "error"
appServiceDetail = "Service d'applications non disponible"
}
// Applications ready.
applicationsState := "pending"
applicationsDetail := "Vérification des applications..."
if instances, err := loadInstances(dataDir); err == nil {
ready := 0
total := len(instances)
for _, inst := range instances {
if getInstanceStatus(dataDir, inst.ID) == "running" {
ready++
}
}
if total == 0 {
applicationsState = "ok"
applicationsDetail = "Aucune application assignée"
} else if ready == total {
applicationsState = "ok"
applicationsDetail = fmt.Sprintf("%d application%s prête%s", ready, plural(ready), plural(ready))
} else if ready > 0 {
applicationsState = "warn"
applicationsDetail = fmt.Sprintf("%d / %d application%s prête%s", ready, total, plural(ready), plural(ready))
} else {
applicationsState = "pending"
applicationsDetail = fmt.Sprintf("%d application%s en cours de démarrage", total, plural(total))
}
}
return map[string]interface{}{
"connection": connectionState,
"connectionDetail": connectionDetail,
"appService": appServiceState,
"appServiceDetail": appServiceDetail,
"applications": applicationsState,
"applicationsDetail": applicationsDetail,
}
}
func engineAvailable(engine string) bool {
_, err := exec.LookPath(engine)
return err == nil
}
func plural(n int) string {
if n > 1 {
return "s"
}
return ""
}
// uiStartInstance starts a stopped instance without recreating its containers,
// so volumes and data are preserved.
func uiStartInstance(dataDir, nodeID, instanceID string) {
inst, err := loadInstances(dataDir)
if err != nil || inst[instanceID] == nil {
log.Printf("uiStartInstance: instance %s not found", instanceID)
return
}
info := inst[instanceID]
if instanceContainersExist(dataDir, instanceID) {
if err := dockerComposeStart(dataDir, instanceID); err != nil {
log.Printf("uiStartInstance: start failed for %s: %v", instanceID, err)
return
}
} else {
if err := dockerComposeUp(dataDir, instanceID); err != nil {
log.Printf("uiStartInstance: up failed for %s: %v", instanceID, err)
return
}
}
time.Sleep(2 * time.Second)
if err := setupTailscaleServe(info.Port); err != nil {
log.Printf("uiStartInstance: setupTailscaleServe failed for %s: %v", instanceID, err)
}
status := getInstanceStatus(dataDir, instanceID)
info.Status = status
_ = upsertInstance(dataDir, info)
_ = sendMessage(WSMessage{Action: "instance_started", InstanceID: instanceID, Port: info.Port})
notifyUI(map[string]interface{}{"action": "instances_updated"})
}
// uiStopInstance stops a running instance without removing its containers or volumes.
func uiStopInstance(dataDir, instanceID string) {
_ = dockerComposeStop(dataDir, instanceID)
if inst, err := loadInstances(dataDir); err == nil && inst[instanceID] != nil {
inst[instanceID].Status = "stopped"
_ = saveInstances(dataDir, inst)
}
_ = sendMessage(WSMessage{Action: "instance_stopped", InstanceID: instanceID})
notifyUI(map[string]interface{}{"action": "instances_updated"})
}
// uiDeleteInstance removes an instance and its data (volumes included).
func uiDeleteInstance(dataDir, instanceID string) {
if inst, err := loadInstances(dataDir); err == nil && inst[instanceID] != nil {
removeTailscaleServe(inst[instanceID].Port)
}
dockerComposeRm(dataDir, instanceID)
removeInstance(dataDir, instanceID)
_ = sendMessage(WSMessage{Action: "instance_deleted", InstanceID: instanceID})
notifyUI(map[string]interface{}{"action": "instances_updated"})
}
// uiResetInstance stops, removes volumes and recreates an instance from scratch.
func uiResetInstance(dataDir, nodeID, instanceID string) {
inst, err := loadInstances(dataDir)
if err != nil || inst[instanceID] == nil {
log.Printf("uiResetInstance: instance %s not found", instanceID)
return
}
info := inst[instanceID]
composePath := filepath.Join(instanceDir(dataDir, instanceID), "docker-compose.yml")
composeBytes, err := os.ReadFile(composePath)
if err != nil {
log.Printf("uiResetInstance: cannot read compose for %s: %v", instanceID, err)
return
}
dockerComposeRm(dataDir, instanceID)
handleStartInstance(dataDir, nodeID, instanceID, info.TemplateName, string(composeBytes), info.Port)
}
// instanceContainersExist returns true if compose containers already exist for this instance.
func instanceContainersExist(dataDir, instanceID string) bool {
dir := instanceDir(dataDir, instanceID)
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "ps", "-q")
configureEngineCmd(cmd, dir)
out, err := cmd.Output()
return err == nil && strings.TrimSpace(string(out)) != ""
}
+843 -135
View File
File diff suppressed because it is too large Load Diff
+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),
})
}