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))
|
||||
}
|
||||
81
backend/internal/audit/audit.go
Normal file
81
backend/internal/audit/audit.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// Package audit fournit le journal d'audit de ProxmoxPanel.
|
||||
// Toutes les actions sensibles (connexion, mises à jour, modifications config) y sont tracées.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Entry représente une entrée dans le journal d'audit.
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID *int64 `json:"user_id,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Action string `json:"action"`
|
||||
Resource string `json:"resource,omitempty"`
|
||||
Details string `json:"details,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Logger est le service d'audit.
|
||||
type Logger struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New crée un nouveau Logger d'audit.
|
||||
func New(db *sql.DB) *Logger {
|
||||
return &Logger{db: db}
|
||||
}
|
||||
|
||||
// Log enregistre une action dans le journal d'audit.
|
||||
func (l *Logger) Log(userID *int64, username, action, resource string, details any, ip string) {
|
||||
var detailsStr string
|
||||
if details != nil {
|
||||
if s, ok := details.(string); ok {
|
||||
detailsStr = s
|
||||
} else if data, err := json.Marshal(details); err == nil {
|
||||
detailsStr = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
// Insertion non bloquante — on ignore les erreurs pour ne pas perturber le flux principal
|
||||
l.db.Exec(`
|
||||
INSERT INTO audit_log (user_id, username, action, resource, details, ip, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, userID, username, action, resource, detailsStr, ip)
|
||||
}
|
||||
|
||||
// GetEntries retourne les dernières entrées du journal, paginées.
|
||||
func (l *Logger) GetEntries(limit, offset int) ([]Entry, error) {
|
||||
rows, err := l.db.Query(`
|
||||
SELECT id, user_id, username, action, resource, details, ip, created_at
|
||||
FROM audit_log
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []Entry
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
var userID sql.NullInt64
|
||||
var resource, details, ip sql.NullString
|
||||
if err := rows.Scan(&e.ID, &userID, &e.Username, &e.Action, &resource, &details, &ip, &e.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
if userID.Valid {
|
||||
e.UserID = &userID.Int64
|
||||
}
|
||||
e.Resource = resource.String
|
||||
e.Details = details.String
|
||||
e.IP = ip.String
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
178
backend/internal/auth/jwt.go
Normal file
178
backend/internal/auth/jwt.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
// Package auth gère les tokens JWT RS256 pour les sessions utilisateurs.
|
||||
// Les clés RSA sont générées automatiquement au premier démarrage et stockées sur disque.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
accessTokenDuration = 15 * time.Minute
|
||||
refreshTokenDuration = 7 * 24 * time.Hour
|
||||
rsaKeySize = 2048
|
||||
)
|
||||
|
||||
// Claims représente le contenu d'un JWT d'accès ProxmoxPanel.
|
||||
type Claims struct {
|
||||
UserID int64 `json:"uid"`
|
||||
Username string `json:"sub"`
|
||||
IsAdmin bool `json:"admin"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// JWTManager gère la signature et la vérification des tokens JWT.
|
||||
type JWTManager struct {
|
||||
privateKey *rsa.PrivateKey
|
||||
publicKey *rsa.PublicKey
|
||||
}
|
||||
|
||||
// NewJWTManager charge ou génère les clés RSA, et retourne un JWTManager prêt à l'emploi.
|
||||
func NewJWTManager(dataDir string) (*JWTManager, error) {
|
||||
keysDir := filepath.Join(dataDir, "keys")
|
||||
if err := os.MkdirAll(keysDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("création répertoire clés : %w", err)
|
||||
}
|
||||
|
||||
privPath := filepath.Join(keysDir, "jwt.key")
|
||||
pubPath := filepath.Join(keysDir, "jwt.pub")
|
||||
|
||||
var privKey *rsa.PrivateKey
|
||||
|
||||
if _, err := os.Stat(privPath); os.IsNotExist(err) {
|
||||
// Générer une paire de clés RSA-2048
|
||||
privKey, err = rsa.GenerateKey(rand.Reader, rsaKeySize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("génération clés RSA : %w", err)
|
||||
}
|
||||
|
||||
// Sauvegarder la clé privée en PEM
|
||||
privPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
||||
})
|
||||
if err := os.WriteFile(privPath, privPEM, 0600); err != nil {
|
||||
return nil, fmt.Errorf("sauvegarde clé privée : %w", err)
|
||||
}
|
||||
|
||||
// Sauvegarder la clé publique en PEM
|
||||
pubBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export clé publique : %w", err)
|
||||
}
|
||||
pubPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: pubBytes,
|
||||
})
|
||||
if err := os.WriteFile(pubPath, pubPEM, 0644); err != nil {
|
||||
return nil, fmt.Errorf("sauvegarde clé publique : %w", err)
|
||||
}
|
||||
} else {
|
||||
// Charger la clé privée existante
|
||||
privPEM, err := os.ReadFile(privPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lecture clé privée : %w", err)
|
||||
}
|
||||
block, _ := pem.Decode(privPEM)
|
||||
if block == nil {
|
||||
return nil, errors.New("clé privée invalide (PEM)")
|
||||
}
|
||||
privKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing clé privée : %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &JWTManager{
|
||||
privateKey: privKey,
|
||||
publicKey: &privKey.PublicKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateAccessToken crée un JWT d'accès signé RS256 (durée : 15 min).
|
||||
func (m *JWTManager) GenerateAccessToken(userID int64, username string, isAdmin bool) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
IsAdmin: isAdmin,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(accessTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "proxmoxpanel",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
return token.SignedString(m.privateKey)
|
||||
}
|
||||
|
||||
// GenerateRefreshToken crée un token de renouvellement (durée : 7 jours).
|
||||
// Ce token est plus simple — il ne contient que le userID et l'expiration.
|
||||
func (m *JWTManager) GenerateRefreshToken(userID int64) (string, error) {
|
||||
claims := jwt.RegisteredClaims{
|
||||
Subject: fmt.Sprintf("%d", userID),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "proxmoxpanel-refresh",
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
return token.SignedString(m.privateKey)
|
||||
}
|
||||
|
||||
// ValidateAccessToken vérifie et décode un JWT d'accès.
|
||||
func (m *JWTManager) ValidateAccessToken(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("méthode de signature inattendue : %v", t.Header["alg"])
|
||||
}
|
||||
return m.publicKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validation token : %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("token invalide")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// ValidateRefreshToken vérifie un token de renouvellement et retourne le userID.
|
||||
func (m *JWTManager) ValidateRefreshToken(tokenStr string) (int64, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("méthode de signature inattendue : %v", t.Header["alg"])
|
||||
}
|
||||
return m.publicKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("validation refresh token : %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*jwt.RegisteredClaims)
|
||||
if !ok || !token.Valid {
|
||||
return 0, errors.New("refresh token invalide")
|
||||
}
|
||||
|
||||
if claims.Issuer != "proxmoxpanel-refresh" {
|
||||
return 0, errors.New("émetteur token invalide")
|
||||
}
|
||||
|
||||
var userID int64
|
||||
fmt.Sscanf(claims.Subject, "%d", &userID)
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// RefreshTokenDuration retourne la durée de validité du refresh token.
|
||||
func RefreshTokenDuration() time.Duration {
|
||||
return refreshTokenDuration
|
||||
}
|
||||
129
backend/internal/auth/ssh_auth.go
Normal file
129
backend/internal/auth/ssh_auth.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// Package auth — Authentification PAM via SSH.
|
||||
// Au lieu de monter les fichiers système du host (/etc/shadow), on tente une connexion
|
||||
// SSH avec les credentials de l'utilisateur. Si elle réussit, les credentials sont valides.
|
||||
// L'appartenance au groupe sudo/wheel détermine le niveau admin.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// UserInfo contient les informations d'un utilisateur authentifié.
|
||||
type UserInfo struct {
|
||||
Username string
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// SSHAuthenticator gère l'authentification des utilisateurs via SSH vers le host Proxmox.
|
||||
type SSHAuthenticator struct {
|
||||
host string // ex: "10.0.0.1:2244"
|
||||
}
|
||||
|
||||
// NewSSHAuthenticator crée un authentificateur SSH pour le host donné.
|
||||
func NewSSHAuthenticator(host string) *SSHAuthenticator {
|
||||
return &SSHAuthenticator{host: host}
|
||||
}
|
||||
|
||||
// Authenticate tente une connexion SSH avec les credentials fournis.
|
||||
// Si la connexion réussit, retourne les informations de l'utilisateur.
|
||||
// Vérifie l'appartenance au groupe sudo ou wheel pour déterminer le niveau admin.
|
||||
func (a *SSHAuthenticator) Authenticate(username, password string) (*UserInfo, error) {
|
||||
config := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(password),
|
||||
},
|
||||
// Timeout court pour l'authentification
|
||||
Timeout: 10 * time.Second,
|
||||
// Accepter n'importe quelle clé host (le host est sur le réseau interne de confiance)
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
// Tentative de connexion SSH
|
||||
client, err := ssh.Dial("tcp", a.host, config)
|
||||
if err != nil {
|
||||
// Distinguer les erreurs d'authentification des erreurs réseau
|
||||
if strings.Contains(err.Error(), "unable to authenticate") ||
|
||||
strings.Contains(err.Error(), "ssh: handshake failed") ||
|
||||
strings.Contains(err.Error(), "no supported methods remain") {
|
||||
return nil, fmt.Errorf("identifiants invalides")
|
||||
}
|
||||
return nil, fmt.Errorf("connexion SSH impossible : %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Vérifier l'appartenance aux groupes sudo/wheel via la commande `id`
|
||||
isAdmin, err := checkSudoGroup(client)
|
||||
if err != nil {
|
||||
// En cas d'erreur de vérification des groupes, l'utilisateur est authentifié mais pas admin
|
||||
isAdmin = false
|
||||
}
|
||||
|
||||
return &UserInfo{
|
||||
Username: username,
|
||||
IsAdmin: isAdmin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestConnectivity teste la connexion SSH sans authentification complète.
|
||||
// Utilisé pendant l'installation pour valider les paramètres de connexion.
|
||||
func TestConnectivity(host string, timeout time.Duration) error {
|
||||
conn, err := net.DialTimeout("tcp", host, timeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("impossible de joindre %s : %w", host, err)
|
||||
}
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestSSHAuth teste une connexion SSH complète avec credentials.
|
||||
// Retourne nil si la connexion réussit, une erreur explicite sinon.
|
||||
func TestSSHAuth(host, username, password string) error {
|
||||
config := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(password),
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", host, config)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "unable to authenticate") {
|
||||
return fmt.Errorf("identifiants SSH invalides")
|
||||
}
|
||||
return fmt.Errorf("connexion SSH échouée : %w", err)
|
||||
}
|
||||
client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSudoGroup exécute `id -nG` sur la session SSH et vérifie la présence
|
||||
// des groupes "sudo" ou "wheel" dans la liste des groupes de l'utilisateur.
|
||||
func checkSudoGroup(client *ssh.Client) (bool, error) {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("ouverture session SSH : %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
output, err := session.Output("id -nG")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("exécution `id -nG` : %w", err)
|
||||
}
|
||||
|
||||
groups := strings.Fields(strings.TrimSpace(string(output)))
|
||||
for _, g := range groups {
|
||||
if g == "sudo" || g == "wheel" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
110
backend/internal/crypto/aes.go
Normal file
110
backend/internal/crypto/aes.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// Package crypto fournit le chiffrement/déchiffrement AES-256-GCM
|
||||
// pour protéger les secrets stockés en base SQLite (tokens API, credentials SSH, etc.)
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Encryptor gère le chiffrement/déchiffrement avec une clé AES-256.
|
||||
type Encryptor struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
// NewEncryptor crée un Encryptor depuis une clé maître stockée sur disque.
|
||||
// Si la clé n'existe pas, elle est générée aléatoirement et sauvegardée.
|
||||
func NewEncryptor(dataDir string) (*Encryptor, error) {
|
||||
keyPath := filepath.Join(dataDir, "master.key")
|
||||
|
||||
var masterSecret []byte
|
||||
|
||||
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
|
||||
// Générer un secret maître de 64 octets aléatoires
|
||||
masterSecret = make([]byte, 64)
|
||||
if _, err := io.ReadFull(rand.Reader, masterSecret); err != nil {
|
||||
return nil, fmt.Errorf("génération clé maître : %w", err)
|
||||
}
|
||||
|
||||
// Sauvegarder avec permissions restreintes (lecture propriétaire uniquement)
|
||||
if err := os.MkdirAll(dataDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("création répertoire : %w", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, masterSecret, 0600); err != nil {
|
||||
return nil, fmt.Errorf("sauvegarde clé maître : %w", err)
|
||||
}
|
||||
} else {
|
||||
// Lire la clé existante
|
||||
masterSecret, err = os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lecture clé maître : %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Dériver une clé AES-256 depuis le secret maître via SHA-256
|
||||
hash := sha256.Sum256(masterSecret)
|
||||
return &Encryptor{key: hash[:]}, nil
|
||||
}
|
||||
|
||||
// Encrypt chiffre une valeur en clair et retourne une chaîne base64.
|
||||
// Format : base64(nonce || ciphertext || tag)
|
||||
func (e *Encryptor) Encrypt(plaintext string) (string, error) {
|
||||
block, err := aes.NewCipher(e.key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("création cipher AES : %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("création GCM : %w", err)
|
||||
}
|
||||
|
||||
// Générer un nonce aléatoire (12 octets pour GCM)
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", fmt.Errorf("génération nonce : %w", err)
|
||||
}
|
||||
|
||||
// Chiffrer : Seal(nonce, nonce, plaintext, nil) → nonce||ciphertext||tag
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt déchiffre une valeur chiffrée par Encrypt.
|
||||
func (e *Encryptor) Decrypt(encoded string) (string, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("décodage base64 : %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(e.key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("création cipher AES : %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("création GCM : %w", err)
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", errors.New("données chiffrées trop courtes")
|
||||
}
|
||||
|
||||
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("déchiffrement : %w", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
185
backend/internal/db/db.go
Normal file
185
backend/internal/db/db.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// Package db gère la connexion SQLite et l'exécution des migrations.
|
||||
// Il expose une instance unique de base de données utilisée par tous les services.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite" // Pilote SQLite pur Go (sans CGO)
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// DB encapsule la connexion SQLite et expose les méthodes nécessaires.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// Open ouvre (ou crée) la base de données SQLite au chemin donné et exécute les migrations.
|
||||
func Open(dataDir string) (*DB, error) {
|
||||
if err := os.MkdirAll(dataDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("création répertoire données : %w", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(dataDir, "panel.db")
|
||||
|
||||
// Paramètres SQLite : WAL mode pour les lectures concurrentes, foreign keys activées
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)", dbPath)
|
||||
sqlDB, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ouverture SQLite : %w", err)
|
||||
}
|
||||
|
||||
// Limiter les connexions simultanées (SQLite n'est pas conçu pour la concurrence élevée)
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("connexion SQLite : %w", err)
|
||||
}
|
||||
|
||||
db := &DB{sqlDB}
|
||||
|
||||
// Exécuter les migrations manquantes
|
||||
if err := db.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("migrations : %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// migrate applique les fichiers SQL de migration non encore exécutés.
|
||||
// Les fichiers sont numérotés (001_init.sql, 002_xxx.sql) et appliqués dans l'ordre.
|
||||
func (db *DB) migrate() error {
|
||||
// Créer la table schema_version si elle n'existe pas encore
|
||||
// (nécessaire avant de lire la version actuelle)
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER NOT NULL,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("création schema_version : %w", err)
|
||||
}
|
||||
|
||||
// Lire la version actuelle
|
||||
var currentVersion int
|
||||
row := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`)
|
||||
if err := row.Scan(¤tVersion); err != nil {
|
||||
return fmt.Errorf("lecture version schéma : %w", err)
|
||||
}
|
||||
|
||||
// Lister et trier les fichiers de migration
|
||||
entries, err := migrationsFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("lecture dossier migrations : %w", err)
|
||||
}
|
||||
|
||||
type migration struct {
|
||||
version int
|
||||
name string
|
||||
}
|
||||
var migrations []migration
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
// Extraire le numéro de version depuis le nom du fichier (ex: "001_init.sql" → 1)
|
||||
parts := strings.SplitN(entry.Name(), "_", 2)
|
||||
if len(parts) < 1 {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
migrations = append(migrations, migration{version: v, name: entry.Name()})
|
||||
}
|
||||
|
||||
// Trier par numéro de version croissant
|
||||
sort.Slice(migrations, func(i, j int) bool {
|
||||
return migrations[i].version < migrations[j].version
|
||||
})
|
||||
|
||||
// Appliquer les migrations manquantes
|
||||
for _, m := range migrations {
|
||||
if m.version <= currentVersion {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := migrationsFS.ReadFile("migrations/" + m.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lecture migration %s : %w", m.name, err)
|
||||
}
|
||||
|
||||
// Exécuter dans une transaction pour garantir l'atomicité
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("transaction migration %s : %w", m.name, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(string(content)); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("exécution migration %s : %w", m.name, err)
|
||||
}
|
||||
|
||||
// Mettre à jour la version (la migration 001 l'insère elle-même, pas besoin de le refaire)
|
||||
if m.version > 1 {
|
||||
if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.version); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("mise à jour version après migration %s : %w", m.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s : %w", m.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSetting lit un paramètre depuis la table settings.
|
||||
// Retourne "" et nil si la clé n'existe pas.
|
||||
func (db *DB) GetSetting(key string) (string, bool, error) {
|
||||
var value string
|
||||
var encrypted int
|
||||
err := db.QueryRow(`SELECT value, encrypted FROM settings WHERE key = ?`, key).Scan(&value, &encrypted)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return value, encrypted == 1, nil
|
||||
}
|
||||
|
||||
// SetSetting enregistre ou met à jour un paramètre.
|
||||
func (db *DB) SetSetting(key, value string, encrypted bool) error {
|
||||
enc := 0
|
||||
if encrypted {
|
||||
enc = 1
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO settings (key, value, encrypted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, encrypted=excluded.encrypted, updated_at=excluded.updated_at
|
||||
`, key, value, enc)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsInstalled vérifie si l'application a déjà été configurée.
|
||||
func (db *DB) IsInstalled() (bool, error) {
|
||||
v, _, err := db.GetSetting("installed")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return v == "true", nil
|
||||
}
|
||||
100
backend/internal/db/migrations/001_init.sql
Normal file
100
backend/internal/db/migrations/001_init.sql
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
-- Migration 001 : Schéma initial de ProxmoxPanel
|
||||
-- Crée toutes les tables de base nécessaires au CORE
|
||||
|
||||
-- Paramètres globaux de l'application (clé/valeur)
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
encrypted INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Utilisateurs (créés automatiquement au premier login)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
lang TEXT NOT NULL DEFAULT 'en',
|
||||
theme TEXT NOT NULL DEFAULT 'dark',
|
||||
sidebar_position TEXT NOT NULL DEFAULT 'left',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at DATETIME
|
||||
);
|
||||
|
||||
-- Sessions de refresh JWT (cookie httpOnly)
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Modules disponibles et leur état (actif/inactif)
|
||||
CREATE TABLE IF NOT EXISTS modules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
version TEXT NOT NULL DEFAULT '0.0.0',
|
||||
is_core INTEGER NOT NULL DEFAULT 0,
|
||||
is_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
installed_at DATETIME,
|
||||
config TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
-- Journal d'audit — toutes les actions sensibles
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
username TEXT,
|
||||
action TEXT NOT NULL,
|
||||
resource TEXT,
|
||||
details TEXT,
|
||||
ip TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Widgets du dashboard par utilisateur
|
||||
CREATE TABLE IF NOT EXISTS user_widgets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
widget_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
position_x INTEGER NOT NULL DEFAULT 0,
|
||||
position_y INTEGER NOT NULL DEFAULT 0,
|
||||
width INTEGER NOT NULL DEFAULT 2,
|
||||
height INTEGER NOT NULL DEFAULT 2,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Historique des mises à jour de paquets
|
||||
CREATE TABLE IF NOT EXISTS update_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id TEXT NOT NULL UNIQUE,
|
||||
target TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
output TEXT NOT NULL DEFAULT '',
|
||||
started_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
finished_at DATETIME
|
||||
);
|
||||
|
||||
-- Version de schéma pour le système de migrations
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER NOT NULL,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO schema_version (version) VALUES (1);
|
||||
|
||||
-- Insertion des modules CORE par défaut (non désinstallables)
|
||||
INSERT OR IGNORE INTO modules (id, name, description, version, is_core, is_enabled) VALUES
|
||||
('dashboard', 'Dashboard', 'Tableau de bord avec widgets configurables', '1.0.0', 1, 1),
|
||||
('proxmox', 'Proxmox', 'Gestion des LXC et VM Proxmox', '1.0.0', 1, 1),
|
||||
('updates', 'Mises à jour', 'Mises à jour de paquets apt avec streaming', '1.0.0', 1, 1),
|
||||
('settings', 'Paramètres', 'Configuration de l''application', '1.0.0', 1, 1),
|
||||
('files', 'Fichiers', 'Navigateur de fichiers SFTP', '1.0.0', 0, 0),
|
||||
('terminal', 'Terminal', 'Terminal SSH interactif', '1.0.0', 0, 0),
|
||||
('logs', 'Logs', 'Streaming de logs en temps réel', '1.0.0', 0, 0),
|
||||
('services', 'Services', 'Gestion des services systemd', '1.0.0', 0, 0);
|
||||
212
backend/internal/proxmox/client.go
Normal file
212
backend/internal/proxmox/client.go
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
// Package proxmox fournit un client pour l'API REST Proxmox VE.
|
||||
// Les credentials (token API ou user/password) sont stockés chiffrés en SQLite.
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client est le client HTTP vers l'API Proxmox VE.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
token string // Format: "PVEAPIToken=user@realm!tokenid=secret"
|
||||
}
|
||||
|
||||
// NodeStatus représente l'état d'un nœud Proxmox.
|
||||
type NodeStatus struct {
|
||||
Node string `json:"node"`
|
||||
Status string `json:"status"`
|
||||
CPU float64 `json:"cpu"`
|
||||
MaxCPU int `json:"maxcpu"`
|
||||
Mem int64 `json:"mem"`
|
||||
MaxMem int64 `json:"maxmem"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
}
|
||||
|
||||
// Resource représente un LXC, une VM ou un autre objet Proxmox.
|
||||
type Resource struct {
|
||||
VMID int `json:"vmid"`
|
||||
Name string `json:"name"`
|
||||
Node string `json:"node"`
|
||||
Type string `json:"type"` // "lxc" | "qemu" | "storage" | "node"
|
||||
Status string `json:"status"` // "running" | "stopped"
|
||||
CPU float64 `json:"cpu"`
|
||||
MaxCPU int `json:"maxcpu"`
|
||||
Mem int64 `json:"mem"`
|
||||
MaxMem int64 `json:"maxmem"`
|
||||
Disk int64 `json:"disk"`
|
||||
MaxDisk int64 `json:"maxdisk"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
NetIn int64 `json:"netin"`
|
||||
NetOut int64 `json:"netout"`
|
||||
}
|
||||
|
||||
// proxmoxResponse est l'enveloppe générique des réponses API Proxmox.
|
||||
type proxmoxResponse struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Error string `json:"errors"`
|
||||
}
|
||||
|
||||
// NewClient crée un client Proxmox avec le token API fourni.
|
||||
// baseURL : ex "https://10.0.0.1:8006"
|
||||
// token : ex "PVEAPIToken=enzo@pam!panel=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
// Proxmox utilise des certificats auto-signés
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetNodes retourne la liste des nœuds Proxmox.
|
||||
func (c *Client) GetNodes() ([]NodeStatus, error) {
|
||||
var nodes []NodeStatus
|
||||
if err := c.get("/api2/json/nodes", &nodes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// GetResources retourne tous les LXC et VM de l'ensemble du cluster.
|
||||
// Le paramètre type filtre les résultats ("lxc", "vm", ou "" pour tout).
|
||||
func (c *Client) GetResources(resourceType string) ([]Resource, error) {
|
||||
path := "/api2/json/cluster/resources"
|
||||
if resourceType != "" {
|
||||
path += "?type=" + resourceType
|
||||
}
|
||||
var resources []Resource
|
||||
if err := c.get(path, &resources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
// GetLXCList retourne uniquement les conteneurs LXC.
|
||||
func (c *Client) GetLXCList() ([]Resource, error) {
|
||||
return c.GetResources("lxc")
|
||||
}
|
||||
|
||||
// GetVMList retourne uniquement les machines virtuelles QEMU.
|
||||
func (c *Client) GetVMList() ([]Resource, error) {
|
||||
return c.GetResources("vm")
|
||||
}
|
||||
|
||||
// StartLXC démarre un conteneur LXC.
|
||||
func (c *Client) StartLXC(node string, vmid int) error {
|
||||
_, err := c.post(fmt.Sprintf("/api2/json/nodes/%s/lxc/%d/status/start", node, vmid), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// StopLXC arrête un conteneur LXC.
|
||||
func (c *Client) StopLXC(node string, vmid int) error {
|
||||
_, err := c.post(fmt.Sprintf("/api2/json/nodes/%s/lxc/%d/status/stop", node, vmid), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// StartVM démarre une machine virtuelle.
|
||||
func (c *Client) StartVM(node string, vmid int) error {
|
||||
_, err := c.post(fmt.Sprintf("/api2/json/nodes/%s/qemu/%d/status/start", node, vmid), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// StopVM arrête une machine virtuelle.
|
||||
func (c *Client) StopVM(node string, vmid int) error {
|
||||
_, err := c.post(fmt.Sprintf("/api2/json/nodes/%s/qemu/%d/status/stop", node, vmid), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// TestConnection vérifie que le token API est valide en récupérant la liste des nœuds.
|
||||
func (c *Client) TestConnection() error {
|
||||
_, err := c.GetNodes()
|
||||
return err
|
||||
}
|
||||
|
||||
// get effectue une requête GET et décode la réponse dans dest.
|
||||
func (c *Client) get(path string, dest any) error {
|
||||
req, err := http.NewRequest("GET", c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", c.token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("requête Proxmox : %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
return fmt.Errorf("token Proxmox invalide ou expiré")
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("erreur Proxmox API : HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return c.decodeResponse(resp.Body, dest)
|
||||
}
|
||||
|
||||
// post effectue une requête POST.
|
||||
func (c *Client) post(path string, body any) (json.RawMessage, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader = strings.NewReader(string(data))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", c.baseURL+path, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("requête Proxmox : %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
return nil, fmt.Errorf("token Proxmox invalide")
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("erreur Proxmox API : HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result json.RawMessage
|
||||
c.decodeResponse(resp.Body, &result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// decodeResponse décode l'enveloppe JSON Proxmox et extrait le champ "data".
|
||||
func (c *Client) decodeResponse(body io.Reader, dest any) error {
|
||||
var wrapper proxmoxResponse
|
||||
if err := json.NewDecoder(body).Decode(&wrapper); err != nil {
|
||||
return fmt.Errorf("décodage réponse Proxmox : %w", err)
|
||||
}
|
||||
if wrapper.Error != "" {
|
||||
return fmt.Errorf("erreur Proxmox : %s", wrapper.Error)
|
||||
}
|
||||
if dest == nil || wrapper.Data == nil {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(wrapper.Data, dest)
|
||||
}
|
||||
216
backend/internal/ssh/pool.go
Normal file
216
backend/internal/ssh/pool.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// Package ssh gère un pool de connexions SSH réutilisables vers le host Proxmox et les LXC.
|
||||
// Les connexions inactives depuis plus de 5 minutes sont automatiquement fermées.
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const (
|
||||
idleTimeout = 5 * time.Minute
|
||||
connTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// poolEntry représente une connexion SSH dans le pool avec sa date de dernier usage.
|
||||
type poolEntry struct {
|
||||
client *gossh.Client
|
||||
lastUsed time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Pool est un pool thread-safe de connexions SSH.
|
||||
type Pool struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*poolEntry
|
||||
ticker *time.Ticker
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewPool crée un pool SSH et démarre le nettoyage automatique des connexions inactives.
|
||||
func NewPool() *Pool {
|
||||
p := &Pool{
|
||||
entries: make(map[string]*poolEntry),
|
||||
ticker: time.NewTicker(1 * time.Minute),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go p.cleanup()
|
||||
return p
|
||||
}
|
||||
|
||||
// Close arrête le pool et ferme toutes les connexions.
|
||||
func (p *Pool) Close() {
|
||||
close(p.done)
|
||||
p.ticker.Stop()
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, entry := range p.entries {
|
||||
entry.client.Close()
|
||||
}
|
||||
p.entries = make(map[string]*poolEntry)
|
||||
}
|
||||
|
||||
// getOrCreate retourne une connexion existante ou en crée une nouvelle.
|
||||
func (p *Pool) getOrCreate(key, host, user, password string) (*poolEntry, error) {
|
||||
p.mu.Lock()
|
||||
entry, exists := p.entries[key]
|
||||
p.mu.Unlock()
|
||||
|
||||
if exists {
|
||||
// Vérifier que la connexion est toujours active
|
||||
entry.mu.Lock()
|
||||
_, _, err := entry.client.SendRequest("keepalive@openssh.com", true, nil)
|
||||
if err == nil {
|
||||
entry.lastUsed = time.Now()
|
||||
entry.mu.Unlock()
|
||||
return entry, nil
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
// Connexion morte — on la supprime et en crée une nouvelle
|
||||
p.mu.Lock()
|
||||
delete(p.entries, key)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// Créer une nouvelle connexion
|
||||
config := &gossh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []gossh.AuthMethod{
|
||||
gossh.Password(password),
|
||||
},
|
||||
Timeout: connTimeout,
|
||||
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
client, err := gossh.Dial("tcp", host, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connexion SSH vers %s : %w", host, err)
|
||||
}
|
||||
|
||||
newEntry := &poolEntry{
|
||||
client: client,
|
||||
lastUsed: time.Now(),
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.entries[key] = newEntry
|
||||
p.mu.Unlock()
|
||||
|
||||
return newEntry, nil
|
||||
}
|
||||
|
||||
// RunCommand exécute une commande sur l'hôte distant et retourne la sortie combinée.
|
||||
func (p *Pool) RunCommand(host, user, password, command string) (string, error) {
|
||||
key := fmt.Sprintf("%s@%s", user, host)
|
||||
entry, err := p.getOrCreate(key, host, user, password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
entry.mu.Lock()
|
||||
defer entry.mu.Unlock()
|
||||
|
||||
session, err := entry.client.NewSession()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ouverture session : %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
output, err := session.CombinedOutput(command)
|
||||
entry.lastUsed = time.Now()
|
||||
return strings.TrimSpace(string(output)), err
|
||||
}
|
||||
|
||||
// StreamCommand exécute une commande et envoie sa sortie ligne par ligne dans le channel.
|
||||
// Le channel est fermé à la fin de la commande.
|
||||
func (p *Pool) StreamCommand(host, user, password, command string, output chan<- string) error {
|
||||
key := fmt.Sprintf("%s@%s", user, host)
|
||||
entry, err := p.getOrCreate(key, host, user, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry.mu.Lock()
|
||||
session, err := entry.client.NewSession()
|
||||
entry.mu.Unlock()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ouverture session : %w", err)
|
||||
}
|
||||
|
||||
// Utiliser un pipe pour lire la sortie en streaming
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
session.Close()
|
||||
return fmt.Errorf("pipe stdout : %w", err)
|
||||
}
|
||||
stderr, err := session.StderrPipe()
|
||||
if err != nil {
|
||||
session.Close()
|
||||
return fmt.Errorf("pipe stderr : %w", err)
|
||||
}
|
||||
|
||||
if err := session.Start(command); err != nil {
|
||||
session.Close()
|
||||
return fmt.Errorf("démarrage commande : %w", err)
|
||||
}
|
||||
|
||||
// Lire stdout et stderr en goroutines et envoyer dans le channel
|
||||
var wg sync.WaitGroup
|
||||
readStream := func(r io.Reader) {
|
||||
defer wg.Done()
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
output <- string(buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(2)
|
||||
go readStream(stdout)
|
||||
go readStream(stderr)
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
session.Wait()
|
||||
session.Close()
|
||||
close(output)
|
||||
p.mu.Lock()
|
||||
if e, ok := p.entries[key]; ok {
|
||||
e.lastUsed = time.Now()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanup supprime périodiquement les connexions inactives depuis plus de idleTimeout.
|
||||
func (p *Pool) cleanup() {
|
||||
for {
|
||||
select {
|
||||
case <-p.done:
|
||||
return
|
||||
case <-p.ticker.C:
|
||||
p.mu.Lock()
|
||||
for key, entry := range p.entries {
|
||||
entry.mu.Lock()
|
||||
if time.Since(entry.lastUsed) > idleTimeout {
|
||||
entry.client.Close()
|
||||
delete(p.entries, key)
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
228
backend/internal/websocket/hub.go
Normal file
228
backend/internal/websocket/hub.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
// Package websocket fournit le hub central WebSocket de ProxmoxPanel.
|
||||
// Les clients s'abonnent à des channels nommés et reçoivent les messages qui leur sont destinés.
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
maxMessageSize = 8192
|
||||
)
|
||||
|
||||
// Message représente un message WebSocket avec un type et un payload JSON.
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Channel string `json:"channel"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// Client représente un client WebSocket connecté.
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
channels map[string]bool
|
||||
mu sync.RWMutex
|
||||
userID int64
|
||||
}
|
||||
|
||||
// Hub gère toutes les connexions WebSocket actives et le routage des messages par channel.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]bool
|
||||
register chan *Client
|
||||
unregister chan *Client
|
||||
broadcast chan broadcastMsg
|
||||
}
|
||||
|
||||
type broadcastMsg struct {
|
||||
channel string
|
||||
data []byte
|
||||
}
|
||||
|
||||
// NewHub crée un nouveau hub WebSocket et le démarre.
|
||||
func NewHub() *Hub {
|
||||
h := &Hub{
|
||||
clients: make(map[*Client]bool),
|
||||
register: make(chan *Client, 64),
|
||||
unregister: make(chan *Client, 64),
|
||||
broadcast: make(chan broadcastMsg, 256),
|
||||
}
|
||||
go h.run()
|
||||
return h
|
||||
}
|
||||
|
||||
// run est la boucle principale du hub (goroutine unique pour éviter les races).
|
||||
func (h *Hub) run() {
|
||||
for {
|
||||
select {
|
||||
case client := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.clients[client] = true
|
||||
h.mu.Unlock()
|
||||
|
||||
case client := <-h.unregister:
|
||||
h.mu.Lock()
|
||||
if h.clients[client] {
|
||||
delete(h.clients, client)
|
||||
close(client.send)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
case msg := <-h.broadcast:
|
||||
h.mu.RLock()
|
||||
for client := range h.clients {
|
||||
client.mu.RLock()
|
||||
subscribed := client.channels[msg.channel] || client.channels["*"]
|
||||
client.mu.RUnlock()
|
||||
if subscribed {
|
||||
select {
|
||||
case client.send <- msg.data:
|
||||
default:
|
||||
// Client lent ou déconnecté — on le supprime
|
||||
h.mu.RUnlock()
|
||||
h.mu.Lock()
|
||||
if h.clients[client] {
|
||||
delete(h.clients, client)
|
||||
close(client.send)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.mu.RLock()
|
||||
}
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publish envoie un message sur un channel donné à tous les clients abonnés.
|
||||
func (h *Hub) Publish(channel, msgType string, payload any) {
|
||||
data, err := marshalMessage(msgType, channel, payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
h.broadcast <- broadcastMsg{channel: channel, data: data}
|
||||
}
|
||||
|
||||
// PublishRaw envoie des données brutes sur un channel.
|
||||
func (h *Hub) PublishRaw(channel string, data []byte) {
|
||||
h.broadcast <- broadcastMsg{channel: channel, data: data}
|
||||
}
|
||||
|
||||
// NewClient crée et enregistre un nouveau client WebSocket.
|
||||
func (h *Hub) NewClient(conn *websocket.Conn, userID int64) *Client {
|
||||
c := &Client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
send: make(chan []byte, 256),
|
||||
channels: make(map[string]bool),
|
||||
userID: userID,
|
||||
}
|
||||
h.register <- c
|
||||
go c.writePump()
|
||||
go c.readPump()
|
||||
return c
|
||||
}
|
||||
|
||||
// Subscribe abonne le client à un channel.
|
||||
func (c *Client) Subscribe(channel string) {
|
||||
c.mu.Lock()
|
||||
c.channels[channel] = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Unsubscribe désabonne le client d'un channel.
|
||||
func (c *Client) Unsubscribe(channel string) {
|
||||
c.mu.Lock()
|
||||
delete(c.channels, channel)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// writePump envoie les messages en attente au client WebSocket.
|
||||
func (c *Client) writePump() {
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
c.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-c.send:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if !ok {
|
||||
// Hub a fermé le channel
|
||||
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readPump lit les messages entrants du client (abonnements, ping, etc.)
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
c.hub.unregister <- c
|
||||
c.conn.Close()
|
||||
}()
|
||||
|
||||
c.conn.SetReadLimit(maxMessageSize)
|
||||
c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
_, rawMsg, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Traiter les messages d'abonnement entrants
|
||||
var msg Message
|
||||
if json.Unmarshal(rawMsg, &msg) == nil {
|
||||
switch msg.Type {
|
||||
case "subscribe":
|
||||
c.Subscribe(msg.Channel)
|
||||
case "unsubscribe":
|
||||
c.Unsubscribe(msg.Channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// marshalMessage sérialise un message WebSocket en JSON.
|
||||
func marshalMessage(msgType, channel string, payload any) ([]byte, error) {
|
||||
var rawPayload json.RawMessage
|
||||
if payload != nil {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawPayload = data
|
||||
}
|
||||
return json.Marshal(Message{
|
||||
Type: msgType,
|
||||
Channel: channel,
|
||||
Payload: rawPayload,
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue