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>
216 lines
4.8 KiB
Go
216 lines
4.8 KiB
Go
// 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()
|
|
}
|
|
}
|
|
}
|