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:
2025-12-26 21:21:11 +01:00
parent 1274400b08
commit 0b1977e0c4
37 changed files with 4976 additions and 148 deletions

227
internal/infra/incus.go Normal file
View File

@@ -0,0 +1,227 @@
// Package infra gère l'infrastructure (serveurs, containers, nginx).
package infra
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// IncusContainer représente un container Incus retourné par incus list.
type IncusContainer struct {
Name string `json:"name"`
State string `json:"state"`
IPv4 []string `json:"ipv4"`
IPv6 []string `json:"ipv6"`
Type string `json:"type"`
Snapshots int `json:"snapshots"`
Location string `json:"location"`
Image string `json:"-"` // Rempli séparément
CreatedAt string `json:"created_at"`
}
// incusListJSON représente le format JSON de incus list.
type incusListJSON struct {
Name string `json:"name"`
Status string `json:"status"`
Type string `json:"type"`
Snapshots int `json:"snapshots"`
Location string `json:"location"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
}
// ListIncusContainers liste les containers Incus sur le serveur.
func (c *SSHClient) ListIncusContainers(ctx context.Context) ([]IncusContainer, error) {
// Récupérer la liste en format JSON
result, err := c.Exec(ctx, "incus list --format json")
if err != nil {
return nil, fmt.Errorf("incus list: %w", err)
}
if result.ExitCode != 0 {
return nil, fmt.Errorf("incus list failed: %s", result.Stderr)
}
// Parser le JSON
var rawContainers []struct {
Name string `json:"name"`
Status string `json:"status"`
StatusCode int `json:"status_code"`
Type string `json:"type"`
Config struct {
Image string `json:"image.description"`
} `json:"config"`
State struct {
Network map[string]struct {
Addresses []struct {
Address string `json:"address"`
Family string `json:"family"`
Scope string `json:"scope"`
} `json:"addresses"`
} `json:"network"`
} `json:"state"`
Snapshots []interface{} `json:"snapshots"`
Location string `json:"location"`
CreatedAt string `json:"created_at"`
}
if err := json.Unmarshal([]byte(result.Stdout), &rawContainers); err != nil {
return nil, fmt.Errorf("parse incus json: %w", err)
}
// Convertir en notre format
containers := make([]IncusContainer, 0, len(rawContainers))
for _, rc := range rawContainers {
container := IncusContainer{
Name: rc.Name,
State: strings.ToLower(rc.Status),
Type: rc.Type,
Snapshots: len(rc.Snapshots),
Location: rc.Location,
Image: rc.Config.Image,
CreatedAt: rc.CreatedAt,
}
// Extraire les IPs
for _, net := range rc.State.Network {
for _, addr := range net.Addresses {
if addr.Scope == "global" {
if addr.Family == "inet" {
container.IPv4 = append(container.IPv4, addr.Address)
} else if addr.Family == "inet6" {
container.IPv6 = append(container.IPv6, addr.Address)
}
}
}
}
containers = append(containers, container)
}
return containers, nil
}
// GetIncusContainer récupère les infos d'un container spécifique.
func (c *SSHClient) GetIncusContainer(ctx context.Context, name string) (*IncusContainer, error) {
containers, err := c.ListIncusContainers(ctx)
if err != nil {
return nil, err
}
for _, container := range containers {
if container.Name == name {
return &container, nil
}
}
return nil, fmt.Errorf("container %s not found", name)
}
// StartIncusContainer démarre un container.
func (c *SSHClient) StartIncusContainer(ctx context.Context, name string) error {
result, err := c.Exec(ctx, fmt.Sprintf("incus start %s", name))
if err != nil {
return fmt.Errorf("incus start: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("incus start %s failed: %s", name, result.Stderr)
}
return nil
}
// StopIncusContainer arrête un container.
func (c *SSHClient) StopIncusContainer(ctx context.Context, name string) error {
result, err := c.Exec(ctx, fmt.Sprintf("incus stop %s", name))
if err != nil {
return fmt.Errorf("incus stop: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("incus stop %s failed: %s", name, result.Stderr)
}
return nil
}
// RestartIncusContainer redémarre un container.
func (c *SSHClient) RestartIncusContainer(ctx context.Context, name string) error {
result, err := c.Exec(ctx, fmt.Sprintf("incus restart %s", name))
if err != nil {
return fmt.Errorf("incus restart: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("incus restart %s failed: %s", name, result.Stderr)
}
return nil
}
// ExecInContainer exécute une commande dans un container.
func (c *SSHClient) ExecInContainer(ctx context.Context, containerName, cmd string) (*SSHResult, error) {
fullCmd := fmt.Sprintf("incus exec %s -- %s", containerName, cmd)
return c.Exec(ctx, fullCmd)
}
// ExecInContainerSimple exécute une commande et retourne stdout.
func (c *SSHClient) ExecInContainerSimple(ctx context.Context, containerName, cmd string) (string, error) {
result, err := c.ExecInContainer(ctx, containerName, cmd)
if err != nil {
return "", err
}
if result.ExitCode != 0 {
return "", fmt.Errorf("command failed in %s (exit %d): %s", containerName, result.ExitCode, result.Stderr)
}
return strings.TrimSpace(result.Stdout), nil
}
// PushFileToContainer envoie un fichier vers un container.
func (c *SSHClient) PushFileToContainer(ctx context.Context, containerName, localPath, remotePath string) error {
// D'abord copier vers le serveur hôte
tempPath := fmt.Sprintf("/tmp/incus-push-%d", ctx.Value("request_id"))
if err := c.CopyFile(ctx, localPath, tempPath); err != nil {
return fmt.Errorf("copy to host: %w", err)
}
// Puis push vers le container
result, err := c.Exec(ctx, fmt.Sprintf("incus file push %s %s%s && rm %s",
tempPath, containerName, remotePath, tempPath))
if err != nil {
return fmt.Errorf("incus file push: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("incus file push failed: %s", result.Stderr)
}
return nil
}
// PullFileFromContainer récupère un fichier depuis un container.
func (c *SSHClient) PullFileFromContainer(ctx context.Context, containerName, remotePath, localPath string) error {
tempPath := fmt.Sprintf("/tmp/incus-pull-%d", ctx.Value("request_id"))
// Pull depuis le container vers l'hôte
result, err := c.Exec(ctx, fmt.Sprintf("incus file pull %s%s %s",
containerName, remotePath, tempPath))
if err != nil {
return fmt.Errorf("incus file pull: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("incus file pull failed: %s", result.Stderr)
}
// Puis copier vers local
if err := c.CopyFrom(ctx, tempPath, localPath); err != nil {
return fmt.Errorf("copy from host: %w", err)
}
// Nettoyer
c.Exec(ctx, fmt.Sprintf("rm %s", tempPath))
return nil
}
// GetContainerLogs récupère les logs d'un container.
func (c *SSHClient) GetContainerLogs(ctx context.Context, containerName string, lines int) (string, error) {
if lines <= 0 {
lines = 100
}
return c.ExecInContainerSimple(ctx, containerName, fmt.Sprintf("journalctl -n %d --no-pager", lines))
}