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:
@@ -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)
|
||||
|
||||
455
cmd/sogoms/admin/handlers_2fa.go
Normal file
455
cmd/sogoms/admin/handlers_2fa.go
Normal 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)
|
||||
}
|
||||
659
cmd/sogoms/admin/handlers_infra.go
Normal file
659
cmd/sogoms/admin/handlers_infra.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
154
cmd/sogoms/admin/templates/2fa_setup.html
Normal file
154
cmd/sogoms/admin/templates/2fa_setup.html
Normal 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}}
|
||||
107
cmd/sogoms/admin/templates/2fa_verify.html
Normal file
107
cmd/sogoms/admin/templates/2fa_verify.html
Normal 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}}
|
||||
@@ -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}}
|
||||
|
||||
|
||||
159
cmd/sogoms/admin/templates/infra.html
Normal file
159
cmd/sogoms/admin/templates/infra.html
Normal 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}}
|
||||
@@ -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}}">
|
||||
|
||||
10
cmd/sogoms/admin/templates/partials/infra_status.html
Normal file
10
cmd/sogoms/admin/templates/partials/infra_status.html
Normal 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}}
|
||||
118
cmd/sogoms/admin/templates/security.html
Normal file
118
cmd/sogoms/admin/templates/security.html
Normal 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}}
|
||||
245
cmd/sogoms/admin/templates/server_detail.html
Normal file
245
cmd/sogoms/admin/templates/server_detail.html
Normal 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}}
|
||||
71
cmd/sogoms/admin/templates/server_new.html
Normal file
71
cmd/sogoms/admin/templates/server_new.html
Normal 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}}
|
||||
105
cmd/sogoms/admin/templates/users.html
Normal file
105
cmd/sogoms/admin/templates/users.html
Normal 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
138
cmd/sogoms/admin/totp.go
Normal 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()
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user