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>
307 lines
8.3 KiB
Go
307 lines
8.3 KiB
Go
// 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)
|
|
}
|