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:
52
internal/infra/db.go
Normal file
52
internal/infra/db.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// DB représente la connexion à la base SQLite.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// Open ouvre ou crée la base de données SQLite.
|
||||
func Open(dbPath string) (*DB, error) {
|
||||
// Créer le répertoire si nécessaire
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create db directory: %w", err)
|
||||
}
|
||||
|
||||
// Ouvrir la connexion
|
||||
sqlDB, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
// Tester la connexion
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
db := &DB{sqlDB}
|
||||
|
||||
// Exécuter les migrations
|
||||
if err := db.Migrate(); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("migrate database: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Close ferme la connexion.
|
||||
func (db *DB) Close() error {
|
||||
return db.DB.Close()
|
||||
}
|
||||
227
internal/infra/incus.go
Normal file
227
internal/infra/incus.go
Normal 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))
|
||||
}
|
||||
92
internal/infra/migrations.go
Normal file
92
internal/infra/migrations.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||
package infra
|
||||
|
||||
import "fmt"
|
||||
|
||||
// migrations contient les migrations SQL à exécuter dans l'ordre.
|
||||
var migrations = []string{
|
||||
// Table servers
|
||||
`CREATE TABLE IF NOT EXISTS servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
host TEXT NOT NULL,
|
||||
vpn_ip TEXT,
|
||||
ssh_port INTEGER NOT NULL DEFAULT 22,
|
||||
ssh_user TEXT NOT NULL DEFAULT 'root',
|
||||
ssh_key_file TEXT NOT NULL,
|
||||
has_incus INTEGER NOT NULL DEFAULT 0,
|
||||
has_nginx INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
|
||||
// Table containers
|
||||
`CREATE TABLE IF NOT EXISTS containers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
incus_name TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
vpn_ip TEXT,
|
||||
image TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
UNIQUE(server_id, incus_name)
|
||||
)`,
|
||||
|
||||
// Table nginx_configs
|
||||
`CREATE TABLE IF NOT EXISTS nginx_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'proxy',
|
||||
template TEXT,
|
||||
upstream TEXT,
|
||||
ssl_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
config_content TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'inactive',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
UNIQUE(server_id, domain)
|
||||
)`,
|
||||
|
||||
// Table app_bindings
|
||||
`CREATE TABLE IF NOT EXISTS app_bindings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
app_id TEXT NOT NULL,
|
||||
container_id INTEGER,
|
||||
nginx_config_id INTEGER,
|
||||
server_id INTEGER,
|
||||
type TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (container_id) REFERENCES containers(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (nginx_config_id) REFERENCES nginx_configs(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL
|
||||
)`,
|
||||
|
||||
// Index pour accélérer les requêtes
|
||||
`CREATE INDEX IF NOT EXISTS idx_containers_server ON containers(server_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_nginx_configs_server ON nginx_configs(server_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_nginx_configs_domain ON nginx_configs(domain)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_app_bindings_app ON app_bindings(app_id)`,
|
||||
|
||||
// Table migrations pour tracking des versions
|
||||
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
}
|
||||
|
||||
// Migrate exécute toutes les migrations.
|
||||
func (db *DB) Migrate() error {
|
||||
for i, migration := range migrations {
|
||||
if _, err := db.Exec(migration); err != nil {
|
||||
return fmt.Errorf("migration %d failed: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
129
internal/infra/models.go
Normal file
129
internal/infra/models.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||
package infra
|
||||
|
||||
import "time"
|
||||
|
||||
// ServerStatus représente l'état d'un serveur.
|
||||
type ServerStatus string
|
||||
|
||||
const (
|
||||
ServerStatusOnline ServerStatus = "online"
|
||||
ServerStatusOffline ServerStatus = "offline"
|
||||
ServerStatusUnknown ServerStatus = "unknown"
|
||||
)
|
||||
|
||||
// ContainerStatus représente l'état d'un container.
|
||||
type ContainerStatus string
|
||||
|
||||
const (
|
||||
ContainerStatusRunning ContainerStatus = "running"
|
||||
ContainerStatusStopped ContainerStatus = "stopped"
|
||||
ContainerStatusUnknown ContainerStatus = "unknown"
|
||||
)
|
||||
|
||||
// NginxConfigStatus représente l'état d'une config Nginx.
|
||||
type NginxConfigStatus string
|
||||
|
||||
const (
|
||||
NginxConfigStatusActive NginxConfigStatus = "active"
|
||||
NginxConfigStatusInactive NginxConfigStatus = "inactive"
|
||||
NginxConfigStatusError NginxConfigStatus = "error"
|
||||
)
|
||||
|
||||
// NginxConfigType représente le type de config Nginx.
|
||||
type NginxConfigType string
|
||||
|
||||
const (
|
||||
NginxTypeProxy NginxConfigType = "proxy"
|
||||
NginxTypeStatic NginxConfigType = "static"
|
||||
NginxTypeSocket NginxConfigType = "socket"
|
||||
)
|
||||
|
||||
// BindingType représente le type de binding app.
|
||||
type BindingType string
|
||||
|
||||
const (
|
||||
BindingTypeContainer BindingType = "container"
|
||||
BindingTypeNginx BindingType = "nginx"
|
||||
BindingTypeServer BindingType = "server"
|
||||
)
|
||||
|
||||
// Server représente un serveur physique ou VM.
|
||||
type Server struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
VpnIP string `json:"vpn_ip,omitempty"`
|
||||
SSHPort int `json:"ssh_port"`
|
||||
SSHUser string `json:"ssh_user"`
|
||||
SSHKeyFile string `json:"ssh_key_file"`
|
||||
HasIncus bool `json:"has_incus"`
|
||||
HasNginx bool `json:"has_nginx"`
|
||||
Status ServerStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Container représente un container Incus.
|
||||
type Container struct {
|
||||
ID int64 `json:"id"`
|
||||
ServerID int64 `json:"server_id"`
|
||||
Name string `json:"name"`
|
||||
IncusName string `json:"incus_name"`
|
||||
IP string `json:"ip"`
|
||||
VpnIP string `json:"vpn_ip,omitempty"`
|
||||
Image string `json:"image"`
|
||||
Status ContainerStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Relation (populé par query)
|
||||
Server *Server `json:"server,omitempty"`
|
||||
}
|
||||
|
||||
// NginxConfig représente une configuration Nginx.
|
||||
type NginxConfig struct {
|
||||
ID int64 `json:"id"`
|
||||
ServerID int64 `json:"server_id"`
|
||||
Domain string `json:"domain"`
|
||||
Type NginxConfigType `json:"type"`
|
||||
Template string `json:"template,omitempty"`
|
||||
Upstream string `json:"upstream,omitempty"`
|
||||
SSLEnabled bool `json:"ssl_enabled"`
|
||||
ConfigContent string `json:"config_content,omitempty"`
|
||||
Status NginxConfigStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Relation (populé par query)
|
||||
Server *Server `json:"server,omitempty"`
|
||||
}
|
||||
|
||||
// AppBinding représente le lien entre une app SOGOMS et l'infrastructure.
|
||||
type AppBinding struct {
|
||||
ID int64 `json:"id"`
|
||||
AppID string `json:"app_id"`
|
||||
ContainerID *int64 `json:"container_id,omitempty"`
|
||||
NginxConfigID *int64 `json:"nginx_config_id,omitempty"`
|
||||
ServerID *int64 `json:"server_id,omitempty"`
|
||||
Type BindingType `json:"type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Relations (populées par query)
|
||||
Container *Container `json:"container,omitempty"`
|
||||
NginxConfig *NginxConfig `json:"nginx_config,omitempty"`
|
||||
Server *Server `json:"server,omitempty"`
|
||||
}
|
||||
|
||||
// ServerWithContainers représente un serveur avec ses containers.
|
||||
type ServerWithContainers struct {
|
||||
Server
|
||||
Containers []Container `json:"containers"`
|
||||
}
|
||||
|
||||
// InfraOverview représente une vue globale de l'infrastructure.
|
||||
type InfraOverview struct {
|
||||
Servers []ServerWithContainers `json:"servers"`
|
||||
NginxConfigs []NginxConfig `json:"nginx_configs"`
|
||||
AppBindings []AppBinding `json:"app_bindings"`
|
||||
}
|
||||
306
internal/infra/nginx.go
Normal file
306
internal/infra/nginx.go
Normal 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)
|
||||
}
|
||||
469
internal/infra/repository.go
Normal file
469
internal/infra/repository.go
Normal file
@@ -0,0 +1,469 @@
|
||||
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Servers
|
||||
// ============================================================================
|
||||
|
||||
// CreateServer crée un nouveau serveur.
|
||||
func (db *DB) CreateServer(s *Server) error {
|
||||
now := time.Now()
|
||||
result, err := db.Exec(`
|
||||
INSERT INTO servers (name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
s.Name, s.Host, nullString(s.VpnIP), s.SSHPort, s.SSHUser, s.SSHKeyFile,
|
||||
boolToInt(s.HasIncus), boolToInt(s.HasNginx), s.Status, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert server: %w", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
s.ID = id
|
||||
s.CreatedAt = now
|
||||
s.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServer récupère un serveur par ID.
|
||||
func (db *DB) GetServer(id int64) (*Server, error) {
|
||||
s := &Server{}
|
||||
err := db.QueryRow(`
|
||||
SELECT id, name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at
|
||||
FROM servers WHERE id = ?`, id).Scan(
|
||||
&s.ID, &s.Name, &s.Host, &s.VpnIP, &s.SSHPort, &s.SSHUser, &s.SSHKeyFile,
|
||||
&s.HasIncus, &s.HasNginx, &s.Status, &s.CreatedAt, &s.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get server: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// GetServerByName récupère un serveur par nom.
|
||||
func (db *DB) GetServerByName(name string) (*Server, error) {
|
||||
s := &Server{}
|
||||
err := db.QueryRow(`
|
||||
SELECT id, name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at
|
||||
FROM servers WHERE name = ?`, name).Scan(
|
||||
&s.ID, &s.Name, &s.Host, &s.VpnIP, &s.SSHPort, &s.SSHUser, &s.SSHKeyFile,
|
||||
&s.HasIncus, &s.HasNginx, &s.Status, &s.CreatedAt, &s.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get server by name: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ListServers retourne tous les serveurs.
|
||||
func (db *DB) ListServers() ([]Server, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at
|
||||
FROM servers ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list servers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var servers []Server
|
||||
for rows.Next() {
|
||||
var s Server
|
||||
if err := rows.Scan(&s.ID, &s.Name, &s.Host, &s.VpnIP, &s.SSHPort, &s.SSHUser, &s.SSHKeyFile,
|
||||
&s.HasIncus, &s.HasNginx, &s.Status, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan server: %w", err)
|
||||
}
|
||||
servers = append(servers, s)
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// UpdateServer met à jour un serveur.
|
||||
func (db *DB) UpdateServer(s *Server) error {
|
||||
s.UpdatedAt = time.Now()
|
||||
_, err := db.Exec(`
|
||||
UPDATE servers SET name=?, host=?, vpn_ip=?, ssh_port=?, ssh_user=?, ssh_key_file=?,
|
||||
has_incus=?, has_nginx=?, status=?, updated_at=? WHERE id=?`,
|
||||
s.Name, s.Host, nullString(s.VpnIP), s.SSHPort, s.SSHUser, s.SSHKeyFile,
|
||||
boolToInt(s.HasIncus), boolToInt(s.HasNginx), s.Status, s.UpdatedAt, s.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateServerStatus met à jour le statut d'un serveur.
|
||||
func (db *DB) UpdateServerStatus(id int64, status ServerStatus) error {
|
||||
_, err := db.Exec(`UPDATE servers SET status=?, updated_at=? WHERE id=?`,
|
||||
status, time.Now(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update server status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteServer supprime un serveur.
|
||||
func (db *DB) DeleteServer(id int64) error {
|
||||
_, err := db.Exec(`DELETE FROM servers WHERE id=?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Containers
|
||||
// ============================================================================
|
||||
|
||||
// CreateContainer crée un nouveau container.
|
||||
func (db *DB) CreateContainer(c *Container) error {
|
||||
now := time.Now()
|
||||
result, err := db.Exec(`
|
||||
INSERT INTO containers (server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ServerID, c.Name, c.IncusName, nullString(c.IP), nullString(c.VpnIP), c.Image, c.Status, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert container: %w", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
c.ID = id
|
||||
c.CreatedAt = now
|
||||
c.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetContainer récupère un container par ID.
|
||||
func (db *DB) GetContainer(id int64) (*Container, error) {
|
||||
c := &Container{}
|
||||
err := db.QueryRow(`
|
||||
SELECT id, server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at
|
||||
FROM containers WHERE id = ?`, id).Scan(
|
||||
&c.ID, &c.ServerID, &c.Name, &c.IncusName, &c.IP, &c.VpnIP, &c.Image, &c.Status, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get container: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ListContainersByServer retourne les containers d'un serveur.
|
||||
func (db *DB) ListContainersByServer(serverID int64) ([]Container, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at
|
||||
FROM containers WHERE server_id = ? ORDER BY name`, serverID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list containers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var containers []Container
|
||||
for rows.Next() {
|
||||
var c Container
|
||||
if err := rows.Scan(&c.ID, &c.ServerID, &c.Name, &c.IncusName, &c.IP, &c.VpnIP, &c.Image, &c.Status, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan container: %w", err)
|
||||
}
|
||||
containers = append(containers, c)
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
// ListAllContainers retourne tous les containers.
|
||||
func (db *DB) ListAllContainers() ([]Container, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at
|
||||
FROM containers ORDER BY server_id, name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list all containers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var containers []Container
|
||||
for rows.Next() {
|
||||
var c Container
|
||||
if err := rows.Scan(&c.ID, &c.ServerID, &c.Name, &c.IncusName, &c.IP, &c.VpnIP, &c.Image, &c.Status, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan container: %w", err)
|
||||
}
|
||||
containers = append(containers, c)
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
// UpdateContainer met à jour un container.
|
||||
func (db *DB) UpdateContainer(c *Container) error {
|
||||
c.UpdatedAt = time.Now()
|
||||
_, err := db.Exec(`
|
||||
UPDATE containers SET server_id=?, name=?, incus_name=?, ip=?, vpn_ip=?, image=?, status=?, updated_at=?
|
||||
WHERE id=?`,
|
||||
c.ServerID, c.Name, c.IncusName, nullString(c.IP), nullString(c.VpnIP), c.Image, c.Status, c.UpdatedAt, c.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update container: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateContainerStatus met à jour le statut d'un container.
|
||||
func (db *DB) UpdateContainerStatus(id int64, status ContainerStatus) error {
|
||||
_, err := db.Exec(`UPDATE containers SET status=?, updated_at=? WHERE id=?`,
|
||||
status, time.Now(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update container status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteContainer supprime un container.
|
||||
func (db *DB) DeleteContainer(id int64) error {
|
||||
_, err := db.Exec(`DELETE FROM containers WHERE id=?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete container: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NginxConfigs
|
||||
// ============================================================================
|
||||
|
||||
// CreateNginxConfig crée une nouvelle config Nginx.
|
||||
func (db *DB) CreateNginxConfig(n *NginxConfig) error {
|
||||
now := time.Now()
|
||||
result, err := db.Exec(`
|
||||
INSERT INTO nginx_configs (server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
n.ServerID, n.Domain, n.Type, nullString(n.Template), nullString(n.Upstream),
|
||||
boolToInt(n.SSLEnabled), nullString(n.ConfigContent), n.Status, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert nginx config: %w", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
n.ID = id
|
||||
n.CreatedAt = now
|
||||
n.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNginxConfig récupère une config Nginx par ID.
|
||||
func (db *DB) GetNginxConfig(id int64) (*NginxConfig, error) {
|
||||
n := &NginxConfig{}
|
||||
err := db.QueryRow(`
|
||||
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
|
||||
FROM nginx_configs WHERE id = ?`, id).Scan(
|
||||
&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
|
||||
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get nginx config: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GetNginxConfigByDomain récupère une config par domaine.
|
||||
func (db *DB) GetNginxConfigByDomain(serverID int64, domain string) (*NginxConfig, error) {
|
||||
n := &NginxConfig{}
|
||||
err := db.QueryRow(`
|
||||
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
|
||||
FROM nginx_configs WHERE server_id = ? AND domain = ?`, serverID, domain).Scan(
|
||||
&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
|
||||
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get nginx config by domain: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ListNginxConfigsByServer retourne les configs d'un serveur.
|
||||
func (db *DB) ListNginxConfigsByServer(serverID int64) ([]NginxConfig, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
|
||||
FROM nginx_configs WHERE server_id = ? ORDER BY domain`, serverID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list nginx configs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var configs []NginxConfig
|
||||
for rows.Next() {
|
||||
var n NginxConfig
|
||||
if err := rows.Scan(&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
|
||||
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan nginx config: %w", err)
|
||||
}
|
||||
configs = append(configs, n)
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// ListAllNginxConfigs retourne toutes les configs.
|
||||
func (db *DB) ListAllNginxConfigs() ([]NginxConfig, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
|
||||
FROM nginx_configs ORDER BY server_id, domain`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list all nginx configs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var configs []NginxConfig
|
||||
for rows.Next() {
|
||||
var n NginxConfig
|
||||
if err := rows.Scan(&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
|
||||
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan nginx config: %w", err)
|
||||
}
|
||||
configs = append(configs, n)
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// UpdateNginxConfig met à jour une config.
|
||||
func (db *DB) UpdateNginxConfig(n *NginxConfig) error {
|
||||
n.UpdatedAt = time.Now()
|
||||
_, err := db.Exec(`
|
||||
UPDATE nginx_configs SET server_id=?, domain=?, type=?, template=?, upstream=?,
|
||||
ssl_enabled=?, config_content=?, status=?, updated_at=? WHERE id=?`,
|
||||
n.ServerID, n.Domain, n.Type, nullString(n.Template), nullString(n.Upstream),
|
||||
boolToInt(n.SSLEnabled), nullString(n.ConfigContent), n.Status, n.UpdatedAt, n.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update nginx config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteNginxConfig supprime une config.
|
||||
func (db *DB) DeleteNginxConfig(id int64) error {
|
||||
_, err := db.Exec(`DELETE FROM nginx_configs WHERE id=?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete nginx config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AppBindings
|
||||
// ============================================================================
|
||||
|
||||
// CreateAppBinding crée un nouveau binding.
|
||||
func (db *DB) CreateAppBinding(b *AppBinding) error {
|
||||
now := time.Now()
|
||||
result, err := db.Exec(`
|
||||
INSERT INTO app_bindings (app_id, container_id, nginx_config_id, server_id, type, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
b.AppID, nullInt64(b.ContainerID), nullInt64(b.NginxConfigID), nullInt64(b.ServerID), b.Type, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert app binding: %w", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
b.ID = id
|
||||
b.CreatedAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAppBindings retourne les bindings d'une app.
|
||||
func (db *DB) ListAppBindings(appID string) ([]AppBinding, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, app_id, container_id, nginx_config_id, server_id, type, created_at
|
||||
FROM app_bindings WHERE app_id = ?`, appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list app bindings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var bindings []AppBinding
|
||||
for rows.Next() {
|
||||
var b AppBinding
|
||||
if err := rows.Scan(&b.ID, &b.AppID, &b.ContainerID, &b.NginxConfigID, &b.ServerID, &b.Type, &b.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan app binding: %w", err)
|
||||
}
|
||||
bindings = append(bindings, b)
|
||||
}
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
// DeleteAppBinding supprime un binding.
|
||||
func (db *DB) DeleteAppBinding(id int64) error {
|
||||
_, err := db.Exec(`DELETE FROM app_bindings WHERE id=?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete app binding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAppBindingsByApp supprime tous les bindings d'une app.
|
||||
func (db *DB) DeleteAppBindingsByApp(appID string) error {
|
||||
_, err := db.Exec(`DELETE FROM app_bindings WHERE app_id=?`, appID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete app bindings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
func nullString(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nullInt64(i *int64) interface{} {
|
||||
if i == nil {
|
||||
return nil
|
||||
}
|
||||
return *i
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Aggregate queries
|
||||
// ============================================================================
|
||||
|
||||
// GetInfraOverview retourne une vue complète de l'infrastructure.
|
||||
func (db *DB) GetInfraOverview() (*InfraOverview, error) {
|
||||
servers, err := db.ListServers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
overview := &InfraOverview{
|
||||
Servers: make([]ServerWithContainers, len(servers)),
|
||||
}
|
||||
|
||||
for i, s := range servers {
|
||||
overview.Servers[i].Server = s
|
||||
containers, err := db.ListContainersByServer(s.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overview.Servers[i].Containers = containers
|
||||
}
|
||||
|
||||
overview.NginxConfigs, err = db.ListAllNginxConfigs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return overview, nil
|
||||
}
|
||||
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