pct n'est pas dans le PATH SSH par défaut (exit 127). Corrigé dans GetPackages, GetTargets et executeUpdate. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
345 lines
11 KiB
Go
345 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, "/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("/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)
|
|
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(
|
|
"/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 $(/usr/sbin/pct list | awk 'NR>1 {print $1}'); do
|
|
echo "=== LXC $ct ==="
|
|
/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))
|
|
}
|