// Package infra gère l'infrastructure (serveurs, containers, nginx). package infra import ( "bytes" "context" "fmt" "io" "os" "strings" "sync" "time" "golang.org/x/crypto/ssh" ) // SSHClient représente une connexion SSH à un serveur. type SSHClient struct { server *Server client *ssh.Client mu sync.Mutex } // SSHPool gère un pool de connexions SSH. type SSHPool struct { clients map[int64]*SSHClient mu sync.RWMutex timeout time.Duration } // SSHResult représente le résultat d'une commande SSH. type SSHResult struct { Stdout string Stderr string ExitCode int Duration time.Duration } // NewSSHPool crée un nouveau pool SSH. func NewSSHPool(timeout time.Duration) *SSHPool { if timeout == 0 { timeout = 30 * time.Second } return &SSHPool{ clients: make(map[int64]*SSHClient), timeout: timeout, } } // Connect établit une connexion SSH à un serveur. func (p *SSHPool) Connect(server *Server) (*SSHClient, error) { p.mu.Lock() defer p.mu.Unlock() // Vérifier si déjà connecté if client, ok := p.clients[server.ID]; ok { if client.isAlive() { return client, nil } // Connexion morte, la supprimer client.Close() delete(p.clients, server.ID) } // Lire la clé SSH keyData, err := os.ReadFile(server.SSHKeyFile) if err != nil { return nil, fmt.Errorf("read ssh key: %w", err) } signer, err := ssh.ParsePrivateKey(keyData) if err != nil { return nil, fmt.Errorf("parse ssh key: %w", err) } // Config SSH config := &ssh.ClientConfig{ User: server.SSHUser, Auth: []ssh.AuthMethod{ ssh.PublicKeys(signer), }, HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: gérer les known_hosts Timeout: p.timeout, } // Connexion addr := fmt.Sprintf("%s:%d", server.Host, server.SSHPort) sshClient, err := ssh.Dial("tcp", addr, config) if err != nil { return nil, fmt.Errorf("ssh dial %s: %w", addr, err) } client := &SSHClient{ server: server, client: sshClient, } p.clients[server.ID] = client return client, nil } // Get récupère un client existant ou nil. func (p *SSHPool) Get(serverID int64) *SSHClient { p.mu.RLock() defer p.mu.RUnlock() return p.clients[serverID] } // Disconnect ferme la connexion à un serveur. func (p *SSHPool) Disconnect(serverID int64) { p.mu.Lock() defer p.mu.Unlock() if client, ok := p.clients[serverID]; ok { client.Close() delete(p.clients, serverID) } } // CloseAll ferme toutes les connexions. func (p *SSHPool) CloseAll() { p.mu.Lock() defer p.mu.Unlock() for id, client := range p.clients { client.Close() delete(p.clients, id) } } // isAlive vérifie si la connexion est active. func (c *SSHClient) isAlive() bool { c.mu.Lock() defer c.mu.Unlock() if c.client == nil { return false } // Test rapide avec une session session, err := c.client.NewSession() if err != nil { return false } session.Close() return true } // Close ferme la connexion. func (c *SSHClient) Close() error { c.mu.Lock() defer c.mu.Unlock() if c.client != nil { err := c.client.Close() c.client = nil return err } return nil } // Exec exécute une commande sur le serveur distant. func (c *SSHClient) Exec(ctx context.Context, cmd string) (*SSHResult, error) { c.mu.Lock() defer c.mu.Unlock() if c.client == nil { return nil, fmt.Errorf("ssh client not connected") } session, err := c.client.NewSession() if err != nil { return nil, fmt.Errorf("create session: %w", err) } defer session.Close() var stdout, stderr bytes.Buffer session.Stdout = &stdout session.Stderr = &stderr start := time.Now() // Exécuter avec timeout done := make(chan error, 1) go func() { done <- session.Run(cmd) }() select { case <-ctx.Done(): session.Signal(ssh.SIGKILL) return nil, ctx.Err() case err := <-done: result := &SSHResult{ Stdout: stdout.String(), Stderr: stderr.String(), Duration: time.Since(start), } if err != nil { if exitErr, ok := err.(*ssh.ExitError); ok { result.ExitCode = exitErr.ExitStatus() } else { return nil, fmt.Errorf("exec command: %w", err) } } return result, nil } } // ExecSimple exécute une commande et retourne stdout. func (c *SSHClient) ExecSimple(ctx context.Context, cmd string) (string, error) { result, err := c.Exec(ctx, cmd) if err != nil { return "", err } if result.ExitCode != 0 { return "", fmt.Errorf("command failed (exit %d): %s", result.ExitCode, result.Stderr) } return strings.TrimSpace(result.Stdout), nil } // WriteFile écrit un fichier sur le serveur distant via cat. func (c *SSHClient) WriteFile(ctx context.Context, path string, content []byte, mode string) error { c.mu.Lock() defer c.mu.Unlock() if c.client == nil { return fmt.Errorf("ssh client not connected") } session, err := c.client.NewSession() if err != nil { return fmt.Errorf("create session: %w", err) } defer session.Close() // Utiliser cat pour écrire le fichier stdin, err := session.StdinPipe() if err != nil { return fmt.Errorf("stdin pipe: %w", err) } cmd := fmt.Sprintf("cat > %s && chmod %s %s", path, mode, path) if err := session.Start(cmd); err != nil { return fmt.Errorf("start command: %w", err) } if _, err := stdin.Write(content); err != nil { return fmt.Errorf("write content: %w", err) } stdin.Close() if err := session.Wait(); err != nil { return fmt.Errorf("wait command: %w", err) } return nil } // ReadFile lit un fichier depuis le serveur distant. func (c *SSHClient) ReadFile(ctx context.Context, path string) ([]byte, error) { result, err := c.Exec(ctx, fmt.Sprintf("cat %s", path)) if err != nil { return nil, err } if result.ExitCode != 0 { return nil, fmt.Errorf("read file failed: %s", result.Stderr) } return []byte(result.Stdout), nil } // FileExists vérifie si un fichier existe. func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) { result, err := c.Exec(ctx, fmt.Sprintf("test -e %s && echo yes || echo no", path)) if err != nil { return false, err } return strings.TrimSpace(result.Stdout) == "yes", nil } // SFTP retourne un client SFTP pour des opérations de fichiers avancées. // Note: nécessite github.com/pkg/sftp si on veut un vrai SFTP. // Pour l'instant on utilise des commandes shell. // CopyFile copie un fichier local vers le serveur distant. func (c *SSHClient) CopyFile(ctx context.Context, localPath, remotePath string) error { content, err := os.ReadFile(localPath) if err != nil { return fmt.Errorf("read local file: %w", err) } return c.WriteFile(ctx, remotePath, content, "644") } // CopyFrom copie un fichier du serveur distant vers local. func (c *SSHClient) CopyFrom(ctx context.Context, remotePath, localPath string) error { content, err := c.ReadFile(ctx, remotePath) if err != nil { return err } return os.WriteFile(localPath, content, 0644) } // StreamExec exécute une commande et stream la sortie. func (c *SSHClient) StreamExec(ctx context.Context, cmd string, stdout, stderr io.Writer) error { c.mu.Lock() defer c.mu.Unlock() if c.client == nil { return fmt.Errorf("ssh client not connected") } session, err := c.client.NewSession() if err != nil { return fmt.Errorf("create session: %w", err) } defer session.Close() session.Stdout = stdout session.Stderr = stderr done := make(chan error, 1) go func() { done <- session.Run(cmd) }() select { case <-ctx.Done(): session.Signal(ssh.SIGKILL) return ctx.Err() case err := <-done: return err } }