// Module Services — gestion des services systemd via SSH. // Expose : liste des services, statut détaillé, start/stop/restart (admin). package services import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "git.geronzi.fr/proxmoxPanel/core/backend/internal/api" "git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto" "git.geronzi.fr/proxmoxPanel/core/backend/internal/db" sshpool "git.geronzi.fr/proxmoxPanel/core/backend/internal/ssh" "git.geronzi.fr/proxmoxPanel/core/backend/modules" "github.com/go-chi/chi/v5" ) // ServicesModule gère les opérations systemctl sur le host et les LXC. type ServicesModule struct { db *db.DB pool *sshpool.Pool enc *crypto.Encryptor } // New crée un ServicesModule avec les dépendances nécessaires. func New(database *db.DB, pool *sshpool.Pool, enc *crypto.Encryptor) *ServicesModule { return &ServicesModule{db: database, pool: pool, enc: enc} } func (m *ServicesModule) ID() string { return "services" } // Register enregistre les routes du module dans le registry CORE. func (m *ServicesModule) Register(r modules.Registry) error { r.RegisterRoute("GET", "/api/services", m.ListServices, false) r.RegisterRoute("GET", "/api/services/{name}/status", m.ServiceStatus, false) r.RegisterRoute("POST", "/api/services/{name}/{action}", m.ServiceAction, true) return nil } // sshCreds récupère et déchiffre les credentials SSH depuis la configuration. func (m *ServicesModule) sshCreds() (host, user, pass string, err error) { host, _, _ = m.db.GetSetting("ssh_host") user, _, _ = m.db.GetSetting("ssh_username") encPass, _, _ := m.db.GetSetting("ssh_password") if encPass != "" { pass, err = m.enc.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 } // buildCmd construit la commande systemctl, en la wrappant via pct exec pour les LXC. func buildCmd(target, systemctlArgs string) string { if strings.HasPrefix(target, "lxc:") { vmid := strings.TrimPrefix(target, "lxc:") if _, err := strconv.Atoi(vmid); err == nil { return fmt.Sprintf("pct exec %s -- systemctl %s", vmid, systemctlArgs) } } return "systemctl " + systemctlArgs } // ServiceEntry représente un service systemd dans la liste. type ServiceEntry struct { Name string `json:"name"` LoadState string `json:"load_state"` ActiveState string `json:"active_state"` SubState string `json:"sub_state"` Description string `json:"description"` } // ListServices retourne la liste des services systemd d'une cible. // GET /api/services?target=host|lxc:ID func (m *ServicesModule) ListServices(w http.ResponseWriter, r *http.Request) { host, user, pass, err := m.sshCreds() if err != nil { api.JSONError(w, err.Error(), http.StatusServiceUnavailable) return } target := r.URL.Query().Get("target") if target == "" { target = "host" } cmd := buildCmd(target, "list-units --type=service --all --no-legend --plain --no-pager 2>/dev/null") out, err := m.pool.RunCommand(host, user, pass, cmd) if err != nil { api.JSONError(w, "Erreur commande SSH : "+err.Error(), http.StatusInternalServerError) return } services := parseServiceList(out) api.JSONResponse(w, http.StatusOK, services) } // parseServiceList parse la sortie de systemctl list-units. // Format : "nom.service load active running Description..." func parseServiceList(output string) []ServiceEntry { var services []ServiceEntry for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "UNIT") || strings.HasPrefix(line, "Legend") || strings.HasPrefix(line, "To show") || strings.HasPrefix(line, "Pass") { continue } // Certaines lignes commencent par "●" (service failed) — on supprime ce caractère line = strings.TrimPrefix(line, "● ") line = strings.TrimPrefix(line, " ") fields := strings.Fields(line) if len(fields) < 4 { continue } name := strings.TrimSuffix(fields[0], ".service") desc := "" if len(fields) >= 5 { desc = strings.Join(fields[4:], " ") } services = append(services, ServiceEntry{ Name: name, LoadState: fields[1], ActiveState: fields[2], SubState: fields[3], Description: desc, }) } return services } // ServiceStatus retourne le statut détaillé d'un service. // GET /api/services/{name}/status?target=host|lxc:ID func (m *ServicesModule) ServiceStatus(w http.ResponseWriter, r *http.Request) { host, user, pass, err := m.sshCreds() if err != nil { api.JSONError(w, err.Error(), http.StatusServiceUnavailable) return } name := chi.URLParam(r, "name") target := r.URL.Query().Get("target") if target == "" { target = "host" } cmd := buildCmd(target, fmt.Sprintf("status %s --no-pager 2>&1", name)) out, _ := m.pool.RunCommand(host, user, pass, cmd) api.JSONResponse(w, http.StatusOK, map[string]string{"output": out}) } // ServiceAction exécute une action (start/stop/restart/reload) sur un service. // POST /api/services/{name}/{action} // Body: { "target": "host" | "lxc:ID" } func (m *ServicesModule) ServiceAction(w http.ResponseWriter, r *http.Request) { host, user, pass, err := m.sshCreds() if err != nil { api.JSONError(w, err.Error(), http.StatusServiceUnavailable) return } name := chi.URLParam(r, "name") action := chi.URLParam(r, "action") // Valider l'action pour éviter l'injection de commandes allowed := map[string]bool{"start": true, "stop": true, "restart": true, "reload": true, "enable": true, "disable": true} if !allowed[action] { api.JSONError(w, "Action invalide", http.StatusBadRequest) return } var body struct { Target string `json:"target"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Target == "" { body.Target = "host" } cmd := buildCmd(body.Target, fmt.Sprintf("%s %s 2>&1", action, name)) out, err := m.pool.RunCommand(host, user, pass, cmd) if err != nil { api.JSONError(w, fmt.Sprintf("Erreur action %s sur %s : %v\n%s", action, name, err, out), http.StatusInternalServerError) return } api.JSONResponse(w, http.StatusOK, map[string]string{ "message": fmt.Sprintf("Action « %s » exécutée sur %s", action, name), "output": out, }) }