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:
+1
-1
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+115
-71
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user