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

306
internal/infra/nginx.go Normal file
View File

@@ -0,0 +1,306 @@
// Package infra gère l'infrastructure (serveurs, containers, nginx).
package infra
import (
"context"
"fmt"
"path/filepath"
"strings"
)
const (
// NginxSitesAvailable est le répertoire des sites disponibles.
NginxSitesAvailable = "/etc/nginx/sites-available"
// NginxSitesEnabled est le répertoire des sites activés.
NginxSitesEnabled = "/etc/nginx/sites-enabled"
)
// NginxSiteInfo représente les infos d'un site Nginx.
type NginxSiteInfo struct {
Name string
Enabled bool
Config string
HasSSL bool
Domains []string
Upstream string
}
// TestNginxConfig teste la configuration Nginx.
func (c *SSHClient) TestNginxConfig(ctx context.Context) error {
result, err := c.Exec(ctx, "nginx -t")
if err != nil {
return fmt.Errorf("nginx test: %w", err)
}
// nginx -t écrit sur stderr même en cas de succès
if result.ExitCode != 0 {
return fmt.Errorf("nginx config invalid: %s", result.Stderr)
}
return nil
}
// ReloadNginx recharge la configuration Nginx.
func (c *SSHClient) ReloadNginx(ctx context.Context) error {
// D'abord tester la config
if err := c.TestNginxConfig(ctx); err != nil {
return err
}
result, err := c.Exec(ctx, "systemctl reload nginx")
if err != nil {
return fmt.Errorf("nginx reload: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("nginx reload failed: %s", result.Stderr)
}
return nil
}
// RestartNginx redémarre Nginx.
func (c *SSHClient) RestartNginx(ctx context.Context) error {
result, err := c.Exec(ctx, "systemctl restart nginx")
if err != nil {
return fmt.Errorf("nginx restart: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("nginx restart failed: %s", result.Stderr)
}
return nil
}
// NginxStatus retourne le statut de Nginx.
func (c *SSHClient) NginxStatus(ctx context.Context) (string, error) {
result, err := c.Exec(ctx, "systemctl is-active nginx")
if err != nil {
return "unknown", nil
}
return strings.TrimSpace(result.Stdout), nil
}
// ListNginxSites liste les sites Nginx configurés.
func (c *SSHClient) ListNginxSites(ctx context.Context) ([]NginxSiteInfo, error) {
// Lister sites-available
result, err := c.Exec(ctx, fmt.Sprintf("ls -1 %s", NginxSitesAvailable))
if err != nil {
return nil, fmt.Errorf("list sites-available: %w", err)
}
if result.ExitCode != 0 {
return nil, fmt.Errorf("list sites-available failed: %s", result.Stderr)
}
// Lister sites-enabled
enabledResult, err := c.Exec(ctx, fmt.Sprintf("ls -1 %s", NginxSitesEnabled))
if err != nil {
return nil, fmt.Errorf("list sites-enabled: %w", err)
}
enabledSet := make(map[string]bool)
for _, name := range strings.Split(enabledResult.Stdout, "\n") {
name = strings.TrimSpace(name)
if name != "" {
enabledSet[name] = true
}
}
var sites []NginxSiteInfo
for _, name := range strings.Split(result.Stdout, "\n") {
name = strings.TrimSpace(name)
if name == "" || name == "default" {
continue
}
site := NginxSiteInfo{
Name: name,
Enabled: enabledSet[name],
}
sites = append(sites, site)
}
return sites, nil
}
// GetNginxSiteConfig récupère la config d'un site.
func (c *SSHClient) GetNginxSiteConfig(ctx context.Context, name string) (string, error) {
path := filepath.Join(NginxSitesAvailable, name)
content, err := c.ReadFile(ctx, path)
if err != nil {
return "", fmt.Errorf("read site config: %w", err)
}
return string(content), nil
}
// WriteNginxSiteConfig écrit la config d'un site.
func (c *SSHClient) WriteNginxSiteConfig(ctx context.Context, name string, config string) error {
path := filepath.Join(NginxSitesAvailable, name)
return c.WriteFile(ctx, path, []byte(config), "644")
}
// EnableNginxSite active un site (crée le lien symbolique).
func (c *SSHClient) EnableNginxSite(ctx context.Context, name string) error {
src := filepath.Join(NginxSitesAvailable, name)
dst := filepath.Join(NginxSitesEnabled, name)
// Vérifier que le site existe
exists, err := c.FileExists(ctx, src)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("site %s does not exist", name)
}
// Créer le lien
result, err := c.Exec(ctx, fmt.Sprintf("ln -sf %s %s", src, dst))
if err != nil {
return fmt.Errorf("enable site: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("enable site failed: %s", result.Stderr)
}
return nil
}
// DisableNginxSite désactive un site (supprime le lien symbolique).
func (c *SSHClient) DisableNginxSite(ctx context.Context, name string) error {
path := filepath.Join(NginxSitesEnabled, name)
result, err := c.Exec(ctx, fmt.Sprintf("rm -f %s", path))
if err != nil {
return fmt.Errorf("disable site: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("disable site failed: %s", result.Stderr)
}
return nil
}
// DeleteNginxSite supprime un site (désactive puis supprime).
func (c *SSHClient) DeleteNginxSite(ctx context.Context, name string) error {
// D'abord désactiver
if err := c.DisableNginxSite(ctx, name); err != nil {
return err
}
// Puis supprimer
path := filepath.Join(NginxSitesAvailable, name)
result, err := c.Exec(ctx, fmt.Sprintf("rm -f %s", path))
if err != nil {
return fmt.Errorf("delete site: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("delete site failed: %s", result.Stderr)
}
return nil
}
// DeployNginxSite déploie un site complet (écrire, activer, recharger).
func (c *SSHClient) DeployNginxSite(ctx context.Context, name string, config string) error {
// Écrire la config
if err := c.WriteNginxSiteConfig(ctx, name, config); err != nil {
return fmt.Errorf("write config: %w", err)
}
// Activer le site
if err := c.EnableNginxSite(ctx, name); err != nil {
return fmt.Errorf("enable site: %w", err)
}
// Recharger Nginx
if err := c.ReloadNginx(ctx); err != nil {
// En cas d'erreur, désactiver le site
c.DisableNginxSite(ctx, name)
return fmt.Errorf("reload nginx: %w", err)
}
return nil
}
// GenerateNginxProxyConfig génère une config proxy standard.
func GenerateNginxProxyConfig(domain, upstream string, ssl bool) string {
var config strings.Builder
if ssl {
// Redirect HTTP to HTTPS
config.WriteString(fmt.Sprintf(`server {
listen 80;
listen [::]:80;
server_name %s;
return 301 https://$server_name$request_uri;
}
`, domain))
}
// Main server block
if ssl {
config.WriteString(fmt.Sprintf(`server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name %s;
ssl_certificate /etc/letsencrypt/live/%s/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/%s/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
location / {
proxy_pass %s;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
}
`, domain, domain, domain, upstream))
} else {
config.WriteString(fmt.Sprintf(`server {
listen 80;
listen [::]:80;
server_name %s;
location / {
proxy_pass %s;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
}
`, domain, upstream))
}
return config.String()
}
// RequestSSLCertificate demande un certificat Let's Encrypt.
func (c *SSHClient) RequestSSLCertificate(ctx context.Context, domain, email string) error {
cmd := fmt.Sprintf("certbot certonly --nginx -d %s --non-interactive --agree-tos -m %s", domain, email)
result, err := c.Exec(ctx, cmd)
if err != nil {
return fmt.Errorf("certbot: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("certbot failed: %s", result.Stderr)
}
return nil
}
// CheckSSLCertificate vérifie si un certificat SSL existe.
func (c *SSHClient) CheckSSLCertificate(ctx context.Context, domain string) (bool, error) {
path := fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", domain)
return c.FileExists(ctx, path)
}