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>
336 lines
7.4 KiB
Go
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
|
|
}
|
|
}
|