core/backend/internal/api/settings.go
enzo a61f805cd0 feat: système de rebuild Docker pour installation de modules has_backend
- internal/docker/client.go : client HTTP brut sur socket Unix
  - BuildImage() : build depuis repo git avec ARG MODULES
  - RebuildAndRestart() : rebuild async + remplacement de container
  - HandleReplacement() : le container successeur arrête et renomme l'ancien
  - Restart() : redémarrage simple (enable/disable sans rebuild)
- cmd/gen-modules/main.go : générateur de registered_modules.go
  Lit MODULES env var, génère imports + appels RegisterModules()
- registered_modules.go : version par défaut (aucun module)
- main.go : appel RegisterModules(loader) + HandleReplacement() au démarrage
- settings.go : inject DockerClient, has_backend dans moduleResp/moduleJSON,
  trigger rebuild à l'install, restart à l'enable/disable
- migrations/006 : colonne has_backend sur table modules
- Dockerfile : ARG MODULES, git clone modules, go run ./cmd/gen-modules
- docker-compose.yml : socket Docker, group_add, env vars CONTAINER_NAME/GIT_REPO/GIT_BRANCH

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

487 lines
15 KiB
Go

// Handlers pour la page paramètres : lecture/écriture de la configuration globale.
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
dockerclient "git.geronzi.fr/proxmoxPanel/core/backend/internal/docker"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/logbuffer"
"github.com/go-chi/chi/v5"
)
// SettingsHandler contient les handlers de configuration.
type SettingsHandler struct {
db *db.DB
auditLogger *audit.Logger
encryptor *crypto.Encryptor
docker *dockerclient.Client
}
// NewSettingsHandler crée un SettingsHandler.
func NewSettingsHandler(database *db.DB, auditLog *audit.Logger, enc *crypto.Encryptor, docker *dockerclient.Client) *SettingsHandler {
return &SettingsHandler{db: database, auditLogger: auditLog, encryptor: enc, docker: docker}
}
// paramètres publics (non-sensibles) accessibles par les admins.
var publicSettings = []string{
"instance_name",
"public_url",
"default_lang",
"proxmox_url",
"ssh_host",
"ssh_username",
"dashboard_shortcuts",
}
// paramètres sensibles : modifiables en écriture seule, stockés chiffrés.
var encryptedSettings = []string{"ssh_password", "proxmox_token"}
// GetAll retourne tous les paramètres publics de l'application.
// GET /api/settings
func (h *SettingsHandler) GetAll(w http.ResponseWriter, r *http.Request) {
result := make(map[string]string)
for _, key := range publicSettings {
value, _, err := h.db.GetSetting(key)
if err == nil {
result[key] = value
}
}
JSONResponse(w, http.StatusOK, result)
}
// UpdateSetting met à jour un paramètre spécifique.
// PUT /api/settings/{key}
// Body: { "value": "..." }
func (h *SettingsHandler) UpdateSetting(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r)
key := chi.URLParam(r, "key")
if key == "" {
JSONError(w, "Clé de paramètre manquante", http.StatusBadRequest)
return
}
// Vérifier que la clé est modifiable (publique ou chiffrée)
isPublic := false
for _, k := range publicSettings {
if k == key {
isPublic = true
break
}
}
isEncrypted := false
for _, k := range encryptedSettings {
if k == key {
isEncrypted = true
break
}
}
if !isPublic && !isEncrypted {
JSONError(w, "Paramètre non modifiable via l'API", http.StatusForbidden)
return
}
var body struct {
Value string `json:"value"`
}
if err := decodeJSON(r, &body); err != nil {
JSONError(w, "Corps de requête invalide", http.StatusBadRequest)
return
}
// Paramètres chiffrés : valeur vide = ne pas modifier
if isEncrypted {
if body.Value == "" {
JSONResponse(w, http.StatusOK, map[string]string{"message": "Aucun changement"})
return
}
encrypted, err := h.encryptor.Encrypt(body.Value)
if err != nil {
JSONError(w, "Erreur chiffrement", http.StatusInternalServerError)
return
}
if err := h.db.SetSetting(key, encrypted, true); err != nil {
JSONError(w, "Erreur sauvegarde paramètre", http.StatusInternalServerError)
return
}
h.auditLogger.Log(&claims.UserID, claims.Username, "setting_update", key,
map[string]string{"key": key}, clientIP(r))
JSONResponse(w, http.StatusOK, map[string]string{"message": "Paramètre mis à jour"})
return
}
if err := h.db.SetSetting(key, body.Value, false); err != nil {
JSONError(w, "Erreur sauvegarde paramètre", http.StatusInternalServerError)
return
}
h.auditLogger.Log(&claims.UserID, claims.Username, "setting_update", key,
map[string]string{"key": key}, clientIP(r))
JSONResponse(w, http.StatusOK, map[string]string{"message": "Paramètre mis à jour"})
}
// moduleResp représente un module dans les réponses API, incluant les champs de navigation.
type moduleResp struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
IsCore bool `json:"is_core"`
IsEnabled bool `json:"is_enabled"`
HasBackend bool `json:"has_backend"`
NavHref string `json:"nav_href"`
NavIcon string `json:"nav_icon"`
NavColor string `json:"nav_color"`
NavLabelKey string `json:"nav_label_key"`
}
// GetModules retourne la liste de tous les modules et leur état.
// GET /api/modules
func (h *SettingsHandler) GetModules(w http.ResponseWriter, r *http.Request) {
rows, err := h.db.Query(`
SELECT id, name, description, version, is_core, is_enabled, has_backend,
COALESCE(nav_href,''), COALESCE(nav_icon,''), COALESCE(nav_color,''), COALESCE(nav_label_key,'')
FROM modules ORDER BY is_core DESC, name ASC
`)
if err != nil {
JSONError(w, "Erreur lecture modules", http.StatusInternalServerError)
return
}
defer rows.Close()
var modules []moduleResp
for rows.Next() {
var m moduleResp
var isCore, isEnabled, hasBackend int
rows.Scan(&m.ID, &m.Name, &m.Description, &m.Version, &isCore, &isEnabled, &hasBackend,
&m.NavHref, &m.NavIcon, &m.NavColor, &m.NavLabelKey)
m.IsCore = isCore == 1
m.IsEnabled = isEnabled == 1
m.HasBackend = hasBackend == 1
modules = append(modules, m)
}
JSONResponse(w, http.StatusOK, modules)
}
// EnableModule active un module.
// POST /api/modules/{id}/enable
func (h *SettingsHandler) EnableModule(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r)
id := chi.URLParam(r, "id")
result, err := h.db.Exec(`UPDATE modules SET is_enabled = 1 WHERE id = ?`, id)
if err != nil {
JSONError(w, "Erreur activation module", http.StatusInternalServerError)
return
}
n, _ := result.RowsAffected()
if n == 0 {
JSONError(w, "Module introuvable", http.StatusNotFound)
return
}
h.auditLogger.Log(&claims.UserID, claims.Username, "module_enable", id, nil, clientIP(r))
// Si le module a du code backend, redémarrer le container pour que LoadActive() le prenne en compte.
restart := h.triggerRestartIfBackend(id)
JSONResponse(w, http.StatusOK, map[string]interface{}{"message": "Module activé", "restarting": restart})
}
// DisableModule désactive un module (ne peut pas désactiver les modules CORE).
// POST /api/modules/{id}/disable
func (h *SettingsHandler) DisableModule(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r)
id := chi.URLParam(r, "id")
// Vérifier que ce n'est pas un module CORE
var isCore int
if err := h.db.QueryRow(`SELECT is_core FROM modules WHERE id = ?`, id).Scan(&isCore); err != nil {
JSONError(w, "Module introuvable", http.StatusNotFound)
return
}
if isCore == 1 {
JSONError(w, "Les modules CORE ne peuvent pas être désactivés", http.StatusForbidden)
return
}
h.db.Exec(`UPDATE modules SET is_enabled = 0 WHERE id = ?`, id)
h.auditLogger.Log(&claims.UserID, claims.Username, "module_disable", id, nil, clientIP(r))
restart := h.triggerRestartIfBackend(id)
JSONResponse(w, http.StatusOK, map[string]interface{}{"message": "Module désactivé", "restarting": restart})
}
// triggerRestartIfBackend déclenche un redémarrage Docker si le module a du code backend.
// Retourne true si un redémarrage a été déclenché.
func (h *SettingsHandler) triggerRestartIfBackend(moduleID string) bool {
if h.docker == nil || !h.docker.Available() {
return false
}
var hasBackend int
if err := h.db.QueryRow(`SELECT has_backend FROM modules WHERE id = ?`, moduleID).Scan(&hasBackend); err != nil {
return false
}
if hasBackend == 1 {
h.docker.Restart()
return true
}
return false
}
// enabledBackendModuleIDs retourne les IDs de tous les modules installés avec has_backend=1.
// Ces modules doivent être compilés dans le binaire lors d'un rebuild.
func (h *SettingsHandler) enabledBackendModuleIDs() ([]string, error) {
rows, err := h.db.Query(`SELECT id FROM modules WHERE has_backend = 1`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
rows.Scan(&id)
ids = append(ids, id)
}
return ids, nil
}
// GetLogs retourne les dernières lignes du log applicatif (tampon mémoire).
// GET /api/settings/logs?lines=200
func (h *SettingsHandler) GetLogs(w http.ResponseWriter, r *http.Request) {
n := 200
if s := r.URL.Query().Get("lines"); s != "" {
if v, err := strconv.Atoi(s); err == nil && v > 0 {
n = v
}
}
lines := logbuffer.Global.Lines(n)
if lines == nil {
lines = []string{}
}
JSONResponse(w, http.StatusOK, lines)
}
// GetAuditLog retourne le journal d'audit paginé.
// GET /api/settings/audit
func (h *SettingsHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) {
rows, err := h.db.Query(`
SELECT id, username, action, resource, details, ip, created_at
FROM audit_log ORDER BY created_at DESC LIMIT 100
`)
if err != nil {
JSONError(w, "Erreur lecture audit", http.StatusInternalServerError)
return
}
defer rows.Close()
type entry struct {
ID int64 `json:"id"`
Username string `json:"username"`
Action string `json:"action"`
Resource *string `json:"resource,omitempty"`
Details *string `json:"details,omitempty"`
IP *string `json:"ip,omitempty"`
CreatedAt string `json:"created_at"`
}
var entries []entry
for rows.Next() {
var e entry
var resource, details, ip *string
rows.Scan(&e.ID, &e.Username, &e.Action, &resource, &details, &ip, &e.CreatedAt)
e.Resource = resource
e.Details = details
e.IP = ip
entries = append(entries, e)
}
JSONResponse(w, http.StatusOK, entries)
}
// RegistryModule représente un module disponible dans le store Forgejo.
type RegistryModule struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
RepoURL string `json:"repo_url"`
Installed bool `json:"installed"`
}
// GetRegistryModules liste les repos de l'organisation proxmoxPanel sur Forgejo.
// GET /api/registry/modules
func (h *SettingsHandler) GetRegistryModules(w http.ResponseWriter, r *http.Request) {
// Récupérer les modules déjà installés en DB
rows, err := h.db.Query(`SELECT id FROM modules`)
if err != nil {
JSONError(w, "Erreur lecture modules", http.StatusInternalServerError)
return
}
installed := make(map[string]bool)
for rows.Next() {
var id string
rows.Scan(&id)
installed[id] = true
}
rows.Close()
// Appel à l'API Forgejo
resp, err := http.Get("https://git.geronzi.fr/api/v1/repos/search?q=&owner=proxmoxPanel&limit=50")
if err != nil {
JSONError(w, fmt.Sprintf("Erreur accès store : %v", err), http.StatusBadGateway)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
JSONError(w, "Erreur lecture réponse store", http.StatusBadGateway)
return
}
// Structure de réponse Gitea/Forgejo
var forgejoResp struct {
Data []struct {
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
HTMLURL string `json:"html_url"`
} `json:"data"`
}
if err := json.Unmarshal(body, &forgejoResp); err != nil {
JSONError(w, "Erreur parsing réponse store", http.StatusBadGateway)
return
}
var modules []RegistryModule
for _, repo := range forgejoResp.Data {
// Exclure le repo "core"
if repo.Name == "core" {
continue
}
modules = append(modules, RegistryModule{
ID: repo.Name,
Name: repo.Name,
Description: repo.Description,
RepoURL: repo.HTMLURL,
Installed: installed[repo.Name],
})
}
if modules == nil {
modules = []RegistryModule{}
}
JSONResponse(w, http.StatusOK, modules)
}
// moduleJSON représente le fichier module.json d'un module.
type moduleJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
NavHref string `json:"nav_href"`
NavIcon string `json:"nav_icon"`
NavColor string `json:"nav_color"`
NavLabelKey string `json:"nav_label_key"`
CoreMinVersion string `json:"core_min_version"`
HasBackend bool `json:"has_backend"` // true = nécessite un rebuild Docker pour compilation
}
// InstallRegistryModule installe un module depuis le store Forgejo.
// POST /api/registry/modules/{id}/install
func (h *SettingsHandler) InstallRegistryModule(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r)
id := chi.URLParam(r, "id")
// Récupérer module.json depuis Forgejo
url := fmt.Sprintf("https://git.geronzi.fr/api/v1/repos/proxmoxPanel/%s/raw/module.json?ref=main", id)
resp, err := http.Get(url)
if err != nil {
JSONError(w, fmt.Sprintf("Impossible d'accéder au module %s : %v", id, err), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
JSONError(w, fmt.Sprintf("module.json introuvable pour %s", id), http.StatusNotFound)
return
}
if resp.StatusCode != http.StatusOK {
JSONError(w, fmt.Sprintf("Erreur récupération module.json : HTTP %d", resp.StatusCode), http.StatusBadGateway)
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
JSONError(w, "Erreur lecture module.json", http.StatusBadGateway)
return
}
var mod moduleJSON
if err := json.Unmarshal(body, &mod); err != nil {
JSONError(w, "Erreur parsing module.json", http.StatusBadGateway)
return
}
// Valider l'ID
if mod.ID == "" {
mod.ID = id
}
if mod.Version == "" {
mod.Version = "1.0.0"
}
// URL du repo
repoURL := fmt.Sprintf("https://git.geronzi.fr/proxmoxPanel/%s", id)
hasBackend := 0
if mod.HasBackend {
hasBackend = 1
}
// Insérer ou remplacer en DB
_, err = h.db.Exec(`
INSERT INTO modules (id, name, description, version, is_core, is_enabled, has_backend,
nav_href, nav_icon, nav_color, nav_label_key, repo_url, installed_at)
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name, description=excluded.description, version=excluded.version,
has_backend=excluded.has_backend,
nav_href=excluded.nav_href, nav_icon=excluded.nav_icon, nav_color=excluded.nav_color,
nav_label_key=excluded.nav_label_key, repo_url=excluded.repo_url,
installed_at=CURRENT_TIMESTAMP
`, mod.ID, mod.Name, mod.Description, mod.Version, hasBackend,
mod.NavHref, mod.NavIcon, mod.NavColor, mod.NavLabelKey, repoURL)
if err != nil {
JSONError(w, fmt.Sprintf("Erreur installation module : %v", err), http.StatusInternalServerError)
return
}
h.auditLogger.Log(&claims.UserID, claims.Username, "module_install", mod.ID,
map[string]string{"version": mod.Version, "has_backend": fmt.Sprintf("%v", mod.HasBackend)}, clientIP(r))
// Si le module a du code backend : déclencher un rebuild Docker.
// Le rebuild compile le nouveau module dans le binaire et recrée le container.
rebuilding := false
if mod.HasBackend && h.docker != nil && h.docker.Available() {
moduleIDs, err := h.enabledBackendModuleIDs()
if err == nil {
h.docker.RebuildAndRestart(moduleIDs)
rebuilding = true
}
}
JSONResponse(w, http.StatusAccepted, map[string]interface{}{
"message": fmt.Sprintf("Module %s installé", mod.ID),
"rebuilding": rebuilding,
})
}