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>
This commit is contained in:
335
internal/infra/ssh.go
Normal file
335
internal/infra/ssh.go
Normal file
@@ -0,0 +1,335 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user