// Handlers pour les mises à jour de paquets apt. // Supporte : host Proxmox, un LXC spécifique, ou tous les LXC. // La sortie est streamée ligne par ligne via WebSocket. package api import ( "fmt" "log" "math/rand" "net/http" "strconv" "strings" "time" "git.geronzi.fr/proxmoxPanel/core/backend/internal/audit" "git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto" "git.geronzi.fr/proxmoxPanel/core/backend/internal/db" "git.geronzi.fr/proxmoxPanel/core/backend/internal/ssh" "git.geronzi.fr/proxmoxPanel/core/backend/internal/websocket" "github.com/go-chi/chi/v5" ) // UpdatesHandler contient les handlers de mises à jour. type UpdatesHandler struct { db *db.DB sshPool *ssh.Pool hub *websocket.Hub auditLogger *audit.Logger encryptor *crypto.Encryptor } // NewUpdatesHandler crée un UpdatesHandler. func NewUpdatesHandler(database *db.DB, sshPool *ssh.Pool, hub *websocket.Hub, auditLog *audit.Logger, enc *crypto.Encryptor) *UpdatesHandler { return &UpdatesHandler{ db: database, sshPool: sshPool, hub: hub, auditLogger: auditLog, encryptor: enc, } } // sshCredentials récupère les credentials SSH depuis la configuration SQLite. func (h *UpdatesHandler) sshCredentials() (host, user, pass string, err error) { host, _, _ = h.db.GetSetting("ssh_host") user, _, _ = h.db.GetSetting("ssh_username") encPass, _, _ := h.db.GetSetting("ssh_password") if encPass != "" { pass, err = h.encryptor.Decrypt(encPass) if err != nil { return "", "", "", fmt.Errorf("impossible de déchiffrer le mot de passe SSH") } } if host == "" || user == "" || pass == "" { return "", "", "", fmt.Errorf("SSH non configuré") } return host, user, pass, nil } // GetTargets retourne la liste des cibles disponibles : host Proxmox + tous les LXC. // GET /api/updates/targets func (h *UpdatesHandler) GetTargets(w http.ResponseWriter, r *http.Request) { sshHost, sshUser, sshPass, err := h.sshCredentials() if err != nil { JSONError(w, err.Error(), http.StatusServiceUnavailable) return } type Target struct { ID string `json:"id"` Name string `json:"name"` Status string `json:"status"` VMID int `json:"vmid,omitempty"` } targets := []Target{ {ID: "host", Name: "Proxmox Host", Status: "running"}, } output, err := h.sshPool.RunCommand(sshHost, sshUser, sshPass, "pct list 2>/dev/null") if err == nil { for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) if line == "" { continue } fields := strings.Fields(line) if len(fields) < 2 || fields[0] == "VMID" { continue } vmid, parseErr := strconv.Atoi(fields[0]) if parseErr != nil { continue } status := fields[1] name := fields[0] // fallback VMID si pas de nom if len(fields) >= 3 { name = fields[len(fields)-1] } targets = append(targets, Target{ ID: fmt.Sprintf("lxc:%d", vmid), Name: name, Status: status, VMID: vmid, }) } } JSONResponse(w, http.StatusOK, targets) } // GetPackages retourne la liste des paquets pouvant être mis à jour pour une cible. // GET /api/updates/packages?target=host|lxc:100 func (h *UpdatesHandler) GetPackages(w http.ResponseWriter, r *http.Request) { target := r.URL.Query().Get("target") if target == "" { JSONError(w, "Paramètre 'target' requis", http.StatusBadRequest) return } sshHost, sshUser, sshPass, err := h.sshCredentials() if err != nil { JSONError(w, err.Error(), http.StatusServiceUnavailable) return } var command string switch { case target == "host": command = "apt list --upgradable 2>/dev/null" case len(target) > 4 && target[:4] == "lxc:": lxcID := target[4:] command = fmt.Sprintf("pct exec %s -- apt list --upgradable 2>/dev/null", lxcID) default: JSONError(w, "Cible invalide", http.StatusBadRequest) return } output, err := h.sshPool.RunCommand(sshHost, sshUser, sshPass, command) if err != nil { log.Printf("[updates/packages] Erreur SSH pour %s : %v", target, err) JSONError(w, "Erreur SSH : "+err.Error(), http.StatusBadGateway) return } JSONResponse(w, http.StatusOK, parseAptPackages(output)) } // parseAptPackages analyse la sortie de `apt list --upgradable`. func parseAptPackages(output string) []map[string]string { var packages []map[string]string for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) if !strings.Contains(line, "[upgradable from:") { continue } // Format : name/repo version arch [upgradable from: old_version] parts := strings.Fields(line) if len(parts) < 2 { continue } name := strings.SplitN(parts[0], "/", 2)[0] version := parts[1] oldVersion := "" if idx := strings.Index(line, "upgradable from: "); idx >= 0 { oldVersion = strings.TrimRight(line[idx+len("upgradable from: "):], "] ") } packages = append(packages, map[string]string{ "name": name, "version": version, "old_version": oldVersion, }) } if packages == nil { packages = []map[string]string{} } return packages } // RunUpdate lance une mise à jour apt sur la cible spécifiée. // POST /api/updates/run // Body: { "target": "host" | "lxc:100" | "all" } func (h *UpdatesHandler) RunUpdate(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r) var body struct { Target string `json:"target"` } if err := decodeJSON(r, &body); err != nil || body.Target == "" { JSONError(w, "Paramètre 'target' requis (host, lxc:ID, ou all)", http.StatusBadRequest) return } // Récupérer les credentials SSH depuis les settings sshHost, _, _ := h.db.GetSetting("ssh_host") sshUser, _, _ := h.db.GetSetting("ssh_username") encryptedPass, _, _ := h.db.GetSetting("ssh_password") sshPass, _ := h.encryptor.Decrypt(encryptedPass) log.Printf("[updates/run] Credentials SSH — host=%s user=%s password_len=%d", sshHost, sshUser, len(sshPass)) if sshHost == "" || sshUser == "" || sshPass == "" { log.Printf("[updates/run] SSH non configuré — host=%q user=%q password_empty=%v", sshHost, sshUser, sshPass == "") JSONError(w, "SSH non configuré", http.StatusServiceUnavailable) return } // Générer un ID de job unique jobID := generateJobID() // Enregistrer le job en base h.db.Exec(` INSERT INTO update_history (job_id, target, status, started_by) VALUES (?, ?, 'running', ?) `, jobID, body.Target, claims.UserID) h.auditLogger.Log(&claims.UserID, claims.Username, "update_start", body.Target, nil, clientIP(r)) log.Printf("[updates/run] Job %s démarré — target=%s user=%d", jobID, body.Target, claims.UserID) go h.executeUpdate(jobID, body.Target, sshHost, sshUser, sshPass, claims.UserID) JSONResponse(w, http.StatusAccepted, map[string]string{ "job_id": jobID, "message": "Mise à jour démarrée", }) } // GetHistory retourne l'historique des mises à jour. // GET /api/updates/history func (h *UpdatesHandler) GetHistory(w http.ResponseWriter, r *http.Request) { rows, err := h.db.Query(` SELECT job_id, target, status, output, started_at, finished_at FROM update_history ORDER BY started_at DESC LIMIT 50 `) if err != nil { JSONError(w, "Erreur lecture historique", http.StatusInternalServerError) return } defer rows.Close() type entry struct { JobID string `json:"job_id"` Target string `json:"target"` Status string `json:"status"` Output string `json:"output"` StartedAt string `json:"started_at"` FinishedAt *string `json:"finished_at,omitempty"` } var entries []entry for rows.Next() { var e entry var finishedAt *string rows.Scan(&e.JobID, &e.Target, &e.Status, &e.Output, &e.StartedAt, &finishedAt) e.FinishedAt = finishedAt entries = append(entries, e) } JSONResponse(w, http.StatusOK, entries) } // WebSocketUpdate ouvre un WebSocket pour suivre un job de mise à jour en temps réel. // GET /ws/updates/{jobId} func (h *UpdatesHandler) WebSocketUpdate(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { return } claims := GetClaims(r) var userID int64 if claims != nil { userID = claims.UserID } jobID := chi.URLParam(r, "jobId") wsClient := h.hub.NewClient(conn, userID) wsClient.Subscribe("update:" + jobID) } // executeUpdate exécute la commande apt et streame la sortie via WebSocket. func (h *UpdatesHandler) executeUpdate(jobID, target, sshHost, sshUser, sshPass string, userID int64) { outputChan := make(chan string, 100) var command string switch { case target == "host": command = "DEBIAN_FRONTEND=noninteractive apt-get update && DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y" case len(target) > 4 && target[:4] == "lxc:": lxcID := target[4:] command = fmt.Sprintf( "pct exec %s -- bash -c 'DEBIAN_FRONTEND=noninteractive apt-get update && DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y'", lxcID, ) case target == "all": command = `for ct in $(pct list | awk 'NR>1 {print $1}'); do echo "=== LXC $ct ===" pct exec $ct -- bash -c 'DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y' 2>/dev/null || echo "SKIP LXC $ct" done` default: h.db.Exec(`UPDATE update_history SET status='error', output=?, finished_at=CURRENT_TIMESTAMP WHERE job_id=?`, "Cible invalide : "+target, jobID) return } // Lancer le streaming SSH cmdPreview := command if len(cmdPreview) > 80 { cmdPreview = cmdPreview[:80] + "..." } log.Printf("[updates/execute] Job %s — SSH %s@%s commande: %s", jobID, sshUser, sshHost, cmdPreview) err := h.sshPool.StreamCommand(sshHost, sshUser, sshPass, command, outputChan) if err != nil { log.Printf("[updates/execute] Job %s — Erreur SSH : %v", jobID, err) h.db.Exec(`UPDATE update_history SET status='error', output=?, finished_at=CURRENT_TIMESTAMP WHERE job_id=?`, "Erreur SSH : "+err.Error(), jobID) h.hub.Publish("update:"+jobID, "update_error", map[string]string{"error": err.Error()}) return } // Collecter la sortie et la publier ligne par ligne var fullOutput string for chunk := range outputChan { fullOutput += chunk h.hub.Publish("update:"+jobID, "update_output", map[string]string{"chunk": chunk}) } // Finaliser le job log.Printf("[updates/execute] Job %s — terminé (%d octets de sortie)", jobID, len(fullOutput)) h.db.Exec(`UPDATE update_history SET status='success', output=?, finished_at=CURRENT_TIMESTAMP WHERE job_id=?`, fullOutput, jobID) h.hub.Publish("update:"+jobID, "update_done", map[string]string{"job_id": jobID}) } // generateJobID génère un identifiant unique pour un job de mise à jour. func generateJobID() string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789" b := make([]byte, 8) for i := range b { b[i] = chars[rand.Intn(len(chars))] } return fmt.Sprintf("%d-%s", time.Now().Unix(), string(b)) }