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

View File

@@ -11,33 +11,26 @@ import (
"sogoms.com/internal/admin"
"sogoms.com/internal/auth"
"sogoms.com/internal/config"
"sogoms.com/internal/infra"
)
// AdminServer contient les dépendances des handlers.
type AdminServer struct {
adminCfg *admin.AdminConfig
registry *config.Registry
sessions *SessionStore
version string
rateLimiter *RateLimiter
perms *admin.PermissionChecker
audit *admin.AuditLogger
services *ServicePool
templates *template.Template
templatesDir string
devMode bool
adminCfg *admin.AdminConfig
registry *config.Registry
sessions *SessionStore
version string
rateLimiter *RateLimiter
perms *admin.PermissionChecker
audit *admin.AuditLogger
services *ServicePool
templates *template.Template
infraDB *infra.DB
sshPool *infra.SSHPool
}
// getTemplates retourne les templates, en les rechargeant si devMode est activé.
// getTemplates retourne les templates.
func (s *AdminServer) getTemplates() *template.Template {
if s.devMode && s.templatesDir != "" {
tmpl, err := loadTemplates(s.templatesDir)
if err != nil {
log.Printf("[admin] reload templates error: %v", err)
return s.templates
}
return tmpl
}
return s.templates
}
@@ -99,8 +92,34 @@ func (s *AdminServer) HandleLogin(w http.ResponseWriter, r *http.Request) {
return
}
// Créer la session
session, err := s.sessions.Create(username, user.Role, ip, userAgent)
// Vérifier si 2FA est requis
needsTwoFA := user.NeedsTwoFA(&s.adminCfg.TwoFA)
var session *Session
var err error
if needsTwoFA && user.TwoFAEnabled {
// Créer une session en attente de validation 2FA
session, err = s.sessions.CreatePending(username, user.Role, ip, userAgent)
if err != nil {
log.Printf("[admin] failed to create pending session: %v", err)
http.Redirect(w, r, "/admin/login?error=Erreur+serveur", http.StatusSeeOther)
return
}
// Définir le cookie
s.sessions.SetCookie(w, session)
// Log tentative
s.audit.LogLogin(true, username, ip, userAgent, "pending_2fa")
// Rediriger vers vérification 2FA
http.Redirect(w, r, "/admin/2fa/verify", http.StatusSeeOther)
return
}
// Pas de 2FA requis ou pas configuré - créer session normale
session, err = s.sessions.Create(username, user.Role, ip, userAgent)
if err != nil {
log.Printf("[admin] failed to create session: %v", err)
http.Redirect(w, r, "/admin/login?error=Erreur+serveur", http.StatusSeeOther)
@@ -113,6 +132,12 @@ func (s *AdminServer) HandleLogin(w http.ResponseWriter, r *http.Request) {
// Log succès
s.audit.LogLogin(true, username, ip, userAgent, "")
// Si 2FA requis mais pas encore configuré, rediriger vers setup
if needsTwoFA && !user.TwoFAEnabled {
http.Redirect(w, r, "/admin/2fa/setup?required=true", http.StatusSeeOther)
return
}
// Rediriger vers dashboard
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
@@ -238,6 +263,9 @@ type TableInfo struct {
Name string
ColumnCount int
PrimaryKey string
SoftDelete bool
Cascade bool
ForeignKeys []string // Ex: ["project_id → projects", "user_id → users"]
}
// RouteInfo contient les infos d'une route pour le template.
@@ -377,14 +405,32 @@ func (s *AdminServer) HandleAppDetailPage(w http.ResponseWriter, r *http.Request
var tables []TableInfo
if cfg.Schema != nil {
for name, table := range cfg.Schema.Tables {
// Clé primaire : composite ou simple
pk := ""
if len(table.Primary) > 0 {
if len(table.Primary) > 1 {
pk = strings.Join(table.Primary, ", ")
} else {
pk = table.GetPrimaryKey()
}
// Collecter les clés étrangères
var fks []string
for colName, col := range table.Columns {
if col.Foreign != "" {
// col.Foreign = "table.column", on extrait juste la table
parts := strings.Split(col.Foreign, ".")
refTable := parts[0]
fks = append(fks, colName+" → "+refTable)
}
}
tables = append(tables, TableInfo{
Name: name,
ColumnCount: len(table.Columns),
PrimaryKey: pk,
SoftDelete: table.SoftDelete,
Cascade: table.Cascade,
ForeignKeys: fks,
})
}
}
@@ -540,6 +586,16 @@ func (s *AdminServer) HandleAppCreate(w http.ResponseWriter, r *http.Request) {
return
}
// Recharger le registry local
if err := s.registry.Load(); err != nil {
log.Printf("[admin] reload registry error: %v", err)
}
// Recharger sogoway pour qu'il connaisse la nouvelle app
if err := s.services.ReloadGateway(); err != nil {
log.Printf("[admin] reload gateway error: %v", err)
}
// Log l'action
s.audit.LogAction(user.Username, "create_app", appName, map[string]any{
"ip": getClientIP(r),
@@ -586,14 +642,27 @@ func (s *AdminServer) HandleAppScanDB(w http.ResponseWriter, r *http.Request) {
return
}
// Générer les fichiers queries depuis le schema
if err := GenerateQueriesFromSchema(appID); err != nil {
log.Printf("[admin] generate queries error: %v", err)
} else {
log.Printf("[admin] queries generated for app: %s", appID)
}
// Mettre à jour login_data dans auth.yaml
if err := UpdateLoginData(appID); err != nil {
log.Printf("[admin] update login_data error: %v", err)
// On ne bloque pas, le scan a réussi
} else {
log.Printf("[admin] login_data updated for app: %s", appID)
}
// Générer les routes dans app.yaml
if err := GenerateRoutesFromSchema(appID); err != nil {
log.Printf("[admin] generate routes error: %v", err)
} else {
log.Printf("[admin] routes generated for app: %s", appID)
}
// Recharger le registry local
if err := s.registry.Load(); err != nil {
log.Printf("[admin] reload registry error: %v", err)

View File

@@ -0,0 +1,455 @@
package main
import (
"log"
"net/http"
"strings"
"golang.org/x/crypto/bcrypt"
"sogoms.com/internal/admin"
)
// HandleTwoFAPage affiche la page de vérification 2FA.
func (s *AdminServer) HandleTwoFAPage(w http.ResponseWriter, r *http.Request) {
// Récupérer la session (doit être pending)
session, err := s.sessions.GetSessionFromRequest(r)
if err != nil || session == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Vérifier que la session est en attente de 2FA
if !session.TwoFAPending {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
data := map[string]any{
"Title": "Vérification 2FA",
"CSRFToken": session.CSRFToken,
"Error": r.URL.Query().Get("error"),
"Username": session.Username,
}
s.render(w, "2fa_verify.html", data)
}
// HandleTwoFAVerify valide le code TOTP ou le code de secours.
func (s *AdminServer) HandleTwoFAVerify(w http.ResponseWriter, r *http.Request) {
ip := getClientIP(r)
// Rate limiting
if !s.rateLimiter.Allow(ip) {
http.Redirect(w, r, "/admin/2fa/verify?error=Trop+de+tentatives", http.StatusSeeOther)
return
}
// Récupérer la session
session, err := s.sessions.GetSessionFromRequest(r)
if err != nil || session == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
if !session.TwoFAPending {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/2fa/verify?error=Formulaire+invalide", http.StatusSeeOther)
return
}
// Vérifier le CSRF token
if r.FormValue("csrf_token") != session.CSRFToken {
http.Redirect(w, r, "/admin/2fa/verify?error=Token+CSRF+invalide", http.StatusSeeOther)
return
}
s.rateLimiter.Record(ip)
// Récupérer l'utilisateur
user := s.adminCfg.GetUser(session.Username)
if user == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
code := strings.TrimSpace(r.FormValue("code"))
useBackup := r.FormValue("use_backup") == "true"
backupCode := strings.TrimSpace(r.FormValue("backup_code"))
var verified bool
if useBackup && backupCode != "" {
// Vérifier le code de secours
index := VerifyBackupCode(backupCode, user.BackupCodes)
if index >= 0 {
verified = true
// Supprimer le code utilisé
user.BackupCodes = RemoveBackupCode(user.BackupCodes, index)
// TODO: sauvegarder la config mise à jour
log.Printf("[admin] 2FA backup code used by %s, %d remaining", session.Username, len(user.BackupCodes))
}
} else if code != "" {
// Vérifier le code TOTP
if ValidateTOTPCode(user.TwoFASecret, code) {
verified = true
}
}
if !verified {
s.audit.LogAction(session.Username, "2fa_failed", "", map[string]any{
"ip": ip,
"use_backup": useBackup,
})
http.Redirect(w, r, "/admin/2fa/verify?error=Code+invalide", http.StatusSeeOther)
return
}
// 2FA validé - compléter la session
s.sessions.CompleteTwoFA(session.ID)
// Mettre à jour le cookie avec la nouvelle expiration
session, _ = s.sessions.Get(session.ID)
s.sessions.SetCookie(w, session)
// Log succès
s.audit.LogAction(session.Username, "2fa_verified", "", map[string]any{
"ip": ip,
"use_backup": useBackup,
})
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
// HandleTwoFASetupPage affiche la page de configuration 2FA.
func (s *AdminServer) HandleTwoFASetupPage(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Si 2FA déjà activé, rediriger vers les paramètres
if user.TwoFAEnabled {
http.Redirect(w, r, "/admin/security?info=2FA+déjà+activé", http.StatusSeeOther)
return
}
// Générer un nouveau secret TOTP
key, err := GenerateTOTPSecret(s.adminCfg.TwoFA.IssuerName, user.Username)
if err != nil {
log.Printf("[admin] generate TOTP secret error: %v", err)
http.Error(w, "Erreur génération secret", http.StatusInternalServerError)
return
}
// Générer le QR code
qrDataURL, err := GenerateQRCodeDataURL(key)
if err != nil {
log.Printf("[admin] generate QR code error: %v", err)
http.Error(w, "Erreur génération QR code", http.StatusInternalServerError)
return
}
// Générer les codes de secours
backupCodes, err := GenerateBackupCodes(10)
if err != nil {
log.Printf("[admin] generate backup codes error: %v", err)
http.Error(w, "Erreur génération codes secours", http.StatusInternalServerError)
return
}
data := map[string]any{
"Title": "Activer 2FA",
"User": user,
"Session": session,
"CSRFToken": session.CSRFToken,
"QRCodeDataURL": qrDataURL,
"TwoFASecret": key.Secret(),
"BackupCodes": backupCodes,
"BackupCodesFormatted": FormatBackupCodes(backupCodes),
"Error": r.URL.Query().Get("error"),
}
s.render(w, "2fa_setup.html", data)
}
// HandleTwoFASetupConfirm confirme l'activation du 2FA.
func (s *AdminServer) HandleTwoFASetupConfirm(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/2fa/setup?error=Formulaire+invalide", http.StatusSeeOther)
return
}
// Vérifier le CSRF token
if r.FormValue("csrf_token") != session.CSRFToken {
http.Redirect(w, r, "/admin/2fa/setup?error=Token+CSRF+invalide", http.StatusSeeOther)
return
}
secret := r.FormValue("temp_secret")
verifyCode := strings.TrimSpace(r.FormValue("verify_code"))
backupCodesRaw := r.FormValue("backup_codes") // JSON array
// Valider le code TOTP
if !ValidateTOTPCode(secret, verifyCode) {
http.Redirect(w, r, "/admin/2fa/setup?error=Code+invalide", http.StatusSeeOther)
return
}
// Parser et hasher les backup codes
backupCodes := strings.Split(backupCodesRaw, ",")
hashedCodes, err := HashBackupCodes(backupCodes)
if err != nil {
log.Printf("[admin] hash backup codes error: %v", err)
http.Error(w, "Erreur hash codes secours", http.StatusInternalServerError)
return
}
// Mettre à jour l'utilisateur
user.TwoFAEnabled = true
user.TwoFASecret = secret
user.BackupCodes = hashedCodes
// Sauvegarder la configuration
if err := s.saveAdminConfig(); err != nil {
log.Printf("[admin] save admin config error: %v", err)
http.Error(w, "Erreur sauvegarde configuration", http.StatusInternalServerError)
return
}
// Log l'action
s.audit.LogAction(user.Username, "2fa_enabled", "", map[string]any{
"ip": getClientIP(r),
})
http.Redirect(w, r, "/admin/?flash=success&msg=2FA+activé+avec+succès", http.StatusSeeOther)
}
// HandleTwoFADisable désactive le 2FA.
func (s *AdminServer) HandleTwoFADisable(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// Vérifier le CSRF token
if r.FormValue("csrf_token") != session.CSRFToken {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
// Vérifier si le rôle exige 2FA
if user.NeedsTwoFA(&s.adminCfg.TwoFA) && !user.TwoFAEnabled {
http.Error(w, "2FA obligatoire pour votre rôle", http.StatusForbidden)
return
}
// Demander le mot de passe pour confirmer
password := r.FormValue("password")
if !verifyUserPassword(user, password) {
http.Redirect(w, r, "/admin/security?error=Mot+de+passe+incorrect", http.StatusSeeOther)
return
}
// Désactiver 2FA
user.TwoFAEnabled = false
user.TwoFASecret = ""
user.BackupCodes = nil
// Sauvegarder la configuration
if err := s.saveAdminConfig(); err != nil {
log.Printf("[admin] save admin config error: %v", err)
http.Error(w, "Erreur sauvegarde configuration", http.StatusInternalServerError)
return
}
// Log l'action
s.audit.LogAction(user.Username, "2fa_disabled", "", map[string]any{
"ip": getClientIP(r),
})
http.Redirect(w, r, "/admin/?flash=success&msg=2FA+désactivé", http.StatusSeeOther)
}
// HandleSecurityPage affiche la page de sécurité (2FA settings).
func (s *AdminServer) HandleSecurityPage(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Vérifier si 2FA est requis pour ce user
twoFARequired := user.NeedsTwoFA(&s.adminCfg.TwoFA)
data := map[string]any{
"Title": "Sécurité",
"User": user,
"Session": session,
"CSRFToken": session.CSRFToken,
"IsSuperAdmin": user.IsSuperAdmin(),
"TwoFAEnabled": user.TwoFAEnabled,
"TwoFARequired": twoFARequired,
"BackupCount": len(user.BackupCodes),
"Error": r.URL.Query().Get("error"),
"Info": r.URL.Query().Get("info"),
}
s.render(w, "security.html", data)
}
// saveAdminConfig sauvegarde la configuration admin dans le fichier YAML.
func (s *AdminServer) saveAdminConfig() error {
return admin.SaveAdminConfig(s.adminCfg, "/secrets/admin_users.yaml")
}
// verifyUserPassword vérifie le mot de passe d'un utilisateur.
func verifyUserPassword(user *admin.AdminUser, password string) bool {
if user == nil || password == "" {
return false
}
// Utiliser bcrypt pour vérifier
return checkPasswordHash(password, user.PasswordHash)
}
// checkPasswordHash vérifie un mot de passe contre son hash bcrypt.
func checkPasswordHash(password, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// HandleUsersPage affiche la liste des utilisateurs admin (super_admin only).
func (s *AdminServer) HandleUsersPage(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Seul super_admin peut voir cette page
if !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Construire la liste des utilisateurs
type UserInfo struct {
Username string
Email string
Role string
TwoFAEnabled bool
BackupCount int
}
users := make([]UserInfo, 0, len(s.adminCfg.Users))
for _, u := range s.adminCfg.Users {
users = append(users, UserInfo{
Username: u.Username,
Email: u.Email,
Role: u.Role,
TwoFAEnabled: u.TwoFAEnabled,
BackupCount: len(u.BackupCodes),
})
}
data := map[string]any{
"Title": "Utilisateurs",
"User": user,
"Session": session,
"CSRFToken": session.CSRFToken,
"IsSuperAdmin": user.IsSuperAdmin(),
"Users": users,
"Flash": r.URL.Query().Get("flash"),
"FlashMessage": r.URL.Query().Get("msg"),
}
s.render(w, "users.html", data)
}
// HandleReset2FA reset le 2FA d'un utilisateur (super_admin only).
func (s *AdminServer) HandleReset2FA(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
currentUser := GetUserFromContext(r.Context())
if session == nil || currentUser == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Seul super_admin peut reset le 2FA
if !currentUser.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// Vérifier CSRF
if r.FormValue("csrf_token") != session.CSRFToken {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
targetUsername := r.FormValue("username")
if targetUsername == "" {
http.Redirect(w, r, "/admin/users?flash=error&msg=Username+manquant", http.StatusSeeOther)
return
}
// Trouver l'utilisateur cible
targetUser := s.adminCfg.GetUser(targetUsername)
if targetUser == nil {
http.Redirect(w, r, "/admin/users?flash=error&msg=Utilisateur+non+trouvé", http.StatusSeeOther)
return
}
// Reset le 2FA
targetUser.TwoFAEnabled = false
targetUser.TwoFASecret = ""
targetUser.BackupCodes = nil
// Sauvegarder
if err := s.saveAdminConfig(); err != nil {
log.Printf("[admin] save admin config error: %v", err)
http.Redirect(w, r, "/admin/users?flash=error&msg=Erreur+sauvegarde", http.StatusSeeOther)
return
}
// Log l'action
s.audit.LogAction(currentUser.Username, "2fa_reset", targetUsername, map[string]any{
"ip": getClientIP(r),
"target_user": targetUsername,
})
log.Printf("[admin] 2FA reset for user %s by %s", targetUsername, currentUser.Username)
http.Redirect(w, r, "/admin/users?flash=success&msg=2FA+réinitialisé+pour+"+targetUsername, http.StatusSeeOther)
}

View File

@@ -0,0 +1,659 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"strconv"
"time"
"sogoms.com/internal/infra"
)
// ============================================================================
// Servers
// ============================================================================
// HandleInfraPage affiche la page principale de l'infrastructure.
func (s *AdminServer) HandleInfraPage(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Seul super_admin peut gérer l'infra
if !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Récupérer les serveurs
servers, err := s.infraDB.ListServers()
if err != nil {
log.Printf("[admin] list servers error: %v", err)
servers = []infra.Server{}
}
// Pour chaque serveur, récupérer les containers
type ServerView struct {
infra.Server
Containers []infra.Container
ContainerCount int
NginxCount int
}
serverViews := make([]ServerView, 0, len(servers))
for _, srv := range servers {
containers, _ := s.infraDB.ListContainersByServer(srv.ID)
nginxConfigs, _ := s.infraDB.ListNginxConfigsByServer(srv.ID)
serverViews = append(serverViews, ServerView{
Server: srv,
Containers: containers,
ContainerCount: len(containers),
NginxCount: len(nginxConfigs),
})
}
data := map[string]any{
"Title": "Infrastructure",
"User": user,
"Session": session,
"CSRFToken": session.CSRFToken,
"IsSuperAdmin": user.IsSuperAdmin(),
"Servers": serverViews,
}
// Flash message
if flash := r.URL.Query().Get("flash"); flash != "" {
data["Flash"] = flash
data["FlashMessage"] = r.URL.Query().Get("msg")
}
s.render(w, "infra.html", data)
}
// HandleServerNewPage affiche le formulaire d'ajout de serveur.
func (s *AdminServer) HandleServerNewPage(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
if !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
data := map[string]any{
"Title": "Nouveau Serveur",
"User": user,
"Session": session,
"CSRFToken": session.CSRFToken,
"IsSuperAdmin": true,
}
s.render(w, "server_new.html", data)
}
// HandleServerCreate crée un nouveau serveur.
func (s *AdminServer) HandleServerCreate(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil || !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// Parser les valeurs
sshPort, _ := strconv.Atoi(r.FormValue("ssh_port"))
if sshPort == 0 {
sshPort = 22
}
server := &infra.Server{
Name: r.FormValue("name"),
Host: r.FormValue("host"),
VpnIP: r.FormValue("vpn_ip"),
SSHPort: sshPort,
SSHUser: r.FormValue("ssh_user"),
SSHKeyFile: r.FormValue("ssh_key_file"),
HasIncus: r.FormValue("has_incus") == "on",
HasNginx: r.FormValue("has_nginx") == "on",
Status: infra.ServerStatusUnknown,
}
if server.Name == "" || server.Host == "" {
http.Error(w, "Nom et host requis", http.StatusBadRequest)
return
}
if err := s.infraDB.CreateServer(server); err != nil {
log.Printf("[admin] create server error: %v", err)
http.Error(w, "Erreur création serveur", http.StatusInternalServerError)
return
}
s.audit.LogAction(user.Username, "create_server", server.Name, map[string]any{
"ip": getClientIP(r),
"host": server.Host,
})
http.Redirect(w, r, "/admin/infra?flash=success&msg=Serveur+créé", http.StatusSeeOther)
}
// HandleServerDetailPage affiche les détails d'un serveur.
func (s *AdminServer) HandleServerDetailPage(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil || !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
if err != nil {
http.Error(w, "Invalid server ID", http.StatusBadRequest)
return
}
server, err := s.infraDB.GetServer(serverID)
if err != nil || server == nil {
http.Error(w, "Server not found", http.StatusNotFound)
return
}
// Récupérer les containers et nginx configs
containers, _ := s.infraDB.ListContainersByServer(serverID)
nginxConfigs, _ := s.infraDB.ListNginxConfigsByServer(serverID)
data := map[string]any{
"Title": server.Name,
"User": user,
"Session": session,
"CSRFToken": session.CSRFToken,
"IsSuperAdmin": true,
"Server": server,
"Containers": containers,
"NginxConfigs": nginxConfigs,
}
// Flash message
if flash := r.URL.Query().Get("flash"); flash != "" {
data["Flash"] = flash
data["FlashMessage"] = r.URL.Query().Get("msg")
}
s.render(w, "server_detail.html", data)
}
// HandleServerDelete supprime un serveur.
func (s *AdminServer) HandleServerDelete(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil || !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
if err != nil {
http.Error(w, "Invalid server ID", http.StatusBadRequest)
return
}
server, _ := s.infraDB.GetServer(serverID)
if server == nil {
http.Error(w, "Server not found", http.StatusNotFound)
return
}
// Déconnecter SSH si connecté
s.sshPool.Disconnect(serverID)
if err := s.infraDB.DeleteServer(serverID); err != nil {
log.Printf("[admin] delete server error: %v", err)
http.Error(w, "Erreur suppression", http.StatusInternalServerError)
return
}
s.audit.LogAction(user.Username, "delete_server", server.Name, nil)
http.Redirect(w, r, "/admin/infra?flash=success&msg=Serveur+supprimé", http.StatusSeeOther)
}
// HandleServerTestSSH teste la connexion SSH à un serveur.
func (s *AdminServer) HandleServerTestSSH(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil || !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
if err != nil {
http.Error(w, "Invalid server ID", http.StatusBadRequest)
return
}
server, err := s.infraDB.GetServer(serverID)
if err != nil || server == nil {
http.Error(w, "Server not found", http.StatusNotFound)
return
}
// Tester la connexion SSH
client, err := s.sshPool.Connect(server)
if err != nil {
s.infraDB.UpdateServerStatus(serverID, infra.ServerStatusOffline)
msg := fmt.Sprintf("Erreur SSH: %v", err)
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", serverID, msg), http.StatusSeeOther)
return
}
// Exécuter une commande de test
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result, err := client.ExecSimple(ctx, "hostname && uptime")
if err != nil {
s.infraDB.UpdateServerStatus(serverID, infra.ServerStatusOffline)
msg := fmt.Sprintf("Erreur commande: %v", err)
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", serverID, msg), http.StatusSeeOther)
return
}
// Mise à jour du statut
s.infraDB.UpdateServerStatus(serverID, infra.ServerStatusOnline)
s.audit.LogAction(user.Username, "test_ssh", server.Name, map[string]any{
"result": result,
})
msg := fmt.Sprintf("Connexion OK: %s", result)
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=success&msg=%s", serverID, msg), http.StatusSeeOther)
}
// HandleServerSyncContainers synchronise les containers depuis Incus.
func (s *AdminServer) HandleServerSyncContainers(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil || !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
if err != nil {
http.Error(w, "Invalid server ID", http.StatusBadRequest)
return
}
server, err := s.infraDB.GetServer(serverID)
if err != nil || server == nil {
http.Error(w, "Server not found", http.StatusNotFound)
return
}
if !server.HasIncus {
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Incus+non+activé", serverID), http.StatusSeeOther)
return
}
// Connexion SSH
client, err := s.sshPool.Connect(server)
if err != nil {
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Erreur+SSH", serverID), http.StatusSeeOther)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Récupérer les containers Incus
incusContainers, err := client.ListIncusContainers(ctx)
if err != nil {
msg := fmt.Sprintf("Erreur Incus: %v", err)
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", serverID, msg), http.StatusSeeOther)
return
}
// Synchroniser avec la base
synced := 0
for _, ic := range incusContainers {
// Vérifier si existe déjà
existing, _ := s.infraDB.ListContainersByServer(serverID)
found := false
for _, c := range existing {
if c.IncusName == ic.Name {
// Mettre à jour le statut
status := infra.ContainerStatusUnknown
if ic.State == "running" {
status = infra.ContainerStatusRunning
} else if ic.State == "stopped" {
status = infra.ContainerStatusStopped
}
s.infraDB.UpdateContainerStatus(c.ID, status)
found = true
break
}
}
if !found {
// Créer le container
ip := ""
if len(ic.IPv4) > 0 {
ip = ic.IPv4[0]
}
status := infra.ContainerStatusUnknown
if ic.State == "running" {
status = infra.ContainerStatusRunning
} else if ic.State == "stopped" {
status = infra.ContainerStatusStopped
}
container := &infra.Container{
ServerID: serverID,
Name: ic.Name,
IncusName: ic.Name,
IP: ip,
Image: ic.Image,
Status: status,
}
if err := s.infraDB.CreateContainer(container); err == nil {
synced++
}
}
}
s.audit.LogAction(user.Username, "sync_containers", server.Name, map[string]any{
"synced": synced,
"total": len(incusContainers),
})
msg := fmt.Sprintf("Sync OK: %d nouveaux containers", synced)
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=success&msg=%s", serverID, msg), http.StatusSeeOther)
}
// ============================================================================
// Containers
// ============================================================================
// HandleContainerAction effectue une action sur un container (start/stop/restart).
func (s *AdminServer) HandleContainerAction(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil || !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
containerID, err := strconv.ParseInt(r.PathValue("containerID"), 10, 64)
if err != nil {
http.Error(w, "Invalid container ID", http.StatusBadRequest)
return
}
action := r.FormValue("action")
if action != "start" && action != "stop" && action != "restart" {
http.Error(w, "Invalid action", http.StatusBadRequest)
return
}
container, err := s.infraDB.GetContainer(containerID)
if err != nil || container == nil {
http.Error(w, "Container not found", http.StatusNotFound)
return
}
server, err := s.infraDB.GetServer(container.ServerID)
if err != nil || server == nil {
http.Error(w, "Server not found", http.StatusNotFound)
return
}
// Connexion SSH
client, err := s.sshPool.Connect(server)
if err != nil {
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Erreur+SSH", server.ID), http.StatusSeeOther)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// Exécuter l'action
switch action {
case "start":
err = client.StartIncusContainer(ctx, container.IncusName)
if err == nil {
s.infraDB.UpdateContainerStatus(containerID, infra.ContainerStatusRunning)
}
case "stop":
err = client.StopIncusContainer(ctx, container.IncusName)
if err == nil {
s.infraDB.UpdateContainerStatus(containerID, infra.ContainerStatusStopped)
}
case "restart":
err = client.RestartIncusContainer(ctx, container.IncusName)
if err == nil {
s.infraDB.UpdateContainerStatus(containerID, infra.ContainerStatusRunning)
}
}
if err != nil {
msg := fmt.Sprintf("Erreur %s: %v", action, err)
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", server.ID, msg), http.StatusSeeOther)
return
}
s.audit.LogAction(user.Username, "container_"+action, container.Name, map[string]any{
"server": server.Name,
})
msg := fmt.Sprintf("Container %s: %s OK", container.Name, action)
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=success&msg=%s", server.ID, msg), http.StatusSeeOther)
}
// ============================================================================
// Nginx
// ============================================================================
// HandleNginxReload recharge Nginx sur un serveur.
func (s *AdminServer) HandleNginxReload(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil || !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
if err != nil {
http.Error(w, "Invalid server ID", http.StatusBadRequest)
return
}
server, err := s.infraDB.GetServer(serverID)
if err != nil || server == nil {
http.Error(w, "Server not found", http.StatusNotFound)
return
}
if !server.HasNginx {
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Nginx+non+activé", serverID), http.StatusSeeOther)
return
}
client, err := s.sshPool.Connect(server)
if err != nil {
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Erreur+SSH", serverID), http.StatusSeeOther)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := client.ReloadNginx(ctx); err != nil {
msg := fmt.Sprintf("Erreur reload: %v", err)
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", serverID, msg), http.StatusSeeOther)
return
}
s.audit.LogAction(user.Username, "nginx_reload", server.Name, nil)
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=success&msg=Nginx+rechargé", serverID), http.StatusSeeOther)
}
// HandleNginxSyncSites synchronise les sites Nginx depuis le serveur.
func (s *AdminServer) HandleNginxSyncSites(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil || !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
if err != nil {
http.Error(w, "Invalid server ID", http.StatusBadRequest)
return
}
server, err := s.infraDB.GetServer(serverID)
if err != nil || server == nil {
http.Error(w, "Server not found", http.StatusNotFound)
return
}
if !server.HasNginx {
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Nginx+non+activé", serverID), http.StatusSeeOther)
return
}
client, err := s.sshPool.Connect(server)
if err != nil {
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Erreur+SSH", serverID), http.StatusSeeOther)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
sites, err := client.ListNginxSites(ctx)
if err != nil {
msg := fmt.Sprintf("Erreur liste sites: %v", err)
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", serverID, msg), http.StatusSeeOther)
return
}
synced := 0
for _, site := range sites {
// Vérifier si existe déjà
existing, _ := s.infraDB.GetNginxConfigByDomain(serverID, site.Name)
if existing != nil {
// Mettre à jour le statut
status := infra.NginxConfigStatusInactive
if site.Enabled {
status = infra.NginxConfigStatusActive
}
existing.Status = status
s.infraDB.UpdateNginxConfig(existing)
continue
}
// Créer la config
status := infra.NginxConfigStatusInactive
if site.Enabled {
status = infra.NginxConfigStatusActive
}
config := &infra.NginxConfig{
ServerID: serverID,
Domain: site.Name,
Type: infra.NginxTypeProxy,
Status: status,
}
if err := s.infraDB.CreateNginxConfig(config); err == nil {
synced++
}
}
s.audit.LogAction(user.Username, "sync_nginx", server.Name, map[string]any{
"synced": synced,
"total": len(sites),
})
msg := fmt.Sprintf("Sync OK: %d nouveaux sites", synced)
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=success&msg=%s", serverID, msg), http.StatusSeeOther)
}
// ============================================================================
// API (htmx)
// ============================================================================
// HandleAPIInfraStatus retourne le statut de l'infrastructure (partial htmx).
func (s *AdminServer) HandleAPIInfraStatus(w http.ResponseWriter, r *http.Request) {
user := GetUserFromContext(r.Context())
if user == nil || !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
servers, _ := s.infraDB.ListServers()
// Compter les statuts
online := 0
offline := 0
for _, srv := range servers {
if srv.Status == infra.ServerStatusOnline {
online++
} else if srv.Status == infra.ServerStatusOffline {
offline++
}
}
containers, _ := s.infraDB.ListAllContainers()
running := 0
stopped := 0
for _, c := range containers {
if c.Status == infra.ContainerStatusRunning {
running++
} else if c.Status == infra.ContainerStatusStopped {
stopped++
}
}
data := map[string]any{
"ServerCount": len(servers),
"ServersOnline": online,
"ServersOffline": offline,
"ContainerCount": len(containers),
"Running": running,
"Stopped": stopped,
}
s.render(w, "partials/infra_status.html", data)
}

View File

@@ -13,9 +13,11 @@ import (
"os"
"os/signal"
"syscall"
"time"
"sogoms.com/internal/admin"
"sogoms.com/internal/config"
"sogoms.com/internal/infra"
"sogoms.com/internal/protocol"
"sogoms.com/internal/version"
)
@@ -30,11 +32,10 @@ var (
port = flag.Int("port", 9000, "HTTP server port")
configDir = flag.String("config", "/config", "Configuration directory")
secretsDir = flag.String("secrets", "/secrets", "Secrets directory")
templatesDir = flag.String("templates", "", "Templates directory (empty = use embedded)")
devMode = flag.Bool("dev", false, "Dev mode: reload templates on each request")
dbSocket = flag.String("db-socket", "/run/sogoms-db.1.sock", "DB service socket")
logsSocket = flag.String("logs-socket", "/run/sogoms-logs.1.sock", "Logs service socket")
cronSocket = flag.String("cron-socket", "/run/sogoms-cron.1.sock", "Cron service socket")
infraDBPath = flag.String("infra-db", "/data/infra.db", "Infrastructure SQLite database path")
)
func main() {
@@ -81,33 +82,38 @@ func main() {
// Audit logger
audit := admin.NewAuditLogger(services.Logs)
// Charger les templates
templates, err := loadTemplates(*templatesDir)
// Infrastructure DB
infraDB, err := infra.Open(*infraDBPath)
if err != nil {
log.Fatalf("open infra db: %v", err)
}
defer infraDB.Close()
log.Printf("[admin] infra db opened: %s", *infraDBPath)
// SSH Pool
sshPool := infra.NewSSHPool(30 * time.Second)
defer sshPool.CloseAll()
// Charger les templates (embedded)
templates, err := loadTemplates()
if err != nil {
log.Fatalf("load templates: %v", err)
}
if *templatesDir != "" {
log.Printf("[admin] templates loaded from filesystem: %s", *templatesDir)
if *devMode {
log.Printf("[admin] dev mode: templates will reload on each request")
}
} else {
log.Printf("[admin] templates loaded from embedded")
}
log.Printf("[admin] templates loaded (embedded)")
// Créer le serveur
server := &AdminServer{
adminCfg: adminCfg,
registry: registry,
sessions: sessions,
version: version.Version,
rateLimiter: rateLimiter,
perms: perms,
audit: audit,
services: services,
templates: templates,
templatesDir: *templatesDir,
devMode: *devMode,
adminCfg: adminCfg,
registry: registry,
sessions: sessions,
version: version.Version,
rateLimiter: rateLimiter,
perms: perms,
audit: audit,
services: services,
templates: templates,
infraDB: infraDB,
sshPool: sshPool,
}
// Router
@@ -125,6 +131,19 @@ func main() {
mux.HandleFunc("GET /admin/login", server.HandleLoginPage)
mux.HandleFunc("POST /admin/login", server.HandleLogin)
// Routes 2FA (session requise mais pas forcément complète)
mux.HandleFunc("GET /admin/2fa/verify", server.HandleTwoFAPage)
mux.HandleFunc("POST /admin/2fa/verify", server.HandleTwoFAVerify)
mux.HandleFunc("GET /admin/2fa/setup", TwoFASetupMiddleware(sessions, adminCfg, server.HandleTwoFASetupPage))
mux.HandleFunc("POST /admin/2fa/setup", TwoFASetupMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleTwoFASetupConfirm)))
mux.HandleFunc("POST /admin/2fa/disable", AuthMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleTwoFADisable)))
mux.HandleFunc("GET /admin/security", AuthMiddleware(sessions, adminCfg, server.HandleSecurityPage))
mux.HandleFunc("GET /admin/users", AuthMiddleware(sessions, adminCfg, server.HandleUsersPage))
mux.HandleFunc("POST /admin/users/reset-2fa", AuthMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleReset2FA)))
// Routes protégées
mux.HandleFunc("GET /admin/{$}", AuthMiddleware(sessions, adminCfg, server.HandleDashboard))
mux.HandleFunc("GET /admin/apps", AuthMiddleware(sessions, adminCfg, server.HandleAppsPage))
@@ -141,6 +160,26 @@ func main() {
mux.HandleFunc("GET /admin/api/apps", AuthMiddleware(sessions, adminCfg, server.HandleAPIApps))
mux.HandleFunc("GET /admin/api/services/health", AuthMiddleware(sessions, adminCfg, server.HandleAPIServicesHealth))
mux.HandleFunc("GET /admin/api/cron/jobs", AuthMiddleware(sessions, adminCfg, server.HandleAPICronJobs))
mux.HandleFunc("GET /admin/api/infra/status", AuthMiddleware(sessions, adminCfg, server.HandleAPIInfraStatus))
// Routes Infrastructure (super_admin only)
mux.HandleFunc("GET /admin/infra", AuthMiddleware(sessions, adminCfg, server.HandleInfraPage))
mux.HandleFunc("GET /admin/infra/servers/new", AuthMiddleware(sessions, adminCfg, server.HandleServerNewPage))
mux.HandleFunc("POST /admin/infra/servers/new", AuthMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleServerCreate)))
mux.HandleFunc("GET /admin/infra/servers/{serverID}", AuthMiddleware(sessions, adminCfg, server.HandleServerDetailPage))
mux.HandleFunc("POST /admin/infra/servers/{serverID}/delete", AuthMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleServerDelete)))
mux.HandleFunc("POST /admin/infra/servers/{serverID}/test-ssh", AuthMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleServerTestSSH)))
mux.HandleFunc("POST /admin/infra/servers/{serverID}/sync-containers", AuthMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleServerSyncContainers)))
mux.HandleFunc("POST /admin/infra/servers/{serverID}/sync-nginx", AuthMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleNginxSyncSites)))
mux.HandleFunc("POST /admin/infra/servers/{serverID}/nginx-reload", AuthMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleNginxReload)))
mux.HandleFunc("POST /admin/infra/containers/{containerID}/action", AuthMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleContainerAction)))
// Handler avec logging
handler := LoggingMiddleware(mux)
@@ -168,29 +207,17 @@ func main() {
httpServer.Close()
}
// loadTemplates charge les templates depuis le filesystem ou embedded.
func loadTemplates(dir string) (*template.Template, error) {
// loadTemplates charge les templates depuis embedded.
func loadTemplates() (*template.Template, error) {
funcMap := template.FuncMap{
"safe": func(s string) template.HTML {
return template.HTML(s)
},
"safeURL": func(s string) template.URL {
return template.URL(s)
},
}
if dir != "" {
// Charger depuis le filesystem
tmpl, err := template.New("").Funcs(funcMap).ParseGlob(dir + "/*.html")
if err != nil {
return nil, err
}
// Charger les partials
tmpl, err = tmpl.ParseGlob(dir + "/partials/*.html")
if err != nil {
return nil, err
}
return tmpl, nil
}
// Charger depuis embedded
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html", "templates/partials/*.html")
if err != nil {
return nil, err

View File

@@ -54,6 +54,12 @@ func AuthMiddleware(sessions *SessionStore, adminCfg *admin.AdminConfig, next ht
return
}
// Vérifier si la session est en attente de 2FA
if session.TwoFAPending {
http.Redirect(w, r, "/admin/2fa/verify", http.StatusSeeOther)
return
}
// Prolonger la session (sliding expiration)
sessions.Refresh(session.ID)
@@ -65,6 +71,36 @@ func AuthMiddleware(sessions *SessionStore, adminCfg *admin.AdminConfig, next ht
}
}
// TwoFASetupMiddleware permet l'accès à la page de setup 2FA même sans 2FA vérifié.
// Utilisé uniquement pour les routes de configuration 2FA.
func TwoFASetupMiddleware(sessions *SessionStore, adminCfg *admin.AdminConfig, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, err := sessions.GetSessionFromRequest(r)
if err != nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Récupérer l'utilisateur
user := adminCfg.GetUser(session.Username)
if user == nil {
sessions.Delete(session.ID)
sessions.ClearCookie(w)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Prolonger la session
sessions.Refresh(session.ID)
// Injecter dans le contexte
ctx := context.WithValue(r.Context(), ctxSession, session)
ctx = context.WithValue(ctx, ctxUser, user)
next(w, r.WithContext(ctx))
}
}
// CSRFMiddleware vérifie le token CSRF pour les requêtes POST/PUT/DELETE.
func CSRFMiddleware(sessions *SessionStore, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {

View File

@@ -307,6 +307,7 @@ func convertTablesToSchema(tablesRaw map[string]any) map[string]any {
}
sort.Strings(tableNames)
// Première passe : créer toutes les tables
for _, tableName := range tableNames {
tableData, ok := tablesRaw[tableName].(map[string]any)
if !ok {
@@ -395,6 +396,11 @@ func convertTablesToSchema(tablesRaw map[string]any) map[string]any {
table["primary"] = pkStrings
}
// Détecter soft_delete (colonne deleted_at)
if hasSoftDelete(tableData) {
table["soft_delete"] = true
}
// CRUD par défaut (sauf tables de liaison)
if hasUserID(tableData) {
table["crud"] = []string{"list", "show", "create", "update", "delete"}
@@ -405,6 +411,60 @@ func convertTablesToSchema(tablesRaw map[string]any) map[string]any {
tables[tableName] = table
}
// Deuxième passe : détecter cascade sur les tables parent
// Une table parent a cascade si elle a soft_delete ET des tables enfants avec soft_delete
for parentName, parentTable := range tables {
parent, ok := parentTable.(map[string]any)
if !ok {
continue
}
// Si la table parent n'a pas soft_delete, pas de cascade
if sd, ok := parent["soft_delete"].(bool); !ok || !sd {
continue
}
// Chercher si des tables enfants ont une FK vers cette table
hasChildWithSoftDelete := false
for childName, childTable := range tables {
if childName == parentName {
continue
}
child, ok := childTable.(map[string]any)
if !ok {
continue
}
// Vérifier si l'enfant a soft_delete
childSD, _ := child["soft_delete"].(bool)
if !childSD {
continue
}
// Vérifier si l'enfant a une FK vers le parent
if cols, ok := child["columns"].(map[string]any); ok {
for _, colData := range cols {
if col, ok := colData.(map[string]any); ok {
if fk, ok := col["foreign"].(string); ok {
// fk = "table.column"
if strings.HasPrefix(fk, parentName+".") {
hasChildWithSoftDelete = true
break
}
}
}
}
}
if hasChildWithSoftDelete {
break
}
}
if hasChildWithSoftDelete {
parent["cascade"] = true
}
}
return tables
}
@@ -417,6 +477,18 @@ func hasUserID(tableData map[string]any) bool {
return false
}
// hasSoftDelete vérifie si une table a une colonne deleted_at (TIMESTAMP/DATETIME).
func hasSoftDelete(tableData map[string]any) bool {
if columnsRaw, ok := tableData["columns"].(map[string]any); ok {
if col, ok := columnsRaw["deleted_at"].(map[string]any); ok {
if colType, ok := col["type"].(string); ok && colType == "datetime" {
return true
}
}
}
return false
}
// UpdateLoginData met à jour le bloc login_data dans auth.yaml
// en se basant sur le schema généré (tables avec filter: owner).
func UpdateLoginData(appID string) error {
@@ -549,6 +621,315 @@ func UpdateLoginData(appID string) error {
return nil
}
// GenerateRoutesFromSchema génère les routes CRUD dans app.yaml basées sur schema.yaml.
func GenerateRoutesFromSchema(appID string) error {
// 1. Lire le schema.yaml
schemaPath := filepath.Join("/config", "apps", appID, "schema.yaml")
schemaData, err := os.ReadFile(schemaPath)
if err != nil {
return fmt.Errorf("read schema: %w", err)
}
var schema map[string]any
if err := yaml.Unmarshal(schemaData, &schema); err != nil {
return fmt.Errorf("parse schema: %w", err)
}
// 2. Lire app.yaml existant
appPath := filepath.Join("/config", "apps", appID, "app.yaml")
appData, err := os.ReadFile(appPath)
if err != nil {
return fmt.Errorf("read app.yaml: %w", err)
}
var appConfig map[string]any
if err := yaml.Unmarshal(appData, &appConfig); err != nil {
return fmt.Errorf("parse app.yaml: %w", err)
}
// 3. Extraire les tables avec CRUD
tablesRaw, ok := schema["tables"].(map[string]any)
if !ok {
return nil // Pas de tables
}
// Trier les tables
tableNames := make([]string, 0, len(tablesRaw))
for name := range tablesRaw {
tableNames = append(tableNames, name)
}
sort.Strings(tableNames)
// 4. Générer les routes
routes := []map[string]any{
// Routes auth par défaut
{"path": "/auth/register", "method": "POST", "scenario": appID + "/auth/register", "auth": false},
{"path": "/auth/login", "method": "POST", "scenario": appID + "/auth/login", "auth": false},
{"path": "/auth/logout", "method": "POST", "scenario": appID + "/auth/logout"},
{"path": "/auth/me", "method": "GET", "scenario": appID + "/auth/me"},
}
for _, tableName := range tableNames {
tableRaw := tablesRaw[tableName]
table, ok := tableRaw.(map[string]any)
if !ok {
continue
}
// Vérifier si CRUD est défini
crudRaw, ok := table["crud"].([]any)
if !ok || len(crudRaw) == 0 {
continue
}
// Convertir en map pour lookup rapide
crudOps := make(map[string]bool)
for _, op := range crudRaw {
if opStr, ok := op.(string); ok {
crudOps[opStr] = true
}
}
// Générer les routes pour cette table
if crudOps["list"] {
routes = append(routes, map[string]any{
"path": "/" + tableName,
"method": "GET",
"scenario": appID + "/" + tableName + "/list",
})
}
if crudOps["create"] {
routes = append(routes, map[string]any{
"path": "/" + tableName,
"method": "POST",
"scenario": appID + "/" + tableName + "/create",
})
}
if crudOps["show"] {
routes = append(routes, map[string]any{
"path": "/" + tableName + "/{id}",
"method": "GET",
"scenario": appID + "/" + tableName + "/show",
})
}
if crudOps["update"] {
routes = append(routes, map[string]any{
"path": "/" + tableName + "/{id}",
"method": "PUT",
"scenario": appID + "/" + tableName + "/update",
})
}
if crudOps["delete"] {
routes = append(routes, map[string]any{
"path": "/" + tableName + "/{id}",
"method": "DELETE",
"scenario": appID + "/" + tableName + "/delete",
})
}
}
// 5. Mettre à jour app.yaml
appConfig["routes"] = routes
// 6. Réécrire le fichier
yamlData, err := yaml.Marshal(appConfig)
if err != nil {
return fmt.Errorf("marshal app.yaml: %w", err)
}
header := fmt.Sprintf("# Application %s\n# Routes générées automatiquement depuis schema.yaml\n\n", appID)
if err := os.WriteFile(appPath, []byte(header+string(yamlData)), 0644); err != nil {
return fmt.Errorf("write app.yaml: %w", err)
}
return nil
}
// GenerateQueriesFromSchema génère les fichiers queries/*.yaml basés sur schema.yaml.
func GenerateQueriesFromSchema(appID string) error {
// 1. Lire le schema.yaml
schemaPath := filepath.Join("/config", "apps", appID, "schema.yaml")
schemaData, err := os.ReadFile(schemaPath)
if err != nil {
return fmt.Errorf("read schema: %w", err)
}
var schema map[string]any
if err := yaml.Unmarshal(schemaData, &schema); err != nil {
return fmt.Errorf("parse schema: %w", err)
}
// 2. Créer le dossier queries
queriesDir := filepath.Join("/config", "apps", appID, "queries")
if err := os.MkdirAll(queriesDir, 0755); err != nil {
return fmt.Errorf("create queries dir: %w", err)
}
// 3. Extraire les tables
tablesRaw, ok := schema["tables"].(map[string]any)
if !ok {
return nil
}
for tableName, tableRaw := range tablesRaw {
table, ok := tableRaw.(map[string]any)
if !ok {
continue
}
// Vérifier si CRUD est défini
crudRaw, ok := table["crud"].([]any)
if !ok || len(crudRaw) == 0 {
continue
}
// Générer le fichier queries pour cette table
if err := generateTableQueries(queriesDir, tableName, table); err != nil {
return fmt.Errorf("generate queries for %s: %w", tableName, err)
}
}
return nil
}
// generateTableQueries génère le fichier queries pour une table.
func generateTableQueries(queriesDir, tableName string, table map[string]any) error {
columns, ok := table["columns"].(map[string]any)
if !ok {
return nil
}
// Collecter les colonnes
colNames := make([]string, 0, len(columns))
createFields := make([]string, 0, len(columns))
updateFields := make([]string, 0, len(columns))
hasPosition := false
hasUserID := false
for colName, colRaw := range columns {
col, ok := colRaw.(map[string]any)
if !ok {
continue
}
colNames = append(colNames, colName)
if colName == "position" {
hasPosition = true
}
if colName == "user_id" {
hasUserID = true
}
// Exclure les colonnes auto-générées du CREATE
isAuto, _ := col["auto"].(bool)
isPrimary, _ := col["primary"].(bool)
isAutoGenerated := colName == "created_at" || colName == "updated_at" || colName == "deleted_at"
if !isAuto && !isAutoGenerated {
createFields = append(createFields, colName)
}
// Exclure id, user_id et auto-générées de l'UPDATE
if !isPrimary && !isAuto && !isAutoGenerated && colName != "user_id" {
updateFields = append(updateFields, colName)
}
}
sort.Strings(colNames)
sort.Strings(createFields)
sort.Strings(updateFields)
// Mettre id en premier dans les colonnes SELECT
selectCols := make([]string, 0, len(colNames))
for _, name := range colNames {
if name == "id" {
selectCols = append([]string{"id"}, selectCols...)
} else if name != "user_id" { // Exclure user_id du SELECT
selectCols = append(selectCols, name)
}
}
// Construire le contenu YAML
queries := make(map[string]any)
// LIST
listQuery := fmt.Sprintf("SELECT %s\nFROM %s", strings.Join(selectCols, ", "), tableName)
orderBy := ""
if hasPosition {
orderBy = "position ASC"
}
listConfig := map[string]any{
"query": listQuery,
}
if hasUserID {
listConfig["filters"] = map[string]string{
"default": "user_id = :user_id",
"admin": "",
}
}
if orderBy != "" {
listConfig["order"] = orderBy
}
queries["list"] = listConfig
// SHOW
showQuery := fmt.Sprintf("SELECT %s\nFROM %s WHERE id = :id", strings.Join(selectCols, ", "), tableName)
showConfig := map[string]any{
"query": showQuery,
}
if hasUserID {
showConfig["filters"] = map[string]string{
"default": "user_id = :user_id",
"admin": "",
}
}
queries["show"] = showConfig
// CREATE
queries["create"] = map[string]any{
"table": tableName,
"fields": createFields,
}
// UPDATE
updateConfig := map[string]any{
"table": tableName,
"fields": updateFields,
}
if hasUserID {
updateConfig["filters"] = map[string]string{
"default": "user_id = :user_id",
"admin": "",
}
}
queries["update"] = updateConfig
// DELETE
deleteConfig := map[string]any{
"table": tableName,
}
if hasUserID {
deleteConfig["filters"] = map[string]string{
"default": "user_id = :user_id",
"admin": "",
}
}
queries["delete"] = deleteConfig
// Sérialiser
yamlData, err := yaml.Marshal(queries)
if err != nil {
return err
}
header := fmt.Sprintf("# Requêtes CRUD %s\n# Généré automatiquement depuis schema.yaml\n\n", tableName)
queryFile := filepath.Join(queriesDir, tableName+".yaml")
return os.WriteFile(queryFile, []byte(header+string(yamlData)), 0644)
}
// ReloadGateway demande à sogoctl de recharger sogoway.
func (sp *ServicePool) ReloadGateway() error {
conn, err := net.DialTimeout("unix", "/run/sogoctl.sock", 2*time.Second)

View File

@@ -25,6 +25,9 @@ type Session struct {
ExpiresAt time.Time
IP string
UserAgent string
// 2FA
TwoFAPending bool // true si en attente de validation 2FA
TwoFAVerified bool // true après validation 2FA réussie
}
// IsExpired vérifie si la session a expiré.
@@ -78,6 +81,51 @@ func (s *SessionStore) Create(username, role, ip, userAgent string) (*Session, e
return session, nil
}
// CreatePending crée une session en attente de validation 2FA (expire en 5 min).
func (s *SessionStore) CreatePending(username, role, ip, userAgent string) (*Session, error) {
sessionID, err := generateSecureToken(32)
if err != nil {
return nil, err
}
csrfToken, err := generateSecureToken(32)
if err != nil {
return nil, err
}
now := time.Now()
session := &Session{
ID: sessionID,
Username: username,
Role: role,
CSRFToken: csrfToken,
CreatedAt: now,
ExpiresAt: now.Add(5 * time.Minute), // expiration courte pour 2FA
IP: ip,
UserAgent: userAgent,
TwoFAPending: true,
TwoFAVerified: false,
}
s.mu.Lock()
s.sessions[sessionID] = session
s.mu.Unlock()
return session, nil
}
// CompleteTwoFA marque la session comme ayant passé la 2FA.
func (s *SessionStore) CompleteTwoFA(sessionID string) {
s.mu.Lock()
if session, ok := s.sessions[sessionID]; ok {
session.TwoFAPending = false
session.TwoFAVerified = true
// Prolonger l'expiration après validation 2FA
session.ExpiresAt = time.Now().Add(time.Duration(s.config.MaxAge) * time.Second)
}
s.mu.Unlock()
}
// Get récupère une session par son ID.
func (s *SessionStore) Get(sessionID string) (*Session, bool) {
s.mu.RLock()

View File

@@ -0,0 +1,154 @@
{{define "2fa_setup.html"}}
<!DOCTYPE html>
<html lang="fr" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Activer 2FA - SOGOMS Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>
.container {
max-width: 600px;
margin: 2rem auto;
padding: 0 1rem;
}
.logo {
text-align: center;
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 2rem;
}
.logo span {
color: var(--pico-primary);
}
.error-message {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 0.75rem 1rem;
border-radius: var(--pico-border-radius);
margin-bottom: 1rem;
}
.success-message {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #16a34a;
padding: 0.75rem 1rem;
border-radius: var(--pico-border-radius);
margin-bottom: 1rem;
}
.qr-container {
text-align: center;
margin: 2rem 0;
padding: 1rem;
background: white;
border-radius: var(--pico-border-radius);
}
.qr-container img {
max-width: 200px;
}
.secret-code {
font-family: monospace;
font-size: 1.1rem;
background: var(--pico-form-element-background-color);
padding: 0.5rem 1rem;
border-radius: var(--pico-border-radius);
word-break: break-all;
}
.backup-codes {
font-family: monospace;
font-size: 0.95rem;
background: var(--pico-form-element-background-color);
padding: 1rem;
border-radius: var(--pico-border-radius);
white-space: pre-wrap;
line-height: 1.8;
}
.warning {
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
padding: 0.75rem 1rem;
border-radius: var(--pico-border-radius);
margin: 1rem 0;
font-size: 0.9rem;
}
.step {
margin-bottom: 2rem;
}
.step h3 {
margin-bottom: 1rem;
}
.code-input {
text-align: center;
font-size: 1.5rem;
letter-spacing: 0.5rem;
font-family: monospace;
max-width: 200px;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">SOGO<span>MS</span> Admin</div>
<h1>Activer l'authentification à deux facteurs</h1>
{{if .Error}}
<div class="error-message">{{.Error}}</div>
{{end}}
<div class="step">
<h3>Étape 1 : Scanner le QR Code</h3>
<p>Scannez ce code avec votre application d'authentification (Google Authenticator, Authy, Microsoft Authenticator...).</p>
<div class="qr-container">
{{if .QRCodeDataURL}}
<img src="{{.QRCodeDataURL | safeURL}}" alt="QR Code pour 2FA">
{{end}}
</div>
<details>
<summary>Ou entrez le secret manuellement</summary>
<p style="margin-top: 1rem;">
<code class="secret-code">{{.TwoFASecret}}</code>
</p>
</details>
</div>
<div class="step">
<h3>Étape 2 : Sauvegardez vos codes de secours</h3>
<div class="warning">
Conservez ces codes dans un endroit sûr. Chaque code ne peut être utilisé qu'une seule fois en cas de perte de votre téléphone.
</div>
<div class="backup-codes">{{.BackupCodesFormatted}}</div>
</div>
<div class="step">
<h3>Étape 3 : Vérifier le code</h3>
<p>Entrez le code à 6 chiffres affiché dans votre application pour confirmer l'activation.</p>
<form action="/admin/2fa/setup" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="temp_secret" value="{{.TwoFASecret}}">
<input type="hidden" name="backup_codes" value="{{range $i, $code := .BackupCodes}}{{if $i}},{{end}}{{$code}}{{end}}">
<label for="verify_code">
Code d'authentification
<input type="text" id="verify_code" name="verify_code" class="code-input"
inputmode="numeric" maxlength="6" pattern="[0-9]{6}"
placeholder="000000" required autofocus
autocomplete="one-time-code">
</label>
<button type="submit">Vérifier et activer le 2FA</button>
</form>
</div>
<footer style="text-align: center; margin-top: 2rem;">
<a href="/admin/" style="font-size: 0.9rem;">Annuler</a>
</footer>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,107 @@
{{define "2fa_verify.html"}}
<!DOCTYPE html>
<html lang="fr" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vérification 2FA - SOGOMS Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.verify-card {
width: 100%;
max-width: 400px;
}
.logo {
text-align: center;
font-size: 2rem;
font-weight: bold;
margin-bottom: 1rem;
}
.logo span {
color: var(--pico-primary);
}
.subtitle {
text-align: center;
color: var(--pico-muted-color);
margin-bottom: 2rem;
}
.error-message {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 0.75rem 1rem;
border-radius: var(--pico-border-radius);
margin-bottom: 1rem;
}
.code-input {
text-align: center;
font-size: 1.5rem;
letter-spacing: 0.5rem;
font-family: monospace;
}
details {
margin-top: 1.5rem;
}
details summary {
cursor: pointer;
color: var(--pico-muted-color);
font-size: 0.9rem;
}
</style>
</head>
<body>
<article class="verify-card">
<div class="logo">SOGO<span>MS</span> Admin</div>
<p class="subtitle">Vérification en deux étapes</p>
{{if .Error}}
<div class="error-message">
{{.Error}}
</div>
{{end}}
<p>Entrez le code à 6 chiffres de votre application d'authentification.</p>
<form action="/admin/2fa/verify" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="code">
Code d'authentification
<input type="text" id="code" name="code" class="code-input"
inputmode="numeric" maxlength="6" pattern="[0-9]{6}"
placeholder="000000" required autofocus
autocomplete="one-time-code">
</label>
<button type="submit">Vérifier</button>
</form>
<details>
<summary>Utiliser un code de secours</summary>
<form action="/admin/2fa/verify" method="post" style="margin-top: 1rem;">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="use_backup" value="true">
<label for="backup_code">
Code de secours
<input type="text" id="backup_code" name="backup_code"
placeholder="XXXX-XXXX" style="font-family: monospace;">
</label>
<button type="submit" class="secondary">Utiliser le code</button>
</form>
</details>
<footer style="text-align: center; margin-top: 2rem;">
<a href="/admin/login" style="font-size: 0.9rem;">Annuler et se reconnecter</a>
</footer>
</article>
</body>
</html>
{{end}}

View File

@@ -71,7 +71,9 @@
<tr>
<th>Table</th>
<th>Colonnes</th>
<th>Clé primaire</th>
<th>PK</th>
<th>Relations (FK)</th>
<th>SD/C</th>
</tr>
</thead>
<tbody>
@@ -80,10 +82,15 @@
<td><strong>{{.Name}}</strong></td>
<td>{{.ColumnCount}}</td>
<td><code>{{.PrimaryKey}}</code></td>
<td>{{range .ForeignKeys}}<code>{{.}}</code><br>{{end}}</td>
<td>{{if .SoftDelete}}*{{end}}{{if .Cascade}}↓{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
<footer>
<small>PK = Clé primaire | FK = Clé étrangère | * = Soft Delete | ↓ = Cascade (supprime aussi les enfants)</small>
</footer>
</article>
{{end}}

View File

@@ -0,0 +1,159 @@
{{define "infra.html"}}
{{template "partials/header.html" .}}
<style>
.server-card {
margin-bottom: 1rem;
}
.server-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.server-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.9rem;
}
.server-info dt {
color: var(--pico-muted-color);
font-size: 0.8rem;
}
.server-info dd {
margin: 0;
font-weight: 500;
}
.badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.badge-online { background: #dcfce7; color: #166534; }
.badge-offline { background: #fef2f2; color: #dc2626; }
.badge-unknown { background: #f3f4f6; color: #6b7280; }
.badge-incus { background: #dbeafe; color: #1d4ed8; }
.badge-nginx { background: #fef3c7; color: #92400e; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.5rem;
margin-bottom: 1rem;
}
.stat-card {
background: var(--pico-card-background-color);
border: 1px solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
padding: 1rem;
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
color: var(--pico-primary);
}
.stat-label {
font-size: 0.8rem;
color: var(--pico-muted-color);
}
</style>
<h1>Infrastructure</h1>
<p class="user-info">
Gestion des serveurs, containers Incus et configurations Nginx.
</p>
{{if eq .Flash "success"}}
<div style="background:#f0fdf4;border:1px solid #bbf7d0;color:#16a34a;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
{{.FlashMessage}}
</div>
{{end}}
{{if eq .Flash "error"}}
<div style="background:#fef2f2;border:1px solid #fecaca;color:#dc2626;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
{{.FlashMessage}}
</div>
{{end}}
<!-- Statistiques -->
<div class="stats-grid" hx-get="/admin/api/infra/status" hx-trigger="every 30s" hx-swap="innerHTML">
{{$online := 0}}
{{$containers := 0}}
{{range .Servers}}
{{if eq .Status "online"}}{{$online = 1}}{{end}}
{{$containers = .ContainerCount}}
{{end}}
<div class="stat-card">
<div class="stat-value">{{len .Servers}}</div>
<div class="stat-label">Serveurs</div>
</div>
<div class="stat-card">
<div class="stat-value">{{range .Servers}}{{.ContainerCount}}{{end}}</div>
<div class="stat-label">Containers</div>
</div>
<div class="stat-card">
<div class="stat-value">{{range .Servers}}{{.NginxCount}}{{end}}</div>
<div class="stat-label">Sites Nginx</div>
</div>
</div>
<!-- Actions -->
<div style="margin-bottom: 1rem;">
<a href="/admin/infra/servers/new" role="button">+ Nouveau Serveur</a>
</div>
<!-- Liste des serveurs -->
{{if .Servers}}
{{range .Servers}}
<article class="server-card">
<header class="server-header">
<div>
<strong>{{.Name}}</strong>
{{if eq .Status "online"}}<span class="badge badge-online">Online</span>{{end}}
{{if eq .Status "offline"}}<span class="badge badge-offline">Offline</span>{{end}}
{{if eq .Status "unknown"}}<span class="badge badge-unknown">?</span>{{end}}
{{if .HasIncus}}<span class="badge badge-incus">Incus</span>{{end}}
{{if .HasNginx}}<span class="badge badge-nginx">Nginx</span>{{end}}
</div>
<a href="/admin/infra/servers/{{.ID}}" role="button" class="outline">Détails</a>
</header>
<dl class="server-info">
<div>
<dt>Host</dt>
<dd>{{.Host}}</dd>
</div>
{{if .VpnIP}}
<div>
<dt>VPN IP</dt>
<dd>{{.VpnIP}}</dd>
</div>
{{end}}
<div>
<dt>SSH</dt>
<dd>{{.SSHUser}}@:{{.SSHPort}}</dd>
</div>
<div>
<dt>Containers</dt>
<dd>{{.ContainerCount}}</dd>
</div>
<div>
<dt>Sites Nginx</dt>
<dd>{{.NginxCount}}</dd>
</div>
</dl>
</article>
{{end}}
{{else}}
<article>
<p style="text-align:center;color:var(--pico-muted-color);">
Aucun serveur configuré. <a href="/admin/infra/servers/new">Ajouter un serveur</a>
</p>
</article>
{{end}}
{{template "partials/footer.html" .}}
{{end}}

View File

@@ -32,7 +32,10 @@
<li><a href="/admin/"{{if eq .Title "Dashboard"}} aria-current="page"{{end}}>Dashboard</a></li>
{{if .IsSuperAdmin}}
<li><a href="/admin/apps"{{if eq .Title "Applications"}} aria-current="page"{{end}}>Apps</a></li>
<li><a href="/admin/infra"{{if eq .Title "Infrastructure"}} aria-current="page"{{end}}>Infra</a></li>
<li><a href="/admin/users"{{if eq .Title "Utilisateurs"}} aria-current="page"{{end}}>Utilisateurs</a></li>
{{end}}
<li><a href="/admin/security"{{if eq .Title "Sécurité"}} aria-current="page"{{end}}>Sécurité</a></li>
<li>
<form action="/admin/logout" method="post" style="margin:0">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">

View File

@@ -0,0 +1,10 @@
{{define "partials/infra_status.html"}}
<div class="stat-card">
<div class="stat-value">{{.ServerCount}}</div>
<div class="stat-label">Serveurs ({{.ServersOnline}} online)</div>
</div>
<div class="stat-card">
<div class="stat-value">{{.ContainerCount}}</div>
<div class="stat-label">Containers ({{.Running}} running)</div>
</div>
{{end}}

View File

@@ -0,0 +1,118 @@
{{define "security.html"}}
{{template "partials/header.html" .}}
<style>
.security-card {
max-width: 600px;
}
.status-enabled {
color: #16a34a;
font-weight: 500;
}
.status-disabled {
color: #dc2626;
font-weight: 500;
}
.backup-count {
font-size: 0.9rem;
color: var(--pico-muted-color);
}
.warning-box {
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
padding: 0.75rem 1rem;
border-radius: var(--pico-border-radius);
margin-bottom: 1rem;
font-size: 0.9rem;
}
</style>
<h1>Sécurité</h1>
<p class="user-info">
Paramètres de sécurité pour <strong>{{.User.Username}}</strong>
</p>
{{if .Error}}
<div class="error-message" style="background:#fef2f2;border:1px solid #fecaca;color:#dc2626;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
{{.Error}}
</div>
{{end}}
{{if .Info}}
<div style="background:#eff6ff;border:1px solid #bfdbfe;color:#1d4ed8;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
{{.Info}}
</div>
{{end}}
<article class="security-card">
<header>
<strong>Authentification à deux facteurs (2FA)</strong>
</header>
<p>
Statut :
{{if .TwoFAEnabled}}
<span class="status-enabled">Activé</span>
{{else}}
<span class="status-disabled">Désactivé</span>
{{end}}
</p>
{{if .TwoFAEnabled}}
<p class="backup-count">
Codes de secours restants : <strong>{{.BackupCount}}</strong>
</p>
{{if .TwoFARequired}}
<div class="warning-box">
Le 2FA est obligatoire pour votre rôle. Vous ne pouvez pas le désactiver.
</div>
{{else}}
<form action="/admin/2fa/disable" method="post" onsubmit="return confirm('Êtes-vous sûr de vouloir désactiver le 2FA ?');">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="password">
Mot de passe (confirmation)
<input type="password" id="password" name="password" required placeholder="Votre mot de passe">
</label>
<button type="submit" class="secondary">Désactiver le 2FA</button>
</form>
{{end}}
{{else}}
{{if .TwoFARequired}}
<div class="warning-box">
Le 2FA est obligatoire pour votre rôle. Veuillez l'activer.
</div>
{{end}}
<p>
Protégez votre compte avec une couche de sécurité supplémentaire.
Vous aurez besoin d'une application d'authentification (Google Authenticator, Authy, etc.).
</p>
<a href="/admin/2fa/setup" role="button">Activer le 2FA</a>
{{end}}
</article>
<article class="security-card" style="margin-top: 1rem;">
<header>
<strong>Informations de connexion</strong>
</header>
<dl>
<dt>Nom d'utilisateur</dt>
<dd>{{.User.Username}}</dd>
<dt>Email</dt>
<dd>{{.User.Email}}</dd>
<dt>Rôle</dt>
<dd>{{.User.Role}}</dd>
</dl>
</article>
{{template "partials/footer.html" .}}
{{end}}

View File

@@ -0,0 +1,245 @@
{{define "server_detail.html"}}
{{template "partials/header.html" .}}
<style>
.badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.badge-online { background: #dcfce7; color: #166534; }
.badge-offline { background: #fef2f2; color: #dc2626; }
.badge-unknown { background: #f3f4f6; color: #6b7280; }
.badge-running { background: #dcfce7; color: #166534; }
.badge-stopped { background: #fef2f2; color: #dc2626; }
.badge-active { background: #dcfce7; color: #166534; }
.badge-inactive { background: #f3f4f6; color: #6b7280; }
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.info-item dt {
color: var(--pico-muted-color);
font-size: 0.85rem;
}
.info-item dd {
margin: 0;
font-weight: 500;
}
.action-bar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.action-bar button, .action-bar a {
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
font-size: 0.9rem;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
margin: 0.1rem;
}
.btn-danger {
background: #dc2626;
border-color: #dc2626;
}
</style>
<nav aria-label="breadcrumb">
<ul>
<li><a href="/admin/infra">Infrastructure</a></li>
<li>{{.Server.Name}}</li>
</ul>
</nav>
<h1>
{{.Server.Name}}
{{if eq .Server.Status "online"}}<span class="badge badge-online">Online</span>{{end}}
{{if eq .Server.Status "offline"}}<span class="badge badge-offline">Offline</span>{{end}}
{{if eq .Server.Status "unknown"}}<span class="badge badge-unknown">?</span>{{end}}
</h1>
{{if eq .Flash "success"}}
<div style="background:#f0fdf4;border:1px solid #bbf7d0;color:#16a34a;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
{{.FlashMessage}}
</div>
{{end}}
{{if eq .Flash "error"}}
<div style="background:#fef2f2;border:1px solid #fecaca;color:#dc2626;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
{{.FlashMessage}}
</div>
{{end}}
<!-- Informations -->
<article>
<header>Informations</header>
<dl class="info-grid">
<div class="info-item">
<dt>Host</dt>
<dd>{{.Server.Host}}</dd>
</div>
{{if .Server.VpnIP}}
<div class="info-item">
<dt>VPN IP</dt>
<dd>{{.Server.VpnIP}}</dd>
</div>
{{end}}
<div class="info-item">
<dt>SSH</dt>
<dd>{{.Server.SSHUser}}@{{.Server.Host}}:{{.Server.SSHPort}}</dd>
</div>
<div class="info-item">
<dt>Clé SSH</dt>
<dd style="font-size:0.8rem;">{{.Server.SSHKeyFile}}</dd>
</div>
</dl>
<div class="action-bar">
<form action="/admin/infra/servers/{{.Server.ID}}/test-ssh" method="post" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="outline">Tester SSH</button>
</form>
<form action="/admin/infra/servers/{{.Server.ID}}/delete" method="post" style="display:inline;"
onsubmit="return confirm('Supprimer ce serveur ?');">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-danger outline">Supprimer</button>
</form>
</div>
</article>
<!-- Containers Incus -->
{{if .Server.HasIncus}}
<article>
<header style="display:flex;justify-content:space-between;align-items:center;">
<span>Containers Incus ({{len .Containers}})</span>
<form action="/admin/infra/servers/{{.Server.ID}}/sync-containers" method="post" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="outline btn-sm">Synchroniser</button>
</form>
</header>
{{if .Containers}}
<div class="table-container">
<table>
<thead>
<tr>
<th>Nom</th>
<th>IP</th>
<th>Image</th>
<th>Statut</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Containers}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td>{{if .IP}}{{.IP}}{{else}}-{{end}}</td>
<td style="font-size:0.8rem;">{{if .Image}}{{.Image}}{{else}}-{{end}}</td>
<td>
{{if eq .Status "running"}}<span class="badge badge-running">Running</span>{{end}}
{{if eq .Status "stopped"}}<span class="badge badge-stopped">Stopped</span>{{end}}
{{if eq .Status "unknown"}}<span class="badge badge-unknown">?</span>{{end}}
</td>
<td>
{{if eq .Status "stopped"}}
<form action="/admin/infra/containers/{{.ID}}/action" method="post" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="start">
<button type="submit" class="btn-sm outline">Start</button>
</form>
{{end}}
{{if eq .Status "running"}}
<form action="/admin/infra/containers/{{.ID}}/action" method="post" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="stop">
<button type="submit" class="btn-sm outline secondary">Stop</button>
</form>
<form action="/admin/infra/containers/{{.ID}}/action" method="post" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="restart">
<button type="submit" class="btn-sm outline">Restart</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p style="color:var(--pico-muted-color);text-align:center;">
Aucun container. Cliquez sur "Synchroniser" pour importer depuis Incus.
</p>
{{end}}
</article>
{{end}}
<!-- Configurations Nginx -->
{{if .Server.HasNginx}}
<article>
<header style="display:flex;justify-content:space-between;align-items:center;">
<span>Sites Nginx ({{len .NginxConfigs}})</span>
<div>
<form action="/admin/infra/servers/{{.Server.ID}}/sync-nginx" method="post" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="outline btn-sm">Synchroniser</button>
</form>
<form action="/admin/infra/servers/{{.Server.ID}}/nginx-reload" method="post" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="outline btn-sm">Reload Nginx</button>
</form>
</div>
</header>
{{if .NginxConfigs}}
<div class="table-container">
<table>
<thead>
<tr>
<th>Domaine</th>
<th>Type</th>
<th>SSL</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
{{range .NginxConfigs}}
<tr>
<td><strong>{{.Domain}}</strong></td>
<td>{{.Type}}</td>
<td>{{if .SSLEnabled}}Oui{{else}}Non{{end}}</td>
<td>
{{if eq .Status "active"}}<span class="badge badge-active">Actif</span>{{end}}
{{if eq .Status "inactive"}}<span class="badge badge-inactive">Inactif</span>{{end}}
{{if eq .Status "error"}}<span class="badge badge-offline">Erreur</span>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p style="color:var(--pico-muted-color);text-align:center;">
Aucun site. Cliquez sur "Synchroniser" pour importer depuis Nginx.
</p>
{{end}}
</article>
{{end}}
{{template "partials/footer.html" .}}
{{end}}

View File

@@ -0,0 +1,71 @@
{{define "server_new.html"}}
{{template "partials/header.html" .}}
<h1>Nouveau Serveur</h1>
<p class="user-info">
Ajoutez un serveur pour le gérer depuis l'interface admin.
</p>
<article>
<form action="/admin/infra/servers/new" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="grid">
<label>
Nom *
<input type="text" name="name" placeholder="in3" required>
<small>Identifiant unique du serveur</small>
</label>
<label>
Host *
<input type="text" name="host" placeholder="192.168.1.100 ou hostname.local" required>
<small>IP ou hostname pour la connexion SSH</small>
</label>
</div>
<div class="grid">
<label>
VPN IP
<input type="text" name="vpn_ip" placeholder="11.1.2.1">
<small>IP WireGuard (optionnel)</small>
</label>
<label>
Port SSH
<input type="number" name="ssh_port" value="22" min="1" max="65535">
</label>
</div>
<div class="grid">
<label>
Utilisateur SSH
<input type="text" name="ssh_user" value="root" required>
</label>
<label>
Fichier clé SSH *
<input type="text" name="ssh_key_file" placeholder="/root/.ssh/id_ed25519" required>
<small>Chemin vers la clé privée sur le container admin</small>
</label>
</div>
<fieldset>
<legend>Services disponibles</legend>
<label>
<input type="checkbox" name="has_incus">
Incus (gestion de containers)
</label>
<label>
<input type="checkbox" name="has_nginx">
Nginx (reverse proxy)
</label>
</fieldset>
<div style="display:flex;gap:1rem;margin-top:1rem;">
<button type="submit">Créer le serveur</button>
<a href="/admin/infra" role="button" class="outline secondary">Annuler</a>
</div>
</form>
</article>
{{template "partials/footer.html" .}}
{{end}}

View File

@@ -0,0 +1,105 @@
{{define "users.html"}}
{{template "partials/header.html" .}}
<style>
.users-table {
width: 100%;
}
.users-table th, .users-table td {
text-align: left;
padding: 0.75rem;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.badge-success {
background: #dcfce7;
color: #166534;
}
.badge-warning {
background: #fef3c7;
color: #92400e;
}
.badge-role {
background: #e0e7ff;
color: #3730a3;
}
.btn-danger {
background: #dc2626;
border-color: #dc2626;
}
.btn-danger:hover {
background: #b91c1c;
border-color: #b91c1c;
}
</style>
<h1>Utilisateurs Admin</h1>
<p class="user-info">
Gestion des utilisateurs de l'interface d'administration.
</p>
{{if eq .Flash "success"}}
<div style="background:#f0fdf4;border:1px solid #bbf7d0;color:#16a34a;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
{{.FlashMessage}}
</div>
{{end}}
{{if eq .Flash "error"}}
<div style="background:#fef2f2;border:1px solid #fecaca;color:#dc2626;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
{{.FlashMessage}}
</div>
{{end}}
<article>
<table class="users-table">
<thead>
<tr>
<th>Utilisateur</th>
<th>Email</th>
<th>Rôle</th>
<th>2FA</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Users}}
<tr>
<td><strong>{{.Username}}</strong></td>
<td>{{.Email}}</td>
<td><span class="badge badge-role">{{.Role}}</span></td>
<td>
{{if .TwoFAEnabled}}
<span class="badge badge-success">Activé</span>
<small style="color:var(--pico-muted-color);">({{.BackupCount}} codes)</small>
{{else}}
<span class="badge badge-warning">Désactivé</span>
{{end}}
</td>
<td>
{{if .TwoFAEnabled}}
<form action="/admin/users/reset-2fa" method="post" style="display:inline;"
onsubmit="return confirm('Réinitialiser le 2FA pour {{.Username}} ?');">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="username" value="{{.Username}}">
<button type="submit" class="btn-danger" style="padding:0.4rem 0.75rem;font-size:0.85rem;">
Reset 2FA
</button>
</form>
{{else}}
<span style="color:var(--pico-muted-color);font-size:0.85rem;">-</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</article>
{{template "partials/footer.html" .}}
{{end}}

138
cmd/sogoms/admin/totp.go Normal file
View File

@@ -0,0 +1,138 @@
package main
import (
"bytes"
"crypto/rand"
"encoding/base64"
"fmt"
"image/png"
"math/big"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/skip2/go-qrcode"
"golang.org/x/crypto/bcrypt"
)
// GenerateTOTPSecret génère un nouveau secret TOTP pour un utilisateur.
func GenerateTOTPSecret(issuer, username string) (*otp.Key, error) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: issuer,
AccountName: username,
Period: 30,
SecretSize: 20,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
return nil, fmt.Errorf("generate TOTP key: %w", err)
}
return key, nil
}
// ValidateTOTPCode valide un code TOTP à 6 chiffres.
func ValidateTOTPCode(secret, code string) bool {
return totp.Validate(code, secret)
}
// GenerateQRCodeDataURL génère une image QR code en data URL base64.
func GenerateQRCodeDataURL(key *otp.Key) (string, error) {
// Générer l'image QR
qr, err := qrcode.New(key.URL(), qrcode.Medium)
if err != nil {
return "", fmt.Errorf("create QR code: %w", err)
}
// Encoder en PNG
var buf bytes.Buffer
err = png.Encode(&buf, qr.Image(256))
if err != nil {
return "", fmt.Errorf("encode QR code: %w", err)
}
// Convertir en data URL
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
return dataURL, nil
}
// GenerateBackupCodes génère 10 codes de secours au format XXXX-XXXX.
func GenerateBackupCodes(count int) ([]string, error) {
if count <= 0 {
count = 10
}
codes := make([]string, count)
for i := 0; i < count; i++ {
// Générer 2 groupes de 4 chiffres
part1, err := randomDigits(4)
if err != nil {
return nil, err
}
part2, err := randomDigits(4)
if err != nil {
return nil, err
}
codes[i] = fmt.Sprintf("%s-%s", part1, part2)
}
return codes, nil
}
// randomDigits génère une chaîne de n chiffres aléatoires.
func randomDigits(n int) (string, error) {
max := new(big.Int)
max.Exp(big.NewInt(10), big.NewInt(int64(n)), nil)
num, err := rand.Int(rand.Reader, max)
if err != nil {
return "", fmt.Errorf("generate random digits: %w", err)
}
format := fmt.Sprintf("%%0%dd", n)
return fmt.Sprintf(format, num), nil
}
// HashBackupCodes hache tous les codes de secours avec bcrypt.
func HashBackupCodes(codes []string) ([]string, error) {
hashed := make([]string, len(codes))
for i, code := range codes {
hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash backup code: %w", err)
}
hashed[i] = string(hash)
}
return hashed, nil
}
// VerifyBackupCode vérifie un code de secours contre une liste de hashes.
// Retourne l'index du code trouvé ou -1 si non trouvé.
func VerifyBackupCode(code string, hashedCodes []string) int {
for i, hash := range hashedCodes {
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(code)) == nil {
return i
}
}
return -1
}
// RemoveBackupCode supprime un code de secours de la liste (après utilisation).
func RemoveBackupCode(codes []string, index int) []string {
if index < 0 || index >= len(codes) {
return codes
}
return append(codes[:index], codes[index+1:]...)
}
// FormatBackupCodes formate les codes pour affichage (2 colonnes).
func FormatBackupCodes(codes []string) string {
var buf bytes.Buffer
for i, code := range codes {
buf.WriteString(code)
if i%2 == 0 && i < len(codes)-1 {
buf.WriteString(" ")
} else {
buf.WriteString("\n")
}
}
return buf.String()
}

View File

@@ -292,6 +292,7 @@ func handleInsert(req *protocol.Request, db *sql.DB, appID string) *protocol.Res
}
// handleUpdate exécute un UPDATE et retourne le nombre de lignes affectées.
// Supporte le paramètre "raw" ([]string) pour les colonnes avec expressions SQL brutes.
func handleUpdate(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
table, ok := req.Params["table"].(string)
if !ok {
@@ -308,13 +309,28 @@ func handleUpdate(req *protocol.Request, db *sql.DB, appID string) *protocol.Res
return protocol.Failure(req.ID, "INVALID_PARAMS", "where is required")
}
// Récupérer les colonnes avec expressions SQL brutes
rawCols := make(map[string]bool)
if rawList, ok := req.Params["raw"].([]any); ok {
for _, col := range rawList {
if colStr, ok := col.(string); ok {
rawCols[colStr] = true
}
}
}
// Construire SET
setClauses := make([]string, 0, len(data))
values := make([]any, 0, len(data)+len(where))
for col, val := range data {
setClauses = append(setClauses, col+" = ?")
values = append(values, val)
if rawCols[col] {
// Expression SQL brute (ex: NOW(), NULL, etc.)
setClauses = append(setClauses, col+" = "+val.(string))
} else {
setClauses = append(setClauses, col+" = ?")
values = append(values, val)
}
}
// Construire WHERE
@@ -612,6 +628,13 @@ func handleIntrospect(req *protocol.Request, db *sql.DB, appID string) *protocol
table["primary"] = primaryKeys
}
// Détecter soft_delete (colonne deleted_at de type TIMESTAMP ou DATETIME)
if col, ok := columns["deleted_at"].(map[string]any); ok {
if colType, ok := col["type"].(string); ok && colType == "datetime" {
table["soft_delete"] = true
}
}
// CRUD par défaut (à affiner manuellement)
table["crud"] = []string{"list", "show", "create", "update", "delete"}