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

48
backend/Dockerfile Normal file
View file

@ -0,0 +1,48 @@
# ── Étape 1 : Build du binaire Go ──────────────────────────────────────────
FROM golang:1.23-alpine AS builder
# Dépendances de compilation (git pour les modules Go)
RUN apk add --no-cache git
WORKDIR /build
# Copier les fichiers de dépendances en premier (optimise le cache Docker)
COPY go.mod go.sum ./
RUN go mod download
# Copier tout le code source
COPY . .
# Compiler le binaire de façon statique
# -ldflags="-s -w" : supprime les infos de debug pour réduire la taille
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o /bin/proxmoxpanel ./
# ── Étape 2 : Image finale minimale ────────────────────────────────────────
FROM alpine:3.20
# Certificats CA pour les requêtes HTTPS vers l'API Proxmox
RUN apk add --no-cache ca-certificates tzdata
# Créer un utilisateur non-root pour la sécurité
RUN addgroup -g 1001 pxp && adduser -u 1001 -G pxp -s /bin/sh -D pxp
WORKDIR /app
# Copier le binaire compilé
COPY --from=builder /bin/proxmoxpanel /app/proxmoxpanel
# Créer les répertoires de données avec les bonnes permissions
RUN mkdir -p /app/data && chown -R pxp:pxp /app
USER pxp
# Port d'écoute du backend
EXPOSE 3001
# Variables d'environnement par défaut
ENV DATA_DIR=/app/data \
LISTEN_ADDR=:3001 \
APP_ENV=production
CMD ["/app/proxmoxpanel"]

23
backend/go.mod Normal file
View file

@ -0,0 +1,23 @@
module git.geronzi.fr/proxmoxPanel/core/backend
go 1.26.1
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.49.0
modernc.org/sqlite v1.47.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

61
backend/go.sum Normal file
View file

@ -0,0 +1,61 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View 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[:])
}

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

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

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

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

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

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

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

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

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
}

View 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
View 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(&currentVersion); 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
}

View 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);

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

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

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

214
backend/main.go Normal file
View file

@ -0,0 +1,214 @@
// ProxmoxPanel — CORE Backend
// Point d'entrée du serveur Go. Initialise la base de données, les services,
// enregistre les modules actifs et démarre le serveur HTTP sur :3001.
package main
import (
"log"
"net/http"
"os"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/api"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/auth"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
sshpool "git.geronzi.fr/proxmoxPanel/core/backend/internal/ssh"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/websocket"
"git.geronzi.fr/proxmoxPanel/core/backend/modules"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
// Répertoire de données persistantes (volume Docker)
dataDir := getEnv("DATA_DIR", "/app/data")
log.Printf("ProxmoxPanel CORE — démarrage (data: %s)", dataDir)
// ── Initialisation de la base de données ───────────────────────────────
database, err := db.Open(dataDir)
if err != nil {
log.Fatalf("Impossible d'ouvrir la base de données : %v", err)
}
log.Println("Base de données SQLite initialisée")
// ── Services de base ───────────────────────────────────────────────────
encryptor, err := crypto.NewEncryptor(dataDir)
if err != nil {
log.Fatalf("Impossible d'initialiser le chiffrement : %v", err)
}
log.Println("Chiffrement AES-256-GCM initialisé")
jwtManager, err := auth.NewJWTManager(dataDir)
if err != nil {
log.Fatalf("Impossible d'initialiser JWT : %v", err)
}
log.Println("Clés JWT RS256 prêtes")
// SSH host depuis la configuration (peut être vide si pas encore installé)
sshHost, _, _ := database.GetSetting("ssh_host")
var sshAuthenticator *auth.SSHAuthenticator
if sshHost != "" {
sshAuthenticator = auth.NewSSHAuthenticator(sshHost)
} else {
sshAuthenticator = auth.NewSSHAuthenticator("") // Sera mis à jour après installation
}
sshPool := sshpool.NewPool()
defer sshPool.Close()
hub := websocket.NewHub()
auditLogger := audit.New(database.DB)
// ── Chargement des modules actifs ──────────────────────────────────────
loader := modules.NewLoader(database.DB)
// Les modules sont enregistrés ici (compilés dans le binaire)
// loader.RegisterModule(dashboard.New(...)) ← à décommenter quand implémentés
if err := loader.LoadActive(); err != nil {
log.Fatalf("Erreur chargement modules : %v", err)
}
// ── Handlers HTTP ──────────────────────────────────────────────────────
installHandler := api.NewInstallHandler(database, encryptor)
authHandler := api.NewAuthHandler(database, jwtManager, sshAuthenticator, auditLogger)
proxmoxHandler := api.NewProxmoxHandler(database, hub, auditLogger, encryptor)
updatesHandler := api.NewUpdatesHandler(database, sshPool, hub, auditLogger, encryptor)
settingsHandler := api.NewSettingsHandler(database, auditLogger)
terminalHandler := api.NewTerminalHandler(database, auditLogger, encryptor)
// Démarrer le polling Proxmox en arrière-plan
proxmoxHandler.StartPolling()
// ── Router Chi ─────────────────────────────────────────────────────────
r := chi.NewRouter()
// Middlewares globaux
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(api.SecurityHeaders)
r.Use(middleware.Compress(5)) // Compression gzip
// Limiter global (100 req/min par IP)
globalLimiter := api.NewRateLimiter(100, 60*1000000000) // 60 secondes
r.Use(api.RateLimit(globalLimiter))
// ── Routes publiques (sans authentification) ───────────────────────────
r.Get("/api/health", func(w http.ResponseWriter, r *http.Request) {
api.JSONResponse(w, http.StatusOK, map[string]string{"status": "ok"})
})
// Routes d'installation (accessibles seulement si non-installé)
r.Group(func(r chi.Router) {
r.Use(requireNotInstalled(database))
r.Get("/api/install/status", installHandler.GetStatus)
r.Post("/api/install/test-ssh", installHandler.TestSSH)
r.Post("/api/install/test-proxmox", installHandler.TestProxmoxToken)
r.Post("/api/install/configure", installHandler.Configure)
})
// Status d'installation accessible toujours (pour la redirection frontend)
r.Get("/api/install/check", func(w http.ResponseWriter, r *http.Request) {
installed, _ := database.IsInstalled()
api.JSONResponse(w, http.StatusOK, map[string]bool{"installed": installed})
})
// Routes d'authentification
r.Post("/api/auth/login", authHandler.Login)
r.Post("/api/auth/refresh", authHandler.Refresh)
// ── Routes protégées (JWT requis) ──────────────────────────────────────
r.Group(func(r chi.Router) {
r.Use(api.RequireAuth(jwtManager))
r.Post("/api/auth/logout", authHandler.Logout)
r.Get("/api/auth/me", authHandler.Me)
r.Patch("/api/auth/preferences", authHandler.UpdatePreferences)
// Proxmox
r.Get("/api/proxmox/resources", proxmoxHandler.GetResources)
r.Get("/api/proxmox/lxc", proxmoxHandler.GetLXC)
// Actions Proxmox — admin uniquement
r.Group(func(r chi.Router) {
r.Use(api.RequireAdmin)
r.Post("/api/proxmox/lxc/{vmid}/start", proxmoxHandler.StartLXC)
r.Post("/api/proxmox/lxc/{vmid}/stop", proxmoxHandler.StopLXC)
})
// Mises à jour — admin uniquement
r.Group(func(r chi.Router) {
r.Use(api.RequireAdmin)
r.Post("/api/updates/run", updatesHandler.RunUpdate)
})
r.Get("/api/updates/history", updatesHandler.GetHistory)
// Paramètres — admin uniquement
r.Group(func(r chi.Router) {
r.Use(api.RequireAdmin)
r.Get("/api/settings", settingsHandler.GetAll)
r.Put("/api/settings/{key}", settingsHandler.UpdateSetting)
r.Get("/api/settings/audit", settingsHandler.GetAuditLog)
})
// Modules
r.Get("/api/modules", settingsHandler.GetModules)
r.Group(func(r chi.Router) {
r.Use(api.RequireAdmin)
r.Post("/api/modules/{id}/enable", settingsHandler.EnableModule)
r.Post("/api/modules/{id}/disable", settingsHandler.DisableModule)
})
// WebSocket — les routes WS extraient le token via query param
r.Get("/ws/proxmox", proxmoxHandler.WebSocket)
r.Get("/ws/updates/{jobId}", updatesHandler.WebSocketUpdate)
r.Get("/ws/terminal", terminalHandler.WebSocket)
})
// Routes enregistrées par les modules actifs
for _, route := range loader.Registry().GetRoutes() {
routeCopy := route // Capturer la variable pour la closure
if routeCopy.RequireAdmin {
r.With(api.RequireAuth(jwtManager), api.RequireAdmin).MethodFunc(routeCopy.Method, routeCopy.Path, routeCopy.Handler)
} else {
r.With(api.RequireAuth(jwtManager)).MethodFunc(routeCopy.Method, routeCopy.Path, routeCopy.Handler)
}
}
// Servir les assets frontend (en production, c'est Nginx qui s'en charge)
if _, err := os.Stat("./static"); err == nil {
fs := http.FileServer(http.Dir("./static"))
r.Handle("/*", fs)
}
// ── Démarrage du serveur ───────────────────────────────────────────────
addr := getEnv("LISTEN_ADDR", ":3001")
log.Printf("Serveur démarré sur %s", addr)
if err := http.ListenAndServe(addr, r); err != nil {
log.Fatalf("Serveur arrêté : %v", err)
}
}
// requireNotInstalled est un middleware qui bloque les routes d'installation si déjà installé.
func requireNotInstalled(database *db.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// La route /api/install/status reste accessible pour le check
installed, _ := database.IsInstalled()
if installed {
api.JSONError(w, "Application déjà installée", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// getEnv lit une variable d'environnement avec une valeur par défaut.
func getEnv(key, defaultValue string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultValue
}

View file

@ -0,0 +1,44 @@
# Module — Dashboard
**Type**: Core (always enabled)
Provides the main dashboard with a configurable, per-user widget grid.
## Features
- Drag-and-drop widget reordering (saved per user in SQLite)
- Add and remove widgets via modal
- Widget layout persisted across sessions
## Widget Types
| Type | Description |
|------|-------------|
| `shortcut` | Clickable link card (icon + label + URL) |
| `lxc_status` | Live status of a specific LXC container |
| `metrics` | Host CPU/RAM/disk summary |
## API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/dashboard/widgets` | JWT | Get current user's widget layout |
| PUT | `/api/dashboard/widgets` | JWT | Save widget layout |
## Widget Layout Format
```json
[
{ "id": "w1", "type": "shortcut", "config": { "label": "Proxmox", "url": "https://proxmox.example.com", "icon": "server" } },
{ "id": "w2", "type": "lxc_status", "config": { "vmid": 100 } },
{ "id": "w3", "type": "metrics", "config": {} }
]
```
## Database
Layouts are stored in the `user_widgets` table, keyed by user ID.
## License
MIT — see [LICENSE](../../LICENSE)

View file

@ -0,0 +1,41 @@
# Module — Files
**Type**: Optional (disabled by default)
SFTP-based file browser for the Proxmox host and LXC containers. Navigate, view, edit, upload, and download files directly from the browser.
## Planned Features
- Directory listing with permissions, size, modification date
- File preview (text, JSON, YAML, shell scripts, logs)
- File editing via CodeMirror 6 (syntax highlighting for common formats)
- Upload and download
- Create/delete files and directories
- Navigate into LXC containers via `pct exec` or direct SFTP
## Planned API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/files/list` | JWT | List directory contents |
| GET | `/api/files/read` | JWT | Read file content |
| PUT | `/api/files/write` | JWT+Admin | Write file content |
| POST | `/api/files/mkdir` | JWT+Admin | Create directory |
| DELETE | `/api/files/delete` | JWT+Admin | Delete file or directory |
| GET | `/api/files/download` | JWT | Download file |
| POST | `/api/files/upload` | JWT+Admin | Upload file |
Query parameters: `path=<absolute-path>`, `host=<optional-ssh-override>`
## Status
> This module is currently a stub. The UI view is implemented (shows a "module not enabled" placeholder). Full SFTP implementation is planned for a future release.
## Requirements
- SSH/SFTP access to the target host
- The `ssh_host`, `ssh_username`, `ssh_password` settings must be configured
## License
MIT — see [LICENSE](../../LICENSE)

150
backend/modules/loader.go Normal file
View file

@ -0,0 +1,150 @@
// Package modules — Loader de modules.
// Découvre les modules disponibles, vérifie leur état en DB, et les initialise si activés.
// Un module désactivé ne fait appel à aucune de ses méthodes Register().
package modules
import (
"database/sql"
"fmt"
"log"
"net/http"
)
// Loader charge et gère les modules actifs.
type Loader struct {
db *sql.DB
registry *coreRegistry
modules []Module
}
// NewLoader crée un Loader avec le router et la DB fournis.
func NewLoader(db *sql.DB) *Loader {
return &Loader{
db: db,
registry: newCoreRegistry(db),
}
}
// RegisterModule enregistre un module disponible (appelé à l'init, depuis main.go).
// Le module sera initialisé seulement s'il est activé en base.
func (l *Loader) RegisterModule(m Module) {
l.modules = append(l.modules, m)
}
// LoadActive charge et initialise tous les modules activés en base de données.
func (l *Loader) LoadActive() error {
for _, m := range l.modules {
enabled, err := l.isEnabled(m.ID())
if err != nil {
return fmt.Errorf("vérification module %s : %w", m.ID(), err)
}
if !enabled {
log.Printf("Module %s : désactivé, ignoré", m.ID())
continue
}
log.Printf("Module %s : chargement...", m.ID())
if err := m.Register(l.registry); err != nil {
return fmt.Errorf("initialisation module %s : %w", m.ID(), err)
}
log.Printf("Module %s : chargé avec succès", m.ID())
}
return nil
}
// isEnabled vérifie en base de données si un module est activé.
func (l *Loader) isEnabled(id string) (bool, error) {
var enabled int
err := l.db.QueryRow(`SELECT is_enabled FROM modules WHERE id = ?`, id).Scan(&enabled)
if err == sql.ErrNoRows {
return false, nil // Module inconnu = désactivé
}
return enabled == 1, err
}
// Registry retourne le registry partagé (pour accès par le serveur HTTP).
func (l *Loader) Registry() *coreRegistry {
return l.registry
}
// ---- Implémentation interne du Registry ----
// RouteEntry décrit une route HTTP enregistrée par un module.
type RouteEntry struct {
Method string
Path string
Handler http.HandlerFunc
RequireAdmin bool
}
type migrationEntry struct {
version int
sql string
fn MigrationFn
}
type translationEntry struct {
lang string
keys map[string]string
}
// coreRegistry implémente l'interface Registry.
type coreRegistry struct {
db *sql.DB
routes []RouteEntry
wsChannels map[string]WSHandler
widgets []WidgetDef
settingsTabs []SettingsTabDef
migrations []migrationEntry
translations []translationEntry
}
func newCoreRegistry(db *sql.DB) *coreRegistry {
return &coreRegistry{
db: db,
wsChannels: make(map[string]WSHandler),
}
}
func (r *coreRegistry) RegisterRoute(method, path string, handler http.HandlerFunc, requireAdmin bool) {
r.routes = append(r.routes, RouteEntry{method, path, handler, requireAdmin})
}
func (r *coreRegistry) RegisterWSChannel(channel string, handler WSHandler) {
r.wsChannels[channel] = handler
}
func (r *coreRegistry) RegisterWidget(widget WidgetDef) {
r.widgets = append(r.widgets, widget)
}
func (r *coreRegistry) RegisterSettingsTab(tab SettingsTabDef) {
r.settingsTabs = append(r.settingsTabs, tab)
}
func (r *coreRegistry) RegisterTranslations(lang string, keys map[string]string) {
r.translations = append(r.translations, translationEntry{lang, keys})
}
func (r *coreRegistry) RegisterMigration(version int, sqlStr string, fn MigrationFn) {
r.migrations = append(r.migrations, migrationEntry{version, sqlStr, fn})
}
func (r *coreRegistry) DB() *sql.DB {
return r.db
}
// GetRoutes retourne les routes enregistrées par les modules.
func (r *coreRegistry) GetRoutes() []RouteEntry {
return r.routes
}
// GetWidgets retourne les types de widgets disponibles.
func (r *coreRegistry) GetWidgets() []WidgetDef {
return r.widgets
}
// GetSettingsTabs retourne les onglets de paramètres des modules.
func (r *coreRegistry) GetSettingsTabs() []SettingsTabDef {
return r.settingsTabs
}

View file

@ -0,0 +1,45 @@
# Module — Logs
**Type**: Optional (disabled by default)
Stream and browse system logs from the Proxmox host or LXC containers in real time via WebSocket (`tail -f` equivalent).
## Planned Features
- Real-time log streaming via WebSocket
- Common log sources: `syslog`, `auth.log`, `kern.log`, journald
- Filter by log level (error, warning, info)
- Stop/start streaming on demand
- LXC log access via `pct exec`
## Planned API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/logs/sources` | JWT | List available log sources |
## Planned WebSocket Endpoint
`GET /ws/logs/{source}?token=<jwt>&host=<optional>`
Where `source` is a log name such as `syslog`, `auth`, or `journal`.
Message types:
| Type | Payload | Description |
|------|---------|-------------|
| `log_line` | `{ "line": "...", "level": "info" }` | New log line |
| `log_end` | — | Stream closed (e.g. SSH disconnected) |
## Status
> This module is currently a stub. The UI view is implemented (shows a "module not enabled" placeholder). Full implementation is planned for a future release.
## Requirements
- SSH access to the target host
- Read permissions on the log files (root or appropriate group)
## License
MIT — see [LICENSE](../../LICENSE)

66
backend/modules/module.go Normal file
View file

@ -0,0 +1,66 @@
// Package modules définit le contrat d'interface pour les modules ProxmoxPanel.
// Chaque module implémente l'interface Module et s'enregistre auprès du ModuleRegistry.
package modules
import (
"database/sql"
"net/http"
)
// Module est l'interface que chaque module doit implémenter.
type Module interface {
// ID retourne l'identifiant unique du module (doit correspondre à la table modules en DB).
ID() string
// Register est appelé au chargement du module actif.
// Il reçoit le registry pour enregistrer ses routes, widgets, etc.
Register(registry Registry) error
}
// Registry est l'interface exposée aux modules pour s'enregistrer dans le CORE.
type Registry interface {
// RegisterRoute enregistre une route HTTP dans le router principal.
RegisterRoute(method, path string, handler http.HandlerFunc, requireAdmin bool)
// RegisterWSChannel enregistre un handler WebSocket pour un channel nommé.
RegisterWSChannel(channel string, handler WSHandler)
// RegisterWidget déclare un type de widget disponible pour le dashboard.
RegisterWidget(widget WidgetDef)
// RegisterSettingsTab ajoute un onglet dans la page paramètres.
RegisterSettingsTab(tab SettingsTabDef)
// RegisterTranslations fusionne des clés de traduction pour une langue donnée.
RegisterTranslations(lang string, keys map[string]string)
// RegisterMigration déclare une migration de base de données propre au module.
RegisterMigration(version int, sql string, fn MigrationFn)
// DB retourne un accès à SQLite avec isolation par module (préfixe de tables).
DB() *sql.DB
}
// WSHandler est un handler WebSocket pour un channel nommé.
type WSHandler func(userID int64, send chan<- []byte, recv <-chan []byte)
// WidgetDef décrit un type de widget disponible pour le dashboard.
type WidgetDef struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
DefaultW int `json:"default_width"`
DefaultH int `json:"default_height"`
}
// SettingsTabDef décrit un onglet de paramètres fourni par un module.
type SettingsTabDef struct {
ID string `json:"id"`
Label string `json:"label"`
Icon string `json:"icon"`
// Path est le chemin frontend du composant Vue à charger (lazy import).
ComponentPath string `json:"component_path"`
}
// MigrationFn est une fonction de migration optionnelle (pour les migrations non-SQL).
type MigrationFn func(db *sql.DB) error

View file

@ -0,0 +1,55 @@
# Module — Services
**Type**: Optional (disabled by default)
Manage systemd services on the Proxmox host and LXC containers. Check status, start, stop, and restart services directly from the web interface.
## Planned Features
- List systemd services with current status (active/inactive/failed)
- Start, stop, restart, reload actions
- View service logs (last N lines via `journalctl -u <service>`)
- Filter by status or name
- LXC service management via `pct exec`
## Planned API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/services` | JWT | List services and their status |
| POST | `/api/services/{name}/start` | JWT+Admin | Start a service |
| POST | `/api/services/{name}/stop` | JWT+Admin | Stop a service |
| POST | `/api/services/{name}/restart` | JWT+Admin | Restart a service |
| POST | `/api/services/{name}/reload` | JWT+Admin | Reload a service |
| GET | `/api/services/{name}/logs` | JWT | Last 100 log lines |
Query parameter: `host=<optional>` to target a specific LXC.
## How It Works
Commands are executed over SSH using `systemctl`:
```bash
systemctl status nginx
systemctl restart nginx
journalctl -u nginx -n 100 --no-pager
```
For LXC containers:
```bash
pct exec <vmid> -- systemctl restart nginx
```
## Status
> This module is currently a stub. The UI view is implemented (shows a "module not enabled" placeholder). Full implementation is planned for a future release.
## Requirements
- SSH access with sufficient privileges to run `systemctl` commands
- `systemd` on the target host/containers
## License
MIT — see [LICENSE](../../LICENSE)

View file

@ -0,0 +1,54 @@
# Module — Terminal
**Type**: Optional (disabled by default)
Interactive SSH terminal in the browser. Connects to the Proxmox host (or any SSH-accessible target) and opens a full PTY session via WebSocket.
## Features
- Full PTY support (`xterm-256color`, interactive shell)
- Responsive resizing — the terminal adjusts when the browser window is resized
- Terminal theme matches the panel's Neumorphism dark/light design
- Audit log entry on open and close
## WebSocket Endpoint
`GET /ws/terminal?token=<jwt>&host=<optional-override>`
If `host` is not specified, the SSH host configured during installation is used.
### Message Format
**Client → Server** (keyboard input): raw binary bytes
**Client → Server** (resize event): JSON text frame
```json
{ "type": "resize", "cols": 120, "rows": 40 }
```
**Server → Client** (terminal output): raw binary bytes
## Frontend
Uses [xterm.js](https://xtermjs.org/) with the following addons:
- `@xterm/addon-fit` — auto-resize to container dimensions
- `@xterm/addon-attach` — attach xterm directly to a WebSocket
## How It Works
1. WebSocket connection is established and JWT is validated
2. Backend opens an SSH connection using stored credentials
3. A PTY session is requested (`xterm-256color`, initial size 80×24)
4. An interactive shell is launched
5. All data flows bidirectionally: WebSocket ↔ SSH ↔ PTY
## Requirements
- SSH access to the target host (password authentication)
- The `ssh_host`, `ssh_username`, `ssh_password` settings must be configured
## License
MIT — see [LICENSE](../../LICENSE)

View file

@ -0,0 +1,68 @@
# Module — Updates
**Type**: Core (always enabled)
Run `apt update && apt full-upgrade` on the Proxmox host or any LXC container, with real-time streaming output via WebSocket.
## Features
- Target: host, a specific LXC (`lxc:100`), or all LXC containers at once
- Output streamed line-by-line via WebSocket — no polling required
- Full output saved to `update_history` table in SQLite
- Admin-only action
## API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/updates/run` | JWT+Admin | Start an update job |
| GET | `/api/updates/history` | JWT | List past update jobs (last 50) |
### POST /api/updates/run
```json
{ "target": "host" }
```
```json
{ "target": "lxc:100" }
```
```json
{ "target": "all" }
```
Response:
```json
{ "job_id": "1710000000-ab3f9z12", "message": "Mise à jour démarrée" }
```
## WebSocket Streaming
Connect to `GET /ws/updates/{jobId}?token=<jwt>` to receive output in real time.
Message types published on the channel:
| Type | Payload | Description |
|------|---------|-------------|
| `update_output` | `{ "chunk": "..." }` | Line(s) of apt output |
| `update_done` | `{ "job_id": "..." }` | Job completed successfully |
| `update_error` | `{ "error": "..." }` | Job failed |
## How It Works
Updates run over SSH using the credentials configured during installation:
- **Host**: runs `DEBIAN_FRONTEND=noninteractive apt-get update && apt-get full-upgrade -y` directly
- **LXC**: runs the same command via `pct exec <vmid> -- bash -c '...'`
- **All**: iterates over `pct list` output and updates each container
## Requirements
- SSH access to the Proxmox host with sudo/root privileges
- `pct` available on the host (standard on Proxmox VE)
## License
MIT — see [LICENSE](../../LICENSE)