core/backend/internal/api/updates.go
enzo 7ba0ff143c fix: sudo -n pour pct exec/list (permissions root requises)
Tous les appels pct passent par sudo -n pour les sessions SSH non-root.
GetPackages est résilient : utilise l'output même si exit code != 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 01:29:07 +01:00

346 lines
11 KiB
Go

// 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, "sudo -n /usr/sbin/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("sudo -n /usr/sbin/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)
packages := parseAptPackages(output)
if err != nil && len(packages) == 0 {
log.Printf("[updates/packages] Erreur SSH pour %s : %v", target, err)
JSONError(w, "Erreur SSH : "+err.Error(), http.StatusBadGateway)
return
}
JSONResponse(w, http.StatusOK, packages)
}
// 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(
"sudo -n /usr/sbin/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 $(sudo -n /usr/sbin/pct list | awk 'NR>1 {print $1}'); do
echo "=== LXC $ct ==="
sudo -n /usr/sbin/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))
}