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