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:
enzo 2026-03-20 21:08:53 +01:00
commit 5dbcb1df07
66 changed files with 10370 additions and 0 deletions

View 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
}

View 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
}