feat: initialisation complète du CORE ProxmoxPanel

Backend Go 1.23+ :
- API REST + WebSocket (chi, gorilla/websocket)
- Authentification PAM via SSH + JWT RS256
- Chiffrement AES-256-GCM pour secrets SQLite
- Pool SSH, client Proxmox REST, hub WebSocket pub/sub
- Système de modules compilés à initialisation conditionnelle
- Audit log, migrations SQLite versionnées

Frontend Vue 3 + Vite + TypeScript :
- Thème Neumorphism sombre/clair (CSS custom properties)
- Wizard d'installation, Dashboard drag-drop, Terminal xterm.js
- Toutes les vues CORE + stubs modules optionnels
- i18n EN/FR (vue-i18n v11)

Infrastructure :
- Docker multi-stage (Go → alpine, Node → nginx)
- docker-compose.yml, .gitattributes, LICENSE MIT, README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
enzo 2026-03-20 21:08:53 +01:00
commit 5dbcb1df07
66 changed files with 10370 additions and 0 deletions

View file

@ -0,0 +1,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()
}
}
}