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

52
internal/infra/db.go Normal file
View 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
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))
}

View 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
View 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
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)
}

View 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
View 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
}
}