feat: initialisation complète du CORE ProxmoxPanel
Backend Go 1.23+ : - API REST + WebSocket (chi, gorilla/websocket) - Authentification PAM via SSH + JWT RS256 - Chiffrement AES-256-GCM pour secrets SQLite - Pool SSH, client Proxmox REST, hub WebSocket pub/sub - Système de modules compilés à initialisation conditionnelle - Audit log, migrations SQLite versionnées Frontend Vue 3 + Vite + TypeScript : - Thème Neumorphism sombre/clair (CSS custom properties) - Wizard d'installation, Dashboard drag-drop, Terminal xterm.js - Toutes les vues CORE + stubs modules optionnels - i18n EN/FR (vue-i18n v11) Infrastructure : - Docker multi-stage (Go → alpine, Node → nginx) - docker-compose.yml, .gitattributes, LICENSE MIT, README Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
5dbcb1df07
66 changed files with 10370 additions and 0 deletions
297
backend/internal/api/auth.go
Normal file
297
backend/internal/api/auth.go
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
// Handlers d'authentification : login PAM, logout, refresh token, profil utilisateur.
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/auth"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
|
||||
)
|
||||
|
||||
// AuthHandler contient les handlers d'authentification.
|
||||
type AuthHandler struct {
|
||||
db *db.DB
|
||||
jwtManager *auth.JWTManager
|
||||
sshAuth *auth.SSHAuthenticator
|
||||
auditLogger *audit.Logger
|
||||
authLimiter *RateLimiter
|
||||
}
|
||||
|
||||
// NewAuthHandler crée un AuthHandler.
|
||||
func NewAuthHandler(database *db.DB, jwtMgr *auth.JWTManager, sshAuth *auth.SSHAuthenticator, auditLog *audit.Logger) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
db: database,
|
||||
jwtManager: jwtMgr,
|
||||
sshAuth: sshAuth,
|
||||
auditLogger: auditLog,
|
||||
authLimiter: NewRateLimiter(5, time.Minute), // 5 tentatives par minute par IP
|
||||
}
|
||||
}
|
||||
|
||||
// Login authentifie un utilisateur via ses credentials Linux (PAM via SSH).
|
||||
// POST /api/auth/login
|
||||
// Body: { "username": "enzo", "password": "..." }
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
|
||||
// Rate limiting sur le login
|
||||
if !h.authLimiter.Allow(ip) {
|
||||
h.auditLogger.Log(nil, "?", "login_rate_limited", "", nil, ip)
|
||||
JSONError(w, "Trop de tentatives de connexion, veuillez patienter", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
JSONError(w, "Corps de requête invalide", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Username == "" || body.Password == "" {
|
||||
JSONError(w, "Nom d'utilisateur et mot de passe requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Authentification PAM via SSH
|
||||
userInfo, err := h.sshAuth.Authenticate(body.Username, body.Password)
|
||||
if err != nil {
|
||||
h.auditLogger.Log(nil, body.Username, "login_failed", "", map[string]string{"error": err.Error()}, ip)
|
||||
JSONError(w, "Identifiants invalides", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Créer ou mettre à jour le profil utilisateur en SQLite
|
||||
userID, err := h.upsertUser(userInfo)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur création profil utilisateur", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Générer les tokens JWT
|
||||
accessToken, err := h.jwtManager.GenerateAccessToken(userID, userInfo.Username, userInfo.IsAdmin)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur génération token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken, err := h.jwtManager.GenerateRefreshToken(userID)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur génération refresh token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Stocker le hash du refresh token en base pour permettre la révocation
|
||||
tokenHash := hashToken(refreshToken)
|
||||
expiry := time.Now().Add(auth.RefreshTokenDuration())
|
||||
h.db.Exec(`
|
||||
INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)
|
||||
`, userID, tokenHash, expiry)
|
||||
|
||||
// Mettre à jour la date de dernier login
|
||||
h.db.Exec(`UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?`, userID)
|
||||
|
||||
// Cookie httpOnly pour le refresh token
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "pxp_refresh",
|
||||
Value: refreshToken,
|
||||
Path: "/api/auth/refresh",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: expiry,
|
||||
})
|
||||
|
||||
h.auditLogger.Log(&userID, userInfo.Username, "login_success", "", nil, ip)
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"access_token": accessToken,
|
||||
"expires_in": 900, // 15 minutes en secondes
|
||||
"user": map[string]any{
|
||||
"id": userID,
|
||||
"username": userInfo.Username,
|
||||
"is_admin": userInfo.IsAdmin,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Logout invalide la session de l'utilisateur.
|
||||
// POST /api/auth/logout
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
|
||||
// Supprimer tous les refresh tokens de cet utilisateur
|
||||
if claims != nil {
|
||||
h.db.Exec(`DELETE FROM refresh_tokens WHERE user_id = ?`, claims.UserID)
|
||||
h.auditLogger.Log(&claims.UserID, claims.Username, "logout", "", nil, clientIP(r))
|
||||
}
|
||||
|
||||
// Effacer le cookie de refresh
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "pxp_refresh",
|
||||
Value: "",
|
||||
Path: "/api/auth/refresh",
|
||||
HttpOnly: true,
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]string{"message": "Déconnexion réussie"})
|
||||
}
|
||||
|
||||
// Refresh renouvelle l'access token via le refresh token (cookie httpOnly).
|
||||
// POST /api/auth/refresh
|
||||
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("pxp_refresh")
|
||||
if err != nil {
|
||||
JSONError(w, "Refresh token manquant", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := h.jwtManager.ValidateRefreshToken(cookie.Value)
|
||||
if err != nil {
|
||||
JSONError(w, "Refresh token invalide ou expiré", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que le token est en base (non révoqué)
|
||||
tokenHash := hashToken(cookie.Value)
|
||||
var count int
|
||||
h.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens WHERE user_id = ? AND token_hash = ? AND expires_at > CURRENT_TIMESTAMP`, userID, tokenHash).Scan(&count)
|
||||
if count == 0 {
|
||||
JSONError(w, "Session expirée ou révoquée", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les infos utilisateur
|
||||
var username string
|
||||
var isAdmin int
|
||||
err = h.db.QueryRow(`SELECT username, is_admin FROM users WHERE id = ?`, userID).Scan(&username, &isAdmin)
|
||||
if err != nil {
|
||||
JSONError(w, "Utilisateur introuvable", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := h.jwtManager.GenerateAccessToken(userID, username, isAdmin == 1)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur génération token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"access_token": accessToken,
|
||||
"expires_in": 900,
|
||||
})
|
||||
}
|
||||
|
||||
// Me retourne le profil de l'utilisateur connecté.
|
||||
// GET /api/auth/me
|
||||
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
if claims == nil {
|
||||
JSONError(w, "Non authentifié", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var lang, theme, sidebarPos string
|
||||
var lastLogin sql.NullTime
|
||||
err := h.db.QueryRow(`SELECT lang, theme, sidebar_position, last_login_at FROM users WHERE id = ?`, claims.UserID).
|
||||
Scan(&lang, &theme, &sidebarPos, &lastLogin)
|
||||
if err != nil {
|
||||
JSONError(w, "Profil introuvable", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"id": claims.UserID,
|
||||
"username": claims.Username,
|
||||
"is_admin": claims.IsAdmin,
|
||||
"lang": lang,
|
||||
"theme": theme,
|
||||
"sidebar_position": sidebarPos,
|
||||
}
|
||||
if lastLogin.Valid {
|
||||
resp["last_login_at"] = lastLogin.Time
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// UpdatePreferences met à jour les préférences de l'utilisateur connecté.
|
||||
// PATCH /api/auth/preferences
|
||||
func (h *AuthHandler) UpdatePreferences(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
|
||||
var body struct {
|
||||
Lang *string `json:"lang"`
|
||||
Theme *string `json:"theme"`
|
||||
SidebarPosition *string `json:"sidebar_position"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
JSONError(w, "Corps de requête invalide", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Lang != nil {
|
||||
if !isValidLang(*body.Lang) {
|
||||
JSONError(w, "Langue non supportée", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.db.Exec(`UPDATE users SET lang = ? WHERE id = ?`, *body.Lang, claims.UserID)
|
||||
}
|
||||
if body.Theme != nil {
|
||||
if *body.Theme != "dark" && *body.Theme != "light" {
|
||||
JSONError(w, "Thème invalide (dark ou light)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.db.Exec(`UPDATE users SET theme = ? WHERE id = ?`, *body.Theme, claims.UserID)
|
||||
}
|
||||
if body.SidebarPosition != nil {
|
||||
if *body.SidebarPosition != "left" && *body.SidebarPosition != "right" {
|
||||
JSONError(w, "Position sidebar invalide (left ou right)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.db.Exec(`UPDATE users SET sidebar_position = ? WHERE id = ?`, *body.SidebarPosition, claims.UserID)
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]string{"message": "Préférences mises à jour"})
|
||||
}
|
||||
|
||||
// upsertUser crée ou met à jour le profil utilisateur en SQLite.
|
||||
// Retourne l'ID de l'utilisateur.
|
||||
func (h *AuthHandler) upsertUser(info *auth.UserInfo) (int64, error) {
|
||||
isAdmin := 0
|
||||
if info.IsAdmin {
|
||||
isAdmin = 1
|
||||
}
|
||||
|
||||
// Mise à jour du statut admin à chaque connexion (peut changer côté Linux)
|
||||
result, err := h.db.Exec(`
|
||||
INSERT INTO users (username, is_admin) VALUES (?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET is_admin = excluded.is_admin
|
||||
`, info.Username, isAdmin)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Tenter de récupérer l'ID (insertions ou update)
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil || id == 0 {
|
||||
// En cas de ON CONFLICT DO UPDATE, LastInsertId peut retourner 0
|
||||
err = h.db.QueryRow(`SELECT id FROM users WHERE username = ?`, info.Username).Scan(&id)
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// hashToken crée un hash SHA-256 d'un token pour le stockage en base.
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
16
backend/internal/api/helpers.go
Normal file
16
backend/internal/api/helpers.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Fonctions utilitaires partagées entre les handlers API.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// decodeJSON décode le corps JSON d'une requête dans dest.
|
||||
// Retourne une erreur si le corps est invalide ou manquant.
|
||||
func decodeJSON(r *http.Request, dest any) error {
|
||||
if r.Body == nil {
|
||||
return json.NewDecoder(r.Body).Decode(dest)
|
||||
}
|
||||
return json.NewDecoder(r.Body).Decode(dest)
|
||||
}
|
||||
233
backend/internal/api/install.go
Normal file
233
backend/internal/api/install.go
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// Handlers pour la page d'installation — premier lancement uniquement.
|
||||
// Ces routes sont accessibles sans authentification mais bloquées après installation.
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/auth"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
|
||||
)
|
||||
|
||||
// InstallHandler contient les handlers d'installation.
|
||||
type InstallHandler struct {
|
||||
db *db.DB
|
||||
encryptor *crypto.Encryptor
|
||||
}
|
||||
|
||||
// NewInstallHandler crée un InstallHandler.
|
||||
func NewInstallHandler(database *db.DB, enc *crypto.Encryptor) *InstallHandler {
|
||||
return &InstallHandler{db: database, encryptor: enc}
|
||||
}
|
||||
|
||||
// GetStatus retourne l'état d'installation et les valeurs pré-remplies.
|
||||
// GET /api/install/status
|
||||
func (h *InstallHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
installed, err := h.db.IsInstalled()
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur base de données", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Pré-remplir l'URL publique depuis le header Host
|
||||
detectedURL := detectPublicURL(r)
|
||||
detectedPort := detectPort(r)
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"installed": installed,
|
||||
"detected_url": detectedURL,
|
||||
"detected_port": detectedPort,
|
||||
})
|
||||
}
|
||||
|
||||
// TestSSH teste la connexion SSH vers le host Proxmox.
|
||||
// POST /api/install/test-ssh
|
||||
// Body: { "host": "10.0.0.1:2244", "username": "enzo", "password": "..." }
|
||||
func (h *InstallHandler) TestSSH(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Host string `json:"host"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
JSONError(w, "Corps de requête invalide", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Host == "" || body.Username == "" || body.Password == "" {
|
||||
JSONError(w, "Paramètres host, username et password requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Valider le format host:port
|
||||
if _, _, err := net.SplitHostPort(body.Host); err != nil {
|
||||
JSONError(w, "Format host invalide (attendu: host:port)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Test de connectivité réseau d'abord
|
||||
if err := auth.TestConnectivity(body.Host, 5*time.Second); err != nil {
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("Impossible de joindre %s : %v", body.Host, err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Test d'authentification SSH
|
||||
if err := auth.TestSSHAuth(body.Host, body.Username, body.Password); err != nil {
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"message": "Connexion SSH réussie",
|
||||
})
|
||||
}
|
||||
|
||||
// TestProxmoxToken teste le token API Proxmox.
|
||||
// POST /api/install/test-proxmox
|
||||
// Body: { "url": "https://10.0.0.1:8006", "token": "PVEAPIToken=..." }
|
||||
func (h *InstallHandler) TestProxmoxToken(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
JSONError(w, "Corps de requête invalide", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Import dynamique évité — on laisse le handler proxmox gérer ça plus tard
|
||||
// Pour l'installation, on fait un test simple via HTTP
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"message": "Token enregistré (validation au prochain démarrage)",
|
||||
})
|
||||
}
|
||||
|
||||
// Configure enregistre la configuration initiale et marque l'app comme installée.
|
||||
// POST /api/install/configure
|
||||
func (h *InstallHandler) Configure(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
InstanceName string `json:"instance_name"`
|
||||
PublicURL string `json:"public_url"`
|
||||
DefaultLang string `json:"default_lang"`
|
||||
SSHHost string `json:"ssh_host"`
|
||||
SSHUsername string `json:"ssh_username"`
|
||||
SSHPassword string `json:"ssh_password"`
|
||||
ProxmoxURL string `json:"proxmox_url"`
|
||||
ProxmoxToken string `json:"proxmox_token"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
JSONError(w, "Corps de requête invalide", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validation basique
|
||||
if body.InstanceName == "" {
|
||||
JSONError(w, "Le nom de l'instance est requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.SSHHost == "" || body.SSHUsername == "" || body.SSHPassword == "" {
|
||||
JSONError(w, "Les paramètres SSH sont requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.DefaultLang == "" {
|
||||
body.DefaultLang = "en"
|
||||
}
|
||||
if !isValidLang(body.DefaultLang) {
|
||||
JSONError(w, "Langue non supportée (en ou fr)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Sauvegarder les paramètres non-sensibles en clair
|
||||
settings := map[string]string{
|
||||
"instance_name": body.InstanceName,
|
||||
"public_url": body.PublicURL,
|
||||
"default_lang": body.DefaultLang,
|
||||
"proxmox_url": body.ProxmoxURL,
|
||||
"ssh_host": body.SSHHost,
|
||||
"ssh_username": body.SSHUsername,
|
||||
}
|
||||
for key, value := range settings {
|
||||
if err := h.db.SetSetting(key, value, false); err != nil {
|
||||
JSONError(w, "Erreur sauvegarde configuration : "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Chiffrer et sauvegarder les secrets sensibles
|
||||
if body.SSHPassword != "" {
|
||||
encrypted, err := h.encryptor.Encrypt(body.SSHPassword)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur chiffrement mot de passe SSH : "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.db.SetSetting("ssh_password", encrypted, true)
|
||||
}
|
||||
if body.ProxmoxToken != "" {
|
||||
encrypted, err := h.encryptor.Encrypt(body.ProxmoxToken)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur chiffrement token Proxmox : "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.db.SetSetting("proxmox_token", encrypted, true)
|
||||
}
|
||||
|
||||
// Marquer l'application comme installée
|
||||
if err := h.db.SetSetting("installed", "true", false); err != nil {
|
||||
JSONError(w, "Erreur finalisation installation", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"message": "Installation terminée avec succès",
|
||||
})
|
||||
}
|
||||
|
||||
// detectPublicURL inférer l'URL publique depuis les headers de la requête entrante.
|
||||
func detectPublicURL(r *http.Request) string {
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
proto := "https"
|
||||
if r.Header.Get("X-Forwarded-Proto") == "http" || (!strings.Contains(host, ".") && !strings.Contains(host, ":")) {
|
||||
proto = "http"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", proto, host)
|
||||
}
|
||||
|
||||
// detectPort extrait le port depuis le header ou l'adresse de connexion.
|
||||
func detectPort(r *http.Request) string {
|
||||
host := r.Host
|
||||
if _, port, err := net.SplitHostPort(host); err == nil {
|
||||
return port
|
||||
}
|
||||
if r.TLS != nil {
|
||||
return "443"
|
||||
}
|
||||
return "80"
|
||||
}
|
||||
|
||||
// isValidLang vérifie que le code langue est supporté.
|
||||
func isValidLang(lang string) bool {
|
||||
supported := []string{"en", "fr"}
|
||||
for _, l := range supported {
|
||||
if l == lang {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
185
backend/internal/api/middleware.go
Normal file
185
backend/internal/api/middleware.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// Package api contient tous les handlers HTTP et les middlewares de ProxmoxPanel.
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/auth"
|
||||
)
|
||||
|
||||
// Clés de contexte pour transmettre les claims JWT aux handlers.
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
ClaimsKey contextKey = "claims"
|
||||
)
|
||||
|
||||
// RateLimiter est un simple rate limiter par IP basé sur un token bucket.
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
maxReq int
|
||||
window time.Duration
|
||||
cleanTicker *time.Ticker
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
count int
|
||||
resetAt time.Time
|
||||
}
|
||||
|
||||
// NewRateLimiter crée un rate limiter avec maxReq requêtes par fenêtre temporelle.
|
||||
func NewRateLimiter(maxReq int, window time.Duration) *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
buckets: make(map[string]*bucket),
|
||||
maxReq: maxReq,
|
||||
window: window,
|
||||
cleanTicker: time.NewTicker(5 * time.Minute),
|
||||
}
|
||||
go rl.cleanup()
|
||||
return rl
|
||||
}
|
||||
|
||||
// Allow vérifie si une IP peut effectuer une requête supplémentaire.
|
||||
func (rl *RateLimiter) Allow(ip string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
b, exists := rl.buckets[ip]
|
||||
if !exists || time.Now().After(b.resetAt) {
|
||||
rl.buckets[ip] = &bucket{count: 1, resetAt: time.Now().Add(rl.window)}
|
||||
return true
|
||||
}
|
||||
if b.count >= rl.maxReq {
|
||||
return false
|
||||
}
|
||||
b.count++
|
||||
return true
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) cleanup() {
|
||||
for range rl.cleanTicker.C {
|
||||
rl.mu.Lock()
|
||||
now := time.Now()
|
||||
for ip, b := range rl.buckets {
|
||||
if now.After(b.resetAt) {
|
||||
delete(rl.buckets, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware sécurité : headers HTTP protecteurs.
|
||||
func SecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
// CSP assez souple pour permettre les WebSockets et les assets locaux
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// RequireAuth est le middleware d'authentification JWT.
|
||||
// Il extrait et valide le Bearer token depuis l'en-tête Authorization.
|
||||
func RequireAuth(jwtManager *auth.JWTManager) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenStr := extractBearerToken(r)
|
||||
if tokenStr == "" {
|
||||
JSONError(w, "Token d'authentification manquant", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := jwtManager.ValidateAccessToken(tokenStr)
|
||||
if err != nil {
|
||||
JSONError(w, "Token invalide ou expiré", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Injecter les claims dans le contexte
|
||||
ctx := context.WithValue(r.Context(), ClaimsKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin vérifie que l'utilisateur connecté est administrateur.
|
||||
func RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
if claims == nil || !claims.IsAdmin {
|
||||
JSONError(w, "Accès réservé aux administrateurs", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// RateLimit crée un middleware de rate limiting pour les endpoints sensibles.
|
||||
func RateLimit(limiter *RateLimiter) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
if !limiter.Allow(ip) {
|
||||
JSONError(w, "Trop de requêtes, veuillez patienter", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetClaims extrait les claims JWT du contexte de la requête.
|
||||
func GetClaims(r *http.Request) *auth.Claims {
|
||||
claims, _ := r.Context().Value(ClaimsKey).(*auth.Claims)
|
||||
return claims
|
||||
}
|
||||
|
||||
// extractBearerToken extrait le token JWT depuis l'en-tête Authorization.
|
||||
func extractBearerToken(r *http.Request) string {
|
||||
header := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(header, "Bearer ") {
|
||||
return strings.TrimPrefix(header, "Bearer ")
|
||||
}
|
||||
// Fallback sur le query param (pour les WebSockets qui ne supportent pas les headers custom)
|
||||
return r.URL.Query().Get("token")
|
||||
}
|
||||
|
||||
// clientIP extrait l'IP réelle du client (en tenant compte des proxys).
|
||||
func clientIP(r *http.Request) string {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
parts := strings.Split(fwd, ",")
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
|
||||
return realIP
|
||||
}
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// JSONResponse envoie une réponse JSON avec le code HTTP donné.
|
||||
func JSONResponse(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// JSONError envoie une réponse d'erreur JSON standardisée.
|
||||
func JSONError(w http.ResponseWriter, message string, status int) {
|
||||
JSONResponse(w, status, map[string]string{"error": message})
|
||||
}
|
||||
217
backend/internal/api/proxmox.go
Normal file
217
backend/internal/api/proxmox.go
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
// Handlers pour l'API Proxmox : liste LXC/VM, démarrage/arrêt, WebSocket temps réel.
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"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/proxmox"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/websocket"
|
||||
gorillaws "github.com/gorilla/websocket"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
var upgrader = gorillaws.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
}
|
||||
|
||||
// ProxmoxHandler contient les handlers Proxmox.
|
||||
type ProxmoxHandler struct {
|
||||
db *db.DB
|
||||
hub *websocket.Hub
|
||||
auditLogger *audit.Logger
|
||||
encryptor *crypto.Encryptor
|
||||
client *proxmox.Client // Peut être nil si pas encore configuré
|
||||
}
|
||||
|
||||
// NewProxmoxHandler crée un ProxmoxHandler.
|
||||
func NewProxmoxHandler(database *db.DB, hub *websocket.Hub, auditLog *audit.Logger, enc *crypto.Encryptor) *ProxmoxHandler {
|
||||
h := &ProxmoxHandler{
|
||||
db: database,
|
||||
hub: hub,
|
||||
auditLogger: auditLog,
|
||||
encryptor: enc,
|
||||
}
|
||||
// Initialiser le client Proxmox depuis la config SQLite
|
||||
h.initClient()
|
||||
return h
|
||||
}
|
||||
|
||||
// initClient recharge le client Proxmox depuis les settings SQLite.
|
||||
func (h *ProxmoxHandler) initClient() {
|
||||
url, _, _ := h.db.GetSetting("proxmox_url")
|
||||
encryptedToken, _, _ := h.db.GetSetting("proxmox_token")
|
||||
if url == "" || encryptedToken == "" {
|
||||
return
|
||||
}
|
||||
token, err := h.encryptor.Decrypt(encryptedToken)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
h.client = proxmox.NewClient(url, token)
|
||||
}
|
||||
|
||||
// GetResources retourne la liste de toutes les ressources Proxmox (LXC + VM + nodes).
|
||||
// GET /api/proxmox/resources
|
||||
func (h *ProxmoxHandler) GetResources(w http.ResponseWriter, r *http.Request) {
|
||||
if h.client == nil {
|
||||
h.initClient()
|
||||
}
|
||||
if h.client == nil {
|
||||
JSONError(w, "Proxmox non configuré", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
resources, err := h.client.GetResources("")
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur API Proxmox : "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, resources)
|
||||
}
|
||||
|
||||
// GetLXC retourne uniquement les conteneurs LXC.
|
||||
// GET /api/proxmox/lxc
|
||||
func (h *ProxmoxHandler) GetLXC(w http.ResponseWriter, r *http.Request) {
|
||||
if h.client == nil {
|
||||
h.initClient()
|
||||
}
|
||||
if h.client == nil {
|
||||
JSONError(w, "Proxmox non configuré", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
lxcs, err := h.client.GetLXCList()
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur API Proxmox : "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, lxcs)
|
||||
}
|
||||
|
||||
// StartLXC démarre un conteneur LXC.
|
||||
// POST /api/proxmox/lxc/{vmid}/start
|
||||
func (h *ProxmoxHandler) StartLXC(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
vmid, node, err := h.extractVMID(r)
|
||||
if err != nil {
|
||||
JSONError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if h.client == nil {
|
||||
h.initClient()
|
||||
}
|
||||
if h.client == nil {
|
||||
JSONError(w, "Proxmox non configuré", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.client.StartLXC(node, vmid); err != nil {
|
||||
JSONError(w, "Erreur démarrage LXC : "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
h.auditLogger.Log(&claims.UserID, claims.Username, "lxc_start", strconv.Itoa(vmid), nil, clientIP(r))
|
||||
JSONResponse(w, http.StatusOK, map[string]string{"message": "LXC démarré"})
|
||||
}
|
||||
|
||||
// StopLXC arrête un conteneur LXC.
|
||||
// POST /api/proxmox/lxc/{vmid}/stop
|
||||
func (h *ProxmoxHandler) StopLXC(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
vmid, node, err := h.extractVMID(r)
|
||||
if err != nil {
|
||||
JSONError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if h.client == nil {
|
||||
h.initClient()
|
||||
}
|
||||
|
||||
if err := h.client.StopLXC(node, vmid); err != nil {
|
||||
JSONError(w, "Erreur arrêt LXC : "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
h.auditLogger.Log(&claims.UserID, claims.Username, "lxc_stop", strconv.Itoa(vmid), nil, clientIP(r))
|
||||
JSONResponse(w, http.StatusOK, map[string]string{"message": "LXC arrêté"})
|
||||
}
|
||||
|
||||
// WebSocket retourne un WebSocket qui envoie les mises à jour Proxmox en temps réel.
|
||||
// GET /ws/proxmox
|
||||
func (h *ProxmoxHandler) WebSocket(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
|
||||
}
|
||||
|
||||
client := h.hub.NewClient(conn, userID)
|
||||
client.Subscribe("proxmox")
|
||||
}
|
||||
|
||||
// StartPolling démarre le polling périodique de l'API Proxmox et publie les updates via WebSocket.
|
||||
// À appeler au démarrage du serveur.
|
||||
func (h *ProxmoxHandler) StartPolling() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if h.client == nil {
|
||||
h.initClient()
|
||||
if h.client == nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
resources, err := h.client.GetResources("")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
h.hub.Publish("proxmox", "resources_update", resources)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// extractVMID extrait l'ID de VM et le nom du nœud depuis l'URL et les query params.
|
||||
func (h *ProxmoxHandler) extractVMID(r *http.Request) (int, string, error) {
|
||||
vmidStr := chi.URLParam(r, "vmid")
|
||||
vmid, err := strconv.Atoi(vmidStr)
|
||||
if err != nil {
|
||||
return 0, "", &invalidParamError{param: "vmid", value: vmidStr}
|
||||
}
|
||||
|
||||
node := r.URL.Query().Get("node")
|
||||
if node == "" {
|
||||
node = "pve" // Nœud par défaut
|
||||
}
|
||||
|
||||
return vmid, node, nil
|
||||
}
|
||||
|
||||
type invalidParamError struct {
|
||||
param string
|
||||
value string
|
||||
}
|
||||
|
||||
func (e *invalidParamError) Error() string {
|
||||
return "Paramètre invalide : " + e.param + " = " + e.value
|
||||
}
|
||||
206
backend/internal/api/settings.go
Normal file
206
backend/internal/api/settings.go
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
// Handlers pour la page paramètres : lecture/écriture de la configuration globale.
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// SettingsHandler contient les handlers de configuration.
|
||||
type SettingsHandler struct {
|
||||
db *db.DB
|
||||
auditLogger *audit.Logger
|
||||
}
|
||||
|
||||
// NewSettingsHandler crée un SettingsHandler.
|
||||
func NewSettingsHandler(database *db.DB, auditLog *audit.Logger) *SettingsHandler {
|
||||
return &SettingsHandler{db: database, auditLogger: auditLog}
|
||||
}
|
||||
|
||||
// paramètres publics (non-sensibles) accessibles par les admins.
|
||||
var publicSettings = []string{
|
||||
"instance_name",
|
||||
"public_url",
|
||||
"default_lang",
|
||||
"proxmox_url",
|
||||
"ssh_host",
|
||||
"ssh_username",
|
||||
}
|
||||
|
||||
// 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
|
||||
allowed := false
|
||||
for _, k := range publicSettings {
|
||||
if k == key {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
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
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
// 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, installed_at
|
||||
FROM modules ORDER BY is_core DESC, name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur lecture modules", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type module 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"`
|
||||
InstalledAt *string `json:"installed_at,omitempty"`
|
||||
}
|
||||
|
||||
var modules []module
|
||||
for rows.Next() {
|
||||
var m module
|
||||
var isCore, isEnabled int
|
||||
var installedAt *string
|
||||
rows.Scan(&m.ID, &m.Name, &m.Description, &m.Version, &isCore, &isEnabled, &installedAt)
|
||||
m.IsCore = isCore == 1
|
||||
m.IsEnabled = isEnabled == 1
|
||||
m.InstalledAt = installedAt
|
||||
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))
|
||||
JSONResponse(w, http.StatusOK, map[string]string{"message": "Module activé (redémarrage requis pour prendre effet)"})
|
||||
}
|
||||
|
||||
// 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))
|
||||
JSONResponse(w, http.StatusOK, map[string]string{"message": "Module désactivé"})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
151
backend/internal/api/terminal.go
Normal file
151
backend/internal/api/terminal.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
// Handler pour le terminal SSH interactif via WebSocket + PTY.
|
||||
// Utilise golang.org/x/crypto/ssh pour la connexion et gorilla/websocket pour le transport.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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"
|
||||
gorillaws "github.com/gorilla/websocket"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// TerminalHandler gère les sessions de terminal SSH interactif.
|
||||
type TerminalHandler struct {
|
||||
db *db.DB
|
||||
auditLogger *audit.Logger
|
||||
encryptor *crypto.Encryptor
|
||||
}
|
||||
|
||||
// NewTerminalHandler crée un TerminalHandler.
|
||||
func NewTerminalHandler(database *db.DB, auditLog *audit.Logger, enc *crypto.Encryptor) *TerminalHandler {
|
||||
return &TerminalHandler{db: database, auditLogger: auditLog, encryptor: enc}
|
||||
}
|
||||
|
||||
// terminalCmd représente un message de contrôle envoyé via WebSocket.
|
||||
type terminalCmd struct {
|
||||
Type string `json:"type"` // "resize" | "data"
|
||||
Cols int `json:"cols,omitempty"`
|
||||
Rows int `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
// WebSocket ouvre un terminal SSH interactif via WebSocket.
|
||||
// GET /ws/terminal
|
||||
// Query params: host (optionnel, défaut = ssh_host depuis config)
|
||||
func (h *TerminalHandler) WebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
|
||||
// Connexion WebSocket
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Récupérer les params SSH
|
||||
sshHost := r.URL.Query().Get("host")
|
||||
if sshHost == "" {
|
||||
sshHost, _, _ = h.db.GetSetting("ssh_host")
|
||||
}
|
||||
sshUser, _, _ := h.db.GetSetting("ssh_username")
|
||||
encryptedPass, _, _ := h.db.GetSetting("ssh_password")
|
||||
sshPass, _ := h.encryptor.Decrypt(encryptedPass)
|
||||
|
||||
if sshHost == "" {
|
||||
conn.WriteMessage(gorillaws.TextMessage, []byte("\r\nErreur : SSH non configuré\r\n"))
|
||||
return
|
||||
}
|
||||
|
||||
h.auditLogger.Log(&claims.UserID, claims.Username, "terminal_open", sshHost, nil, clientIP(r))
|
||||
|
||||
// Établir la connexion SSH
|
||||
sshConfig := &gossh.ClientConfig{
|
||||
User: sshUser,
|
||||
Auth: []gossh.AuthMethod{
|
||||
gossh.Password(sshPass),
|
||||
},
|
||||
Timeout: 15 * time.Second,
|
||||
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
sshClient, err := gossh.Dial("tcp", sshHost, sshConfig)
|
||||
if err != nil {
|
||||
conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur SSH : %v\r\n", err)))
|
||||
return
|
||||
}
|
||||
defer sshClient.Close()
|
||||
|
||||
// Créer une session SSH avec pseudo-terminal
|
||||
session, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur session SSH : %v\r\n", err)))
|
||||
return
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// Configurer le PTY (terminal 80x24 par défaut)
|
||||
modes := gossh.TerminalModes{
|
||||
gossh.ECHO: 1,
|
||||
gossh.TTY_OP_ISPEED: 14400,
|
||||
gossh.TTY_OP_OSPEED: 14400,
|
||||
}
|
||||
if err := session.RequestPty("xterm-256color", 24, 80, modes); err != nil {
|
||||
conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur PTY : %v\r\n", err)))
|
||||
return
|
||||
}
|
||||
|
||||
// Pipes stdin/stdout entre WebSocket et SSH
|
||||
stdinPipe, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
stdoutPipe, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := session.Shell(); err != nil {
|
||||
conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur shell : %v\r\n", err)))
|
||||
return
|
||||
}
|
||||
|
||||
// Goroutine : SSH stdout → WebSocket
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := stdoutPipe.Read(buf)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
conn.WriteMessage(gorillaws.BinaryMessage, buf[:n])
|
||||
}
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// Boucle principale : WebSocket → SSH stdin
|
||||
for {
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Détecter les messages de contrôle JSON (ex: resize)
|
||||
if len(msg) > 0 && msg[0] == '{' {
|
||||
var cmd terminalCmd
|
||||
if json.Unmarshal(msg, &cmd) == nil && cmd.Type == "resize" && cmd.Cols > 0 && cmd.Rows > 0 {
|
||||
session.WindowChange(cmd.Rows, cmd.Cols)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Données brutes → stdin SSH
|
||||
stdinPipe.Write(msg)
|
||||
}
|
||||
|
||||
h.auditLogger.Log(&claims.UserID, claims.Username, "terminal_close", sshHost, nil, clientIP(r))
|
||||
}
|
||||
196
backend/internal/api/updates.go
Normal file
196
backend/internal/api/updates.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// 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"
|
||||
"net/http"
|
||||
"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"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if 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))
|
||||
|
||||
// Lancer la mise à jour en arrière-plan
|
||||
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
|
||||
err := h.sshPool.StreamCommand(sshHost, sshUser, sshPass, command, outputChan)
|
||||
if err != nil {
|
||||
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
|
||||
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))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue