package main import ( _ "embed" "encoding/json" "fmt" "log" "net/http" "os" "os/exec" "path/filepath" "strings" "sync" "time" "github.com/gorilla/websocket" ) //go:embed ui/index.html 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") fmt.Fprint(w, uiHTML) }) http.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.Method { case http.MethodGet: cfg, _, err := loadOrCreateConfig(dataDir) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Expose a merged view with the agent version for the UI. serverVersion := getServerAgentVersion() updateAvailable := serverVersion != "" && serverVersion != version 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, "server_version": serverVersion, "update_available": updateAvailable, } json.NewEncoder(w).Encode(response) case http.MethodPost: var cfg AgentConfig if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if cfg.DataDir == "" { cfg.DataDir = dataDir } if err := saveConfig(dataDir, &cfg); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } }) http.HandleFunc("/api/restart", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } w.WriteHeader(http.StatusNoContent) go func() { cmd := exec.Command(os.Args[0], os.Args[1:]...) hideWindow(cmd) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin if err := cmd.Start(); err != nil { log.Printf("Restart failed: %v", err) return } os.Exit(0) }() }) http.HandleFunc("/api/update", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } cfg, _, err := loadOrCreateConfig(dataDir) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } go func() { broadcastUI(map[string]interface{}{ "action": "update_progress", "percent": "10", "message": "Téléchargement de la mise à jour...", }) if err := startAgentUpdate(dataDir, cfg.Server); err != nil { log.Printf("Agent update failed: %v", err) broadcastUI(map[string]interface{}{ "action": "update_progress", "percent": "0", "message": "Échec de la mise à jour : " + err.Error(), }) } }() w.WriteHeader(http.StatusNoContent) }) http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("UI WS upgrade error: %v", err) 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{}) { if err := conn.WriteJSON(msg); err != nil { log.Printf("UI notify error: %v", err) } }) 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 read error: %v", err) break } action, _ := msg["action"].(string) log.Printf("UI received action: %s", action) switch action { case "check": act, err := loadActivation(dataDir) if err == nil && act.Activated { conn.WriteJSON(map[string]interface{}{"action": "activated", "studentName": act.StudentName}) } else { conn.WriteJSON(map[string]interface{}{"action": "not_activated"}) } case "activate": code, _ := msg["code"].(string) log.Printf("UI handling activate with code: %s", code) if err := sendMessage(WSMessage{Action: "activate", NodeID: nodeID, Code: code}); err != nil { log.Printf("UI sendMessage failed: %v", err) conn.WriteJSON(map[string]interface{}{"action": "activation_failed", "error": err.Error()}) } else { log.Printf("UI sendMessage succeeded, waiting for server response...") } 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) } } } }) port := "7070" log.Printf("%s UI starting on http://localhost:%s", APP_NAME, port) if err := http.ListenAndServe("127.0.0.1:"+port, nil); err != nil { log.Fatalf("%s UI server error: %v", APP_NAME, err) } } func listInstances(dataDir string, conn *websocket.Conn) { instances, err := loadInstances(dataDir) if err != nil { log.Printf("loadInstances error: %v", err) conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": []interface{}{}}) return } list := []map[string]interface{}{} for _, inst := range instances { status := getInstanceStatus(dataDir, inst.ID) if status != inst.Status { inst.Status = status _ = upsertInstance(dataDir, inst) } list = append(list, map[string]interface{}{ "id": inst.ID, "templateName": inst.TemplateName, "type": inst.TemplateName, "port": inst.Port, "status": inst.Status, "url": instanceURL(inst), }) } 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 { func(c *websocket.Conn) { defer func() { if r := recover(); r != nil { log.Printf("PANIC in sendUILog: %v", r) } }() if err := c.WriteJSON(msg); err != nil { // Client may have disconnected; ignore. } }(conn) } } // 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 { func(c *websocket.Conn) { defer func() { if r := recover(); r != nil { log.Printf("PANIC in broadcastUI: %v", r) } }() if err := c.WriteJSON(msg); err != nil { // Ignore write errors for disconnected clients. } }(conn) } } // 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) } go 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) go 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)) != "" }