Files
sogoms/internal/infra/ssh.go
Pierre 0b1977e0c4 SOGOMS v1.0.7 - 2FA obligatoire et Infrastructure Management
Phase 17g - Double Authentification:
- TOTP avec Google Authenticator/Authy
- QR code pour enrôlement
- Codes de backup (10 codes usage unique)
- Page /admin/security pour gestion 2FA
- Page /admin/users avec Reset 2FA (super_admin)
- 2FA obligatoire pour rôles configurés

Phase 21 - Infrastructure Management:
- SQLite pour données infra (/data/infra.db)
- SSH Pool avec reconnexion auto
- Gestion Incus (list, start, stop, restart, sync)
- Gestion Nginx (test, reload, deploy, sync, certbot)
- Interface admin /admin/infra
- Formulaire ajout serveur
- Page détail serveur avec containers et sites

Fichiers créés:
- internal/infra/ (db, models, migrations, repository, ssh, incus, nginx)
- cmd/sogoms/admin/totp.go
- cmd/sogoms/admin/handlers_2fa.go
- cmd/sogoms/admin/handlers_infra.go
- Templates: 2fa_*, security, users, infra, server_*

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:21:11 +01:00

336 lines
7.4 KiB
Go

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