diff --git a/DOCTECH.md b/DOCTECH.md index 053bbaf..f7332c7 100644 --- a/DOCTECH.md +++ b/DOCTECH.md @@ -220,6 +220,23 @@ Interface d'administration web pour gérer les applications SOGOMS. - Sessions : Cookie HttpOnly + Secure + SameSite=Strict - CSRF : Token par session - Rate limiting : 5 tentatives/min par IP +- **2FA obligatoire** : TOTP (Google Authenticator, Authy) + codes de secours + +**Authentification à deux facteurs (2FA) :** + +Le 2FA est obligatoire pour les rôles configurés dans `required_roles`. + +Flux de connexion avec 2FA : +``` +Login (password) → Session pending → /admin/2fa/verify → Session complète → Dashboard + ↓ + Code invalide → Retry (rate limited) +``` + +Première connexion (2FA requis mais pas configuré) : +``` +Login (password) → /admin/2fa/setup → Scanner QR + sauvegarder codes → Dashboard +``` **Routes :** - `GET /admin/login` : page de connexion @@ -230,6 +247,12 @@ Interface d'administration web pour gérer les applications SOGOMS. - `POST /admin/apps/{app}/scan` : scan DB et génération schema - `GET /admin/api/apps` : liste apps (htmx partial) - `GET /admin/api/services/health` : statut services (htmx partial) +- `GET /admin/2fa/verify` : page saisie code TOTP +- `POST /admin/2fa/verify` : validation code TOTP ou backup +- `GET /admin/2fa/setup` : page activation 2FA (QR code) +- `POST /admin/2fa/setup` : confirmation activation 2FA +- `POST /admin/2fa/disable` : désactivation 2FA +- `GET /admin/security` : page paramètres sécurité **Scan DB et génération automatique :** @@ -256,11 +279,22 @@ rate_limit: login_max: 5 login_window: 60 +two_fa: + enabled: true + issuer_name: "SOGOMS Admin" + required_roles: + - super_admin # 2FA obligatoire pour ce rôle + users: - username: pierre password_hash: "$2a$12$..." role: super_admin email: pierre@example.com + two_fa_enabled: true + two_fa_secret: "BASE32SECRET..." # généré lors du setup + backup_codes: # bcrypt hashed + - "$2a$10$..." + - "$2a$10$..." - username: client1 password_hash: "$2a$12$..." diff --git a/TODO.md b/TODO.md index afbfc0c..77f665a 100755 --- a/TODO.md +++ b/TODO.md @@ -362,6 +362,54 @@ users: email: pierre@example.com ``` +### 17g. Double Authentification (2FA) - OBLIGATOIRE + +**Prérequis de sécurité** : l'accès à l'admin SOGOMS doit être protégé par 2FA. + +- [x] Package Go `github.com/pquerna/otp` pour TOTP +- [x] Package Go `github.com/skip2/go-qrcode` pour QR codes +- [x] Stockage 2FA dans admin_users.yaml (two_fa_enabled, two_fa_secret, backup_codes) +- [x] Enrôlement TOTP : + - [x] Page `/admin/2fa/setup` : configuration 2FA + - [x] Génération secret TOTP (base32, 160 bits) + - [x] Affichage QR code pour scan (Google Auth, Authy, etc.) + - [x] Saisie code de vérification pour activer + - [x] Génération 10 codes de backup format XXXX-XXXX (usage unique) +- [x] Login avec 2FA : + - [x] Après password valide → page saisie code TOTP (`/admin/2fa/verify`) + - [x] Validation code TOTP (fenêtre ±30s) + - [x] Option "code de backup" si téléphone perdu + - [x] Session marquée `TwoFAVerified` +- [ ] Fallback Email OTP : + - [ ] Si TOTP non configuré → envoi code 6 chiffres par email + - [ ] Code valide 10 minutes, usage unique + - [ ] Utilise sogoms-smtp existant +- [x] Politique : + - [x] 2FA obligatoire pour rôles configurés (`required_roles`) + - [x] 2FA optionnel pour autres rôles + - [x] Forcer configuration 2FA à la première connexion si requis +- [x] Recovery : + - [x] Reset 2FA par super_admin (page `/admin/users`) + - [x] Audit log des actions 2FA (2fa_reset loggé) +- [x] Page `/admin/security` : gestion 2FA utilisateur +- [x] Page `/admin/users` : liste utilisateurs + bouton Reset 2FA (super_admin) +- [x] Config admin_users.yaml : + ```yaml + two_fa: + enabled: true + issuer_name: "SOGOMS Admin" + required_roles: [super_admin] + + users: + - username: pierre + password_hash: "$2a$12$..." + role: super_admin + email: pierre@example.com + two_fa_enabled: true + two_fa_secret: "BASE32SECRET..." + backup_codes: ["$2a$...", ...] # bcrypt hashed + ``` + ## Hors scope V1 - sogorch (orchestrateur scénarios) @@ -424,6 +472,264 @@ Note : le bouton "Scanner la base" (19b) fait office d'Update Schema. - [ ] JWT secret : auto-généré (openssl rand -base64 32) - [ ] Permissions fichiers : 600 +## Phase 20 : Soft Delete + +Objectif : supporter la suppression logique pour les tables ayant un champ `deleted_at`. + +### 20a. Détection lors du scan DB + +- [x] Introspection : détecter colonne `deleted_at` (TIMESTAMP ou DATETIME) +- [x] Schema.yaml : ajouter propriété `soft_delete: true` sur la table +- [x] Affichage admin : indicateur visuel tables avec soft delete (*) + +### 20b. Comportement DELETE + +- [x] Route DELETE : UPDATE `deleted_at = NOW()` au lieu de DELETE physique +- [x] Queries YAML : support soft_delete via schema +- [x] Réponse API : retourner `affected_rows` comme avant +- [x] Support paramètre `raw` dans sogoms-db pour expressions SQL brutes + +### 20c. Filtrage automatique SELECT + +- [x] Routes list/show : ajouter `WHERE deleted_at IS NULL` automatiquement +- [x] Schema-driven : BuildListQuery/BuildShowQuery avec filtre soft delete +- [x] Queries YAML : fonction addSoftDeleteFilter() pour injection automatique +- [ ] Option `include_deleted: true` pour voir les supprimés (admin) + +### 20d. Restauration (optionnel) + +- [ ] Route `POST /api/{resource}/{id}/restore` : remet `deleted_at = NULL` +- [ ] Permission spécifique pour restauration +- [ ] Logging de l'action restore + +### 20e. Purge définitive (optionnel) + +- [ ] Route `DELETE /api/{resource}/{id}/purge` : suppression physique +- [ ] Permission admin requise +- [ ] Confirmation double (paramètre `force=true`) + +### 20f. Cascade Soft Delete + +- [x] Détecter les tables enfants via FK (ex: tasks.project_id → projects) +- [x] Lors du soft delete parent, soft delete automatique des enfants +- [x] Récursion : petits-enfants supprimés avant enfants (depth-first) +- [x] Option `cascade: true` dans schema.yaml (auto-détecté lors du scan) +- [x] Auto-détection : cascade activé si parent a soft_delete ET enfants avec soft_delete +- [ ] Logging des suppressions en cascade + +--- + +## Phase 21 : Infrastructure Management + +Objectif : piloter depuis l'admin SOGOMS les configurations Nginx, serveurs et containers Incus. + +### 21a. Modèle de données + +- [x] Table `servers` : id, name, host (IP/hostname), vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status +- [x] Table `containers` : id, server_id, name, incus_name, ip, vpn_ip, image, status (running/stopped/unknown) +- [x] Table `nginx_configs` : id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status +- [x] Table `app_bindings` : id, app_id, container_id, nginx_config_id, server_id, type +- [x] Stockage : SQLite local `/data/infra.db` (flag `-infra-db`) +- [x] Migration : auto-création tables au démarrage (`internal/infra/migrations.go`) + +### 21b. SSH Pool (intégré dans sogoms-admin) + +- [x] `internal/infra/ssh.go` : client SSH avec pool de connexions +- [x] Pool de connexions SSH vers serveurs configurés +- [x] Reconnexion automatique en cas de perte (isAlive check) +- [x] Méthodes SSH : + - [x] `Exec` / `ExecSimple` : exécute commande sur serveur + - [x] `WriteFile` / `ReadFile` : lecture/écriture fichiers distants + - [x] `CopyFile` / `CopyFrom` : copie fichiers local ↔ distant + - [x] `StreamExec` : exécution avec streaming stdout/stderr +- [x] Config : clé SSH stockée dans SQLite (chemin fichier) +- [x] Sécurité : accès restreint super_admin uniquement + +### 21c. Gestion Incus (containers) + +- [x] `internal/infra/incus.go` : méthodes Incus via SSH +- [x] Action `ListIncusContainers` : liste containers (`incus list --format json`) +- [ ] Action `incus_create` : crée un container (image, nom, config) +- [x] Action `StartIncusContainer` : démarre un container +- [x] Action `StopIncusContainer` : arrête un container (graceful) +- [x] Action `RestartIncusContainer` : redémarre un container +- [ ] Action `incus_delete` : supprime un container (avec confirmation) +- [x] Action `ExecInContainer` : exécute une commande dans un container +- [ ] Action `incus_copy` : copie un container (backup/clone) +- [ ] Action `incus_move` : migre un container vers un autre serveur +- [ ] Action `incus_snapshot` : crée un snapshot +- [x] Sync : synchronisation containers Incus → base SQLite +- [ ] Templates : images préconfigurées (alpine-sogoms, alpine-node, alpine-nginx) +- [ ] Réseau : attribution IP automatique ou manuelle + +### 21d. Gestion Nginx + +- [x] `internal/infra/nginx.go` : méthodes Nginx via SSH +- [ ] Templates Nginx dans `config/nginx-templates/` + - [ ] `frontend.conf.tmpl` : proxy vers container frontend + - [ ] `api.conf.tmpl` : proxy vers sogoway (local ou distant via VPN) + - [ ] `admin.conf.tmpl` : proxy vers sogoms-admin + - [ ] `ssl.conf.tmpl` : config SSL commune (Let's Encrypt) +- [x] Action `GenerateNginxProxyConfig` : génère config proxy standard +- [x] Action `DeployNginxSite` : écrire + activer + recharger +- [x] Action `TestNginxConfig` : `nginx -t` sur le serveur cible +- [x] Action `ReloadNginx` : `systemctl reload nginx` sur le serveur cible +- [x] Action `ListNginxSites` : liste sites-available/enabled +- [x] Action `EnableNginxSite` / `DisableNginxSite` : gestion liens symboliques +- [x] Action `DeleteNginxSite` : supprime une config +- [x] Sync : synchronisation sites Nginx → base SQLite +- [x] Gestion Let's Encrypt : `RequestSSLCertificate` via certbot +- [ ] Rollback : sauvegarde config avant modification + +### 21e. Interface Admin + +- [x] Page `/admin/infra` : dashboard infrastructure (liste serveurs, stats) +- [x] Lien "Infra" dans header (super_admin only) +- [x] Section Serveurs : + - [x] Liste serveurs avec statut (online/offline/unknown) + - [x] Badges Incus/Nginx pour services disponibles + - [x] Bouton test connexion SSH + - [x] Formulaire ajout serveur (nom, host, vpn_ip, ssh_user, ssh_key_file, port) + - [x] Page détail serveur : containers, configs nginx + - [x] Bouton suppression serveur +- [x] Section Containers : + - [x] Liste containers par serveur + - [x] Statut (running/stopped/unknown) + - [x] Actions : start, stop, restart + - [x] Bouton sync depuis Incus + - [ ] Formulaire création container (serveur, image, nom, IP) + - [ ] Logs container (dernières lignes) +- [x] Section Nginx : + - [x] Liste sites par serveur/domaine + - [x] Statut : active/inactive + - [x] Bouton sync depuis serveur + - [x] Bouton reload Nginx + - [ ] Éditeur config (lecture seule ou édition avancée) + - [ ] Historique déploiements +- [ ] Section Apps : + - [ ] Vue unifiée : app → container frontend + config nginx + API sogoms + - [ ] Wizard création app complète (voir 21f) + +**⚠️ À TESTER** : Interface infra déployée, valider fonctionnement avec serveur réel. + +### 21f. Orchestration (Workflows) + +Workflows automatisés pour opérations complexes. + +- [ ] Workflow `app_create_full` : création app complète + 1. Créer container frontend sur serveur cible + 2. Configurer container (packages, user, etc.) + 3. Générer config Nginx frontend + 4. Générer config Nginx API (proxy vers sogoway) + 5. Déployer configs Nginx + 6. Créer config app SOGOMS (`config/apps/{app}/`) + 7. Recharger sogoway +- [ ] Workflow `app_migrate` : migration app vers autre serveur + 1. Snapshot container source + 2. Copier vers serveur destination + 3. Mettre à jour configs Nginx + 4. Basculer DNS (notification) + 5. Supprimer ancien container (optionnel) +- [ ] Workflow `ssl_setup` : configuration SSL + 1. Vérifier DNS pointe vers serveur + 2. Exécuter certbot + 3. Mettre à jour config Nginx + 4. Recharger Nginx +- [ ] Logging : toutes étapes loggées dans sogoms-logs +- [ ] Rollback : annulation automatique si échec + +### 21g. API Interne + +Endpoints admin pour piloter l'infrastructure. + +- [ ] `GET /admin/api/infra/servers` : liste serveurs +- [ ] `POST /admin/api/infra/servers` : ajoute serveur +- [ ] `DELETE /admin/api/infra/servers/{id}` : supprime serveur +- [ ] `POST /admin/api/infra/servers/{id}/test` : teste connexion +- [ ] `GET /admin/api/infra/containers` : liste containers (tous serveurs) +- [ ] `GET /admin/api/infra/containers/{server_id}` : containers d'un serveur +- [ ] `POST /admin/api/infra/containers` : crée container +- [ ] `POST /admin/api/infra/containers/{id}/start` : démarre +- [ ] `POST /admin/api/infra/containers/{id}/stop` : arrête +- [ ] `DELETE /admin/api/infra/containers/{id}` : supprime +- [ ] `GET /admin/api/infra/nginx` : liste configs nginx +- [ ] `POST /admin/api/infra/nginx/generate` : génère config +- [ ] `POST /admin/api/infra/nginx/deploy` : déploie config +- [ ] `POST /admin/api/infra/nginx/reload/{server_id}` : reload nginx +- [ ] `POST /admin/api/infra/workflows/{name}` : lance workflow + +### 21h. Configuration exemple + +```yaml +# /secrets/infra_servers.yaml +servers: + - name: IN3 + host: 195.154.80.116 + vpn_ip: 11.1.2.1 + ssh_user: root + ssh_key_file: /secrets/ssh_in3_key + ssh_port: 22 + type: host + incus: true + + - name: IN4 + host: 195.154.xx.xx + vpn_ip: 11.1.2.14 + ssh_user: root + ssh_key_file: /secrets/ssh_in4_key + ssh_port: 22 + type: host + incus: true + +# Templates Nginx +nginx: + templates_dir: /config/nginx-templates + certbot_email: admin@sogoms.com +``` + +```nginx +# config/nginx-templates/api.conf.tmpl +server { + server_name {{.Domain}}; + + location /api/ { + proxy_pass http://{{.ApiUpstream}}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + {{if .SSLEnabled}} + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/{{.Domain}}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{.Domain}}/privkey.pem; + {{else}} + listen 80; + {{end}} +} +``` + +### 21i. Sécurité + +- [ ] Clés SSH : fichiers séparés dans `/secrets/`, permissions 600 +- [ ] Accès : super_admin uniquement pour toutes opérations infra +- [ ] Audit : toutes actions loggées (qui, quoi, quand, serveur) +- [ ] Rate limiting : max 10 opérations/minute par user +- [ ] Confirmation : double confirmation pour actions destructives (delete container) +- [ ] Isolation : sogoms-infra tourne avec user dédié + +### 21j. Dépendances + +- Phase 17 (Admin UI) ✅ +- Phase 19 (Création App) ✅ +- Accès SSH aux serveurs (clés à configurer) +- Incus installé sur les serveurs hôtes +- [x] Package Go : `golang.org/x/crypto/ssh` +- [x] Package Go : `github.com/mattn/go-sqlite3` + +--- + ## Phase 18 : Application Geosector (Janvier-Février 2026) Migration de l'API PHP 8.3 existante vers SOGOMS pour l'application Flutter (Web + mobiles). diff --git a/VERSION b/VERSION index 1464c52..f9cbc01 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.5 \ No newline at end of file +1.0.7 \ No newline at end of file diff --git a/admin b/admin index 40b2c1f..964f0be 100755 Binary files a/admin and b/admin differ diff --git a/cmd/sogoms/admin/handlers.go b/cmd/sogoms/admin/handlers.go index 1237785..488032c 100644 --- a/cmd/sogoms/admin/handlers.go +++ b/cmd/sogoms/admin/handlers.go @@ -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) diff --git a/cmd/sogoms/admin/handlers_2fa.go b/cmd/sogoms/admin/handlers_2fa.go new file mode 100644 index 0000000..dee6988 --- /dev/null +++ b/cmd/sogoms/admin/handlers_2fa.go @@ -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) +} diff --git a/cmd/sogoms/admin/handlers_infra.go b/cmd/sogoms/admin/handlers_infra.go new file mode 100644 index 0000000..557dbac --- /dev/null +++ b/cmd/sogoms/admin/handlers_infra.go @@ -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) +} diff --git a/cmd/sogoms/admin/main.go b/cmd/sogoms/admin/main.go index eeb7964..b352c4a 100644 --- a/cmd/sogoms/admin/main.go +++ b/cmd/sogoms/admin/main.go @@ -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 diff --git a/cmd/sogoms/admin/middleware.go b/cmd/sogoms/admin/middleware.go index 83a7fb9..516abc8 100644 --- a/cmd/sogoms/admin/middleware.go +++ b/cmd/sogoms/admin/middleware.go @@ -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) { diff --git a/cmd/sogoms/admin/services.go b/cmd/sogoms/admin/services.go index 83641c6..98bc4bd 100644 --- a/cmd/sogoms/admin/services.go +++ b/cmd/sogoms/admin/services.go @@ -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) diff --git a/cmd/sogoms/admin/session.go b/cmd/sogoms/admin/session.go index 48f8c4d..006866d 100644 --- a/cmd/sogoms/admin/session.go +++ b/cmd/sogoms/admin/session.go @@ -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() diff --git a/cmd/sogoms/admin/templates/2fa_setup.html b/cmd/sogoms/admin/templates/2fa_setup.html new file mode 100644 index 0000000..879369c --- /dev/null +++ b/cmd/sogoms/admin/templates/2fa_setup.html @@ -0,0 +1,154 @@ +{{define "2fa_setup.html"}} + + + + + + Activer 2FA - SOGOMS Admin + + + + +
+ + +

Activer l'authentification à deux facteurs

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+

Étape 1 : Scanner le QR Code

+

Scannez ce code avec votre application d'authentification (Google Authenticator, Authy, Microsoft Authenticator...).

+ +
+ {{if .QRCodeDataURL}} + QR Code pour 2FA + {{end}} +
+ +
+ Ou entrez le secret manuellement +

+ {{.TwoFASecret}} +

+
+
+ +
+

Étape 2 : Sauvegardez vos codes de secours

+
+ 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. +
+
{{.BackupCodesFormatted}}
+
+ +
+

Étape 3 : Vérifier le code

+

Entrez le code à 6 chiffres affiché dans votre application pour confirmer l'activation.

+ +
+ + + + + + + +
+
+ + +
+ + +{{end}} diff --git a/cmd/sogoms/admin/templates/2fa_verify.html b/cmd/sogoms/admin/templates/2fa_verify.html new file mode 100644 index 0000000..1e9db5e --- /dev/null +++ b/cmd/sogoms/admin/templates/2fa_verify.html @@ -0,0 +1,107 @@ +{{define "2fa_verify.html"}} + + + + + + Vérification 2FA - SOGOMS Admin + + + + +
+ +

Vérification en deux étapes

+ + {{if .Error}} +
+ {{.Error}} +
+ {{end}} + +

Entrez le code à 6 chiffres de votre application d'authentification.

+ +
+ + + + + +
+ +
+ Utiliser un code de secours +
+ + + + + + +
+
+ + +
+ + +{{end}} diff --git a/cmd/sogoms/admin/templates/app_detail.html b/cmd/sogoms/admin/templates/app_detail.html index 86e458c..8ea025f 100644 --- a/cmd/sogoms/admin/templates/app_detail.html +++ b/cmd/sogoms/admin/templates/app_detail.html @@ -71,7 +71,9 @@ Table Colonnes - Clé primaire + PK + Relations (FK) + SD/C @@ -80,10 +82,15 @@ {{.Name}} {{.ColumnCount}} {{.PrimaryKey}} + {{range .ForeignKeys}}{{.}}
{{end}} + {{if .SoftDelete}}*{{end}}{{if .Cascade}}↓{{end}} {{end}} + {{end}} diff --git a/cmd/sogoms/admin/templates/infra.html b/cmd/sogoms/admin/templates/infra.html new file mode 100644 index 0000000..0c74f6b --- /dev/null +++ b/cmd/sogoms/admin/templates/infra.html @@ -0,0 +1,159 @@ +{{define "infra.html"}} +{{template "partials/header.html" .}} + + + +

Infrastructure

+ +

+ Gestion des serveurs, containers Incus et configurations Nginx. +

+ +{{if eq .Flash "success"}} +
+ {{.FlashMessage}} +
+{{end}} + +{{if eq .Flash "error"}} +
+ {{.FlashMessage}} +
+{{end}} + + +
+ {{$online := 0}} + {{$containers := 0}} + {{range .Servers}} + {{if eq .Status "online"}}{{$online = 1}}{{end}} + {{$containers = .ContainerCount}} + {{end}} +
+
{{len .Servers}}
+
Serveurs
+
+
+
{{range .Servers}}{{.ContainerCount}}{{end}}
+
Containers
+
+
+
{{range .Servers}}{{.NginxCount}}{{end}}
+
Sites Nginx
+
+
+ + +
+ + Nouveau Serveur +
+ + +{{if .Servers}} +{{range .Servers}} +
+
+
+ {{.Name}} + {{if eq .Status "online"}}Online{{end}} + {{if eq .Status "offline"}}Offline{{end}} + {{if eq .Status "unknown"}}?{{end}} + {{if .HasIncus}}Incus{{end}} + {{if .HasNginx}}Nginx{{end}} +
+ Détails +
+
+
+
Host
+
{{.Host}}
+
+ {{if .VpnIP}} +
+
VPN IP
+
{{.VpnIP}}
+
+ {{end}} +
+
SSH
+
{{.SSHUser}}@:{{.SSHPort}}
+
+
+
Containers
+
{{.ContainerCount}}
+
+
+
Sites Nginx
+
{{.NginxCount}}
+
+
+
+{{end}} +{{else}} +
+

+ Aucun serveur configuré. Ajouter un serveur +

+
+{{end}} + +{{template "partials/footer.html" .}} +{{end}} diff --git a/cmd/sogoms/admin/templates/partials/header.html b/cmd/sogoms/admin/templates/partials/header.html index 336b556..28743fc 100644 --- a/cmd/sogoms/admin/templates/partials/header.html +++ b/cmd/sogoms/admin/templates/partials/header.html @@ -32,7 +32,10 @@
  • Dashboard
  • {{if .IsSuperAdmin}}
  • Apps
  • +
  • Infra
  • +
  • Utilisateurs
  • {{end}} +
  • Sécurité
  • diff --git a/cmd/sogoms/admin/templates/partials/infra_status.html b/cmd/sogoms/admin/templates/partials/infra_status.html new file mode 100644 index 0000000..cd28aa4 --- /dev/null +++ b/cmd/sogoms/admin/templates/partials/infra_status.html @@ -0,0 +1,10 @@ +{{define "partials/infra_status.html"}} +
    +
    {{.ServerCount}}
    +
    Serveurs ({{.ServersOnline}} online)
    +
    +
    +
    {{.ContainerCount}}
    +
    Containers ({{.Running}} running)
    +
    +{{end}} diff --git a/cmd/sogoms/admin/templates/security.html b/cmd/sogoms/admin/templates/security.html new file mode 100644 index 0000000..e601fd6 --- /dev/null +++ b/cmd/sogoms/admin/templates/security.html @@ -0,0 +1,118 @@ +{{define "security.html"}} +{{template "partials/header.html" .}} + + + +

    Sécurité

    + + + +{{if .Error}} +
    + {{.Error}} +
    +{{end}} + +{{if .Info}} +
    + {{.Info}} +
    +{{end}} + +
    +
    + Authentification à deux facteurs (2FA) +
    + +

    + Statut : + {{if .TwoFAEnabled}} + Activé + {{else}} + Désactivé + {{end}} +

    + + {{if .TwoFAEnabled}} +

    + Codes de secours restants : {{.BackupCount}} +

    + + {{if .TwoFARequired}} +
    + Le 2FA est obligatoire pour votre rôle. Vous ne pouvez pas le désactiver. +
    + {{else}} + + + + + + + + {{end}} + + {{else}} + + {{if .TwoFARequired}} +
    + Le 2FA est obligatoire pour votre rôle. Veuillez l'activer. +
    + {{end}} + +

    + Protégez votre compte avec une couche de sécurité supplémentaire. + Vous aurez besoin d'une application d'authentification (Google Authenticator, Authy, etc.). +

    + + Activer le 2FA + + {{end}} +
    + +
    +
    + Informations de connexion +
    +
    +
    Nom d'utilisateur
    +
    {{.User.Username}}
    +
    Email
    +
    {{.User.Email}}
    +
    Rôle
    +
    {{.User.Role}}
    +
    +
    + +{{template "partials/footer.html" .}} +{{end}} diff --git a/cmd/sogoms/admin/templates/server_detail.html b/cmd/sogoms/admin/templates/server_detail.html new file mode 100644 index 0000000..a485251 --- /dev/null +++ b/cmd/sogoms/admin/templates/server_detail.html @@ -0,0 +1,245 @@ +{{define "server_detail.html"}} +{{template "partials/header.html" .}} + + + + + +

    + {{.Server.Name}} + {{if eq .Server.Status "online"}}Online{{end}} + {{if eq .Server.Status "offline"}}Offline{{end}} + {{if eq .Server.Status "unknown"}}?{{end}} +

    + +{{if eq .Flash "success"}} +
    + {{.FlashMessage}} +
    +{{end}} + +{{if eq .Flash "error"}} +
    + {{.FlashMessage}} +
    +{{end}} + + +
    +
    Informations
    +
    +
    +
    Host
    +
    {{.Server.Host}}
    +
    + {{if .Server.VpnIP}} +
    +
    VPN IP
    +
    {{.Server.VpnIP}}
    +
    + {{end}} +
    +
    SSH
    +
    {{.Server.SSHUser}}@{{.Server.Host}}:{{.Server.SSHPort}}
    +
    +
    +
    Clé SSH
    +
    {{.Server.SSHKeyFile}}
    +
    +
    + +
    +
    + + +
    +
    + + +
    +
    +
    + + +{{if .Server.HasIncus}} +
    +
    + Containers Incus ({{len .Containers}}) +
    + + +
    +
    + + {{if .Containers}} +
    + + + + + + + + + + + + {{range .Containers}} + + + + + + + + {{end}} + +
    NomIPImageStatutActions
    {{.Name}}{{if .IP}}{{.IP}}{{else}}-{{end}}{{if .Image}}{{.Image}}{{else}}-{{end}} + {{if eq .Status "running"}}Running{{end}} + {{if eq .Status "stopped"}}Stopped{{end}} + {{if eq .Status "unknown"}}?{{end}} + + {{if eq .Status "stopped"}} +
    + + + +
    + {{end}} + {{if eq .Status "running"}} +
    + + + +
    +
    + + + +
    + {{end}} +
    +
    + {{else}} +

    + Aucun container. Cliquez sur "Synchroniser" pour importer depuis Incus. +

    + {{end}} +
    +{{end}} + + +{{if .Server.HasNginx}} +
    +
    + Sites Nginx ({{len .NginxConfigs}}) +
    +
    + + +
    +
    + + +
    +
    +
    + + {{if .NginxConfigs}} +
    + + + + + + + + + + + {{range .NginxConfigs}} + + + + + + + {{end}} + +
    DomaineTypeSSLStatut
    {{.Domain}}{{.Type}}{{if .SSLEnabled}}Oui{{else}}Non{{end}} + {{if eq .Status "active"}}Actif{{end}} + {{if eq .Status "inactive"}}Inactif{{end}} + {{if eq .Status "error"}}Erreur{{end}} +
    +
    + {{else}} +

    + Aucun site. Cliquez sur "Synchroniser" pour importer depuis Nginx. +

    + {{end}} +
    +{{end}} + +{{template "partials/footer.html" .}} +{{end}} diff --git a/cmd/sogoms/admin/templates/server_new.html b/cmd/sogoms/admin/templates/server_new.html new file mode 100644 index 0000000..0c530b6 --- /dev/null +++ b/cmd/sogoms/admin/templates/server_new.html @@ -0,0 +1,71 @@ +{{define "server_new.html"}} +{{template "partials/header.html" .}} + +

    Nouveau Serveur

    + +

    + Ajoutez un serveur pour le gérer depuis l'interface admin. +

    + +
    +
    + + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + Services disponibles + + +
    + +
    + + Annuler +
    +
    +
    + +{{template "partials/footer.html" .}} +{{end}} diff --git a/cmd/sogoms/admin/templates/users.html b/cmd/sogoms/admin/templates/users.html new file mode 100644 index 0000000..998d4e5 --- /dev/null +++ b/cmd/sogoms/admin/templates/users.html @@ -0,0 +1,105 @@ +{{define "users.html"}} +{{template "partials/header.html" .}} + + + +

    Utilisateurs Admin

    + +

    + Gestion des utilisateurs de l'interface d'administration. +

    + +{{if eq .Flash "success"}} +
    + {{.FlashMessage}} +
    +{{end}} + +{{if eq .Flash "error"}} +
    + {{.FlashMessage}} +
    +{{end}} + +
    + + + + + + + + + + + + {{range .Users}} + + + + + + + + {{end}} + +
    UtilisateurEmailRôle2FAActions
    {{.Username}}{{.Email}}{{.Role}} + {{if .TwoFAEnabled}} + Activé + ({{.BackupCount}} codes) + {{else}} + Désactivé + {{end}} + + {{if .TwoFAEnabled}} +
    + + + +
    + {{else}} + - + {{end}} +
    +
    + +{{template "partials/footer.html" .}} +{{end}} diff --git a/cmd/sogoms/admin/totp.go b/cmd/sogoms/admin/totp.go new file mode 100644 index 0000000..011767b --- /dev/null +++ b/cmd/sogoms/admin/totp.go @@ -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() +} diff --git a/cmd/sogoms/db/main.go b/cmd/sogoms/db/main.go index 3e7f2be..c4bc433 100755 --- a/cmd/sogoms/db/main.go +++ b/cmd/sogoms/db/main.go @@ -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"} diff --git a/config/sogoctl.yaml b/config/sogoctl.yaml index 19f0fd2..54ce529 100644 --- a/config/sogoctl.yaml +++ b/config/sogoctl.yaml @@ -69,9 +69,6 @@ services: - "/config" - "-secrets" - "/secrets" - - "-templates" - - "/config/admin/templates" - - "-dev" - "-port" - "9000" - "-db-socket" diff --git a/deploy-admin.sh b/deploy-admin.sh deleted file mode 100755 index bed68a8..0000000 --- a/deploy-admin.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/bash - -# Script de déploiement des templates admin pour SOGOMS -# Déploie uniquement les templates HTML sans recompilation - -set -euo pipefail - -# Configuration SSH -JUMP_USER="root" -JUMP_HOST="195.154.80.116" -JUMP_PORT="22" -JUMP_KEY="/home/pierre/.ssh/id_rsa_mbpi" - -# Configuration Incus -INCUS_PROJECT="default" -INCUS_CONTAINER="gw3" - -# Chemin des templates sur le container -REMOTE_TEMPLATES="/config/admin/templates" - -# Couleurs -GREEN='\033[0;32m' -BLUE='\033[0;34m' -NC='\033[0m' - -echo_step() { echo -e "${GREEN}==>${NC} $1"; } -echo_info() { echo -e "${BLUE}Info:${NC} $1"; } - -# Répertoire du script -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -TEMPLATES_DIR="${SCRIPT_DIR}/cmd/sogoms/admin/templates" - -if [ ! -d "$TEMPLATES_DIR" ]; then - echo "Error: templates directory not found: $TEMPLATES_DIR" - exit 1 -fi - -# Commandes SSH/SCP -SSH_CMD="ssh -i ${JUMP_KEY} -p ${JUMP_PORT} ${JUMP_USER}@${JUMP_HOST}" -SCP_CMD="scp -i ${JUMP_KEY} -P ${JUMP_PORT}" - -# Créer archive des templates -echo_step "Creating templates archive..." -TIMESTAMP=$(date +%s) -ARCHIVE="sogoms-templates-${TIMESTAMP}.tar.gz" -tar -czf "/tmp/${ARCHIVE}" -C "${TEMPLATES_DIR}" . -echo_info "Archive: $(du -h /tmp/${ARCHIVE} | cut -f1)" - -# Copier vers IN3 -echo_step "Copying to jump server..." -$SCP_CMD "/tmp/${ARCHIVE}" "${JUMP_USER}@${JUMP_HOST}:/tmp/" - -# Déployer sur gw3 -echo_step "Deploying templates to ${INCUS_CONTAINER}..." -$SSH_CMD " - incus project switch ${INCUS_PROJECT} - incus file push /tmp/${ARCHIVE} ${INCUS_CONTAINER}/tmp/ - incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_TEMPLATES}/partials - incus exec ${INCUS_CONTAINER} -- tar -xzf /tmp/${ARCHIVE} -C ${REMOTE_TEMPLATES}/ - incus exec ${INCUS_CONTAINER} -- rm -f /tmp/${ARCHIVE} - rm -f /tmp/${ARCHIVE} - echo 'Templates deployed to ${REMOTE_TEMPLATES}' - incus exec ${INCUS_CONTAINER} -- ls -la ${REMOTE_TEMPLATES}/ -" - -# Cleanup local -rm -f "/tmp/${ARCHIVE}" - -echo_step "Done! Templates deployed." -echo_info "Dev mode: templates reload automatically on each request." diff --git a/deploy.sh b/deploy.sh index 1493c7a..b5df5fa 100755 --- a/deploy.sh +++ b/deploy.sh @@ -94,7 +94,8 @@ BIN_ARCHIVE="sogoms-bin-${TIMESTAMP}.tar.gz" CONFIG_ARCHIVE="sogoms-config-${TIMESTAMP}.tar.gz" tar -czf "/tmp/${BIN_ARCHIVE}" -C bin . || echo_error "Failed to create bin archive" -tar -czf "/tmp/${CONFIG_ARCHIVE}" -C config . || echo_error "Failed to create config archive" +# Exclure schema.yaml (généré par scan DB) et queries/auth.yaml (généré avec login_data) +tar -czf "/tmp/${CONFIG_ARCHIVE}" -C config --exclude='*/schema.yaml' --exclude='*/queries/auth.yaml' . || echo_error "Failed to create config archive" BIN_SIZE=$(du -h "/tmp/${BIN_ARCHIVE}" | cut -f1) CONFIG_SIZE=$(du -h "/tmp/${CONFIG_ARCHIVE}" | cut -f1) diff --git a/go.mod b/go.mod index 970f8f9..62edbac 100755 --- a/go.mod +++ b/go.mod @@ -6,7 +6,12 @@ toolchain go1.24.11 require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/pquerna/otp v1.5.0 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect golang.org/x/crypto v0.46.0 // indirect + golang.org/x/sys v0.39.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 42dcdcd..d9b726c 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,23 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/admin/config.go b/internal/admin/config.go index b1eeeb2..7c9b7df 100644 --- a/internal/admin/config.go +++ b/internal/admin/config.go @@ -13,9 +13,17 @@ import ( type AdminConfig struct { Session SessionConfig `yaml:"session"` RateLimit RateLimitConfig `yaml:"rate_limit"` + TwoFA TwoFAConfig `yaml:"two_fa"` Users []AdminUser `yaml:"users"` } +// TwoFAConfig configure l'authentification à deux facteurs. +type TwoFAConfig struct { + Enabled bool `yaml:"enabled"` + IssuerName string `yaml:"issuer_name"` + RequiredRoles []string `yaml:"required_roles"` // rôles obligés d'avoir 2FA +} + // SessionConfig configure les sessions. type SessionConfig struct { SecretFile string `yaml:"secret_file"` @@ -38,6 +46,10 @@ type AdminUser struct { Email string `yaml:"email"` Apps []string `yaml:"apps,omitempty"` // pour app_admin Permissions []string `yaml:"permissions,omitempty"` // pour app_admin + // 2FA + TwoFAEnabled bool `yaml:"two_fa_enabled,omitempty"` + TwoFASecret string `yaml:"two_fa_secret,omitempty"` // base32 encoded + BackupCodes []string `yaml:"backup_codes,omitempty"` // bcrypt hashed } // IsSuperAdmin retourne true si l'utilisateur est super_admin. @@ -45,6 +57,24 @@ func (u *AdminUser) IsSuperAdmin() bool { return u.Role == "super_admin" } +// NeedsTwoFA retourne true si l'utilisateur doit utiliser 2FA. +func (u *AdminUser) NeedsTwoFA(cfg *TwoFAConfig) bool { + if !cfg.Enabled { + return false + } + // Si 2FA activé pour cet utilisateur + if u.TwoFAEnabled { + return true + } + // Si le rôle est dans la liste des rôles obligés + for _, role := range cfg.RequiredRoles { + if u.Role == role { + return true + } + } + return false +} + // LoadAdminConfig charge la configuration admin depuis un fichier YAML. func LoadAdminConfig(path string) (*AdminConfig, error) { data, err := os.ReadFile(path) @@ -70,6 +100,9 @@ func LoadAdminConfig(path string) (*AdminConfig, error) { if cfg.RateLimit.LoginWindow == 0 { cfg.RateLimit.LoginWindow = 60 } + if cfg.TwoFA.IssuerName == "" { + cfg.TwoFA.IssuerName = "SOGOMS Admin" + } // Charger le secret de session depuis le fichier if cfg.Session.SecretFile != "" { @@ -110,3 +143,21 @@ func (cfg *AdminConfig) GetUserByEmail(email string) *AdminUser { } return nil } + +// SaveAdminConfig sauvegarde la configuration admin dans un fichier YAML. +func SaveAdminConfig(cfg *AdminConfig, path string) error { + // Créer une copie sans le secret en mémoire pour la sauvegarde + saveCfg := *cfg + saveCfg.Session.Secret = "" // Ne pas sauvegarder le secret déchiffré + + data, err := yaml.Marshal(&saveCfg) + if err != nil { + return fmt.Errorf("marshal admin config: %w", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("write admin config: %w", err) + } + + return nil +} diff --git a/internal/config/schema.go b/internal/config/schema.go index c3f7dca..9b3cb70 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -19,10 +19,12 @@ type Schema struct { // Table représente une table de la base de données. type Table struct { - Columns map[string]*Column `yaml:"columns"` - Primary []string `yaml:"primary,omitempty"` // Clé primaire composite - CRUD []string `yaml:"crud"` - Order string `yaml:"order,omitempty"` + Columns map[string]*Column `yaml:"columns"` + Primary []string `yaml:"primary,omitempty"` // Clé primaire composite + CRUD []string `yaml:"crud"` + Order string `yaml:"order,omitempty"` + SoftDelete bool `yaml:"soft_delete,omitempty"` // Si true, DELETE → UPDATE deleted_at + Cascade bool `yaml:"cascade,omitempty"` // Si true, soft delete en cascade sur enfants } // Column représente une colonne d'une table. @@ -78,6 +80,50 @@ func (t *Table) IsCompositePK() bool { return len(t.Primary) > 1 } +// IsSoftDelete vérifie si la table utilise le soft delete. +func (t *Table) IsSoftDelete() bool { + return t.SoftDelete +} + +// IsCascade vérifie si le cascade delete est activé. +func (t *Table) IsCascade() bool { + return t.Cascade +} + +// ChildRelation représente une relation enfant (FK vers table parent). +type ChildRelation struct { + ChildTable string // Nom de la table enfant + ChildColumn string // Colonne FK dans la table enfant + ParentTable string // Nom de la table parent +} + +// GetChildRelations retourne les tables enfants qui ont une FK vers parentTable. +func (s *Schema) GetChildRelations(parentTable string) []ChildRelation { + var children []ChildRelation + + for tableName, table := range s.Tables { + if tableName == parentTable { + continue // Skip la table elle-même + } + + for colName, col := range table.Columns { + if col.Foreign != "" { + // col.Foreign = "table.column" + parts := strings.Split(col.Foreign, ".") + if len(parts) >= 1 && parts[0] == parentTable { + children = append(children, ChildRelation{ + ChildTable: tableName, + ChildColumn: colName, + ParentTable: parentTable, + }) + } + } + } + } + + return children +} + // GetSelectColumns retourne la liste des colonnes pour un SELECT. func (t *Table) GetSelectColumns() []string { cols := make([]string, 0, len(t.Columns)) @@ -111,13 +157,26 @@ func (t *Table) GetUpdateColumns() []string { } // BuildListQuery génère la requête SELECT pour list. +// Filtre automatiquement les enregistrements supprimés si soft_delete est activé. func (t *Table) BuildListQuery(tableName string) string { cols := t.GetSelectColumns() query := fmt.Sprintf("SELECT %s FROM %s", strings.Join(cols, ", "), tableName) - // Ajouter filtre owner si présent + // Construire les conditions WHERE + var conditions []string + + // Filtre owner si présent if ownerCol := t.GetOwnerColumn(); ownerCol != "" { - query += fmt.Sprintf(" WHERE %s = ?", ownerCol) + conditions = append(conditions, fmt.Sprintf("%s = ?", ownerCol)) + } + + // Filtre soft delete + if t.SoftDelete { + conditions = append(conditions, "deleted_at IS NULL") + } + + if len(conditions) > 0 { + query += " WHERE " + strings.Join(conditions, " AND ") } // Ajouter ORDER BY si défini @@ -129,6 +188,7 @@ func (t *Table) BuildListQuery(tableName string) string { } // BuildShowQuery génère la requête SELECT pour show (un seul enregistrement). +// Filtre automatiquement les enregistrements supprimés si soft_delete est activé. func (t *Table) BuildShowQuery(tableName string) string { cols := t.GetSelectColumns() pk := t.GetPrimaryKey() @@ -139,6 +199,11 @@ func (t *Table) BuildShowQuery(tableName string) string { query += fmt.Sprintf(" AND %s = ?", ownerCol) } + // Filtre soft delete + if t.SoftDelete { + query += " AND deleted_at IS NULL" + } + return query } diff --git a/internal/infra/db.go b/internal/infra/db.go new file mode 100644 index 0000000..def0fad --- /dev/null +++ b/internal/infra/db.go @@ -0,0 +1,52 @@ +// Package infra gère l'infrastructure (serveurs, containers, nginx). +package infra + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "github.com/mattn/go-sqlite3" +) + +// DB représente la connexion à la base SQLite. +type DB struct { + *sql.DB +} + +// Open ouvre ou crée la base de données SQLite. +func Open(dbPath string) (*DB, error) { + // Créer le répertoire si nécessaire + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("create db directory: %w", err) + } + + // Ouvrir la connexion + sqlDB, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on") + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + // Tester la connexion + if err := sqlDB.Ping(); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + + db := &DB{sqlDB} + + // Exécuter les migrations + if err := db.Migrate(); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("migrate database: %w", err) + } + + return db, nil +} + +// Close ferme la connexion. +func (db *DB) Close() error { + return db.DB.Close() +} diff --git a/internal/infra/incus.go b/internal/infra/incus.go new file mode 100644 index 0000000..cf5a7af --- /dev/null +++ b/internal/infra/incus.go @@ -0,0 +1,227 @@ +// Package infra gère l'infrastructure (serveurs, containers, nginx). +package infra + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// IncusContainer représente un container Incus retourné par incus list. +type IncusContainer struct { + Name string `json:"name"` + State string `json:"state"` + IPv4 []string `json:"ipv4"` + IPv6 []string `json:"ipv6"` + Type string `json:"type"` + Snapshots int `json:"snapshots"` + Location string `json:"location"` + Image string `json:"-"` // Rempli séparément + CreatedAt string `json:"created_at"` +} + +// incusListJSON représente le format JSON de incus list. +type incusListJSON struct { + Name string `json:"name"` + Status string `json:"status"` + Type string `json:"type"` + Snapshots int `json:"snapshots"` + Location string `json:"location"` + IPv4 string `json:"ipv4"` + IPv6 string `json:"ipv6"` +} + +// ListIncusContainers liste les containers Incus sur le serveur. +func (c *SSHClient) ListIncusContainers(ctx context.Context) ([]IncusContainer, error) { + // Récupérer la liste en format JSON + result, err := c.Exec(ctx, "incus list --format json") + if err != nil { + return nil, fmt.Errorf("incus list: %w", err) + } + if result.ExitCode != 0 { + return nil, fmt.Errorf("incus list failed: %s", result.Stderr) + } + + // Parser le JSON + var rawContainers []struct { + Name string `json:"name"` + Status string `json:"status"` + StatusCode int `json:"status_code"` + Type string `json:"type"` + Config struct { + Image string `json:"image.description"` + } `json:"config"` + State struct { + Network map[string]struct { + Addresses []struct { + Address string `json:"address"` + Family string `json:"family"` + Scope string `json:"scope"` + } `json:"addresses"` + } `json:"network"` + } `json:"state"` + Snapshots []interface{} `json:"snapshots"` + Location string `json:"location"` + CreatedAt string `json:"created_at"` + } + + if err := json.Unmarshal([]byte(result.Stdout), &rawContainers); err != nil { + return nil, fmt.Errorf("parse incus json: %w", err) + } + + // Convertir en notre format + containers := make([]IncusContainer, 0, len(rawContainers)) + for _, rc := range rawContainers { + container := IncusContainer{ + Name: rc.Name, + State: strings.ToLower(rc.Status), + Type: rc.Type, + Snapshots: len(rc.Snapshots), + Location: rc.Location, + Image: rc.Config.Image, + CreatedAt: rc.CreatedAt, + } + + // Extraire les IPs + for _, net := range rc.State.Network { + for _, addr := range net.Addresses { + if addr.Scope == "global" { + if addr.Family == "inet" { + container.IPv4 = append(container.IPv4, addr.Address) + } else if addr.Family == "inet6" { + container.IPv6 = append(container.IPv6, addr.Address) + } + } + } + } + + containers = append(containers, container) + } + + return containers, nil +} + +// GetIncusContainer récupère les infos d'un container spécifique. +func (c *SSHClient) GetIncusContainer(ctx context.Context, name string) (*IncusContainer, error) { + containers, err := c.ListIncusContainers(ctx) + if err != nil { + return nil, err + } + + for _, container := range containers { + if container.Name == name { + return &container, nil + } + } + + return nil, fmt.Errorf("container %s not found", name) +} + +// StartIncusContainer démarre un container. +func (c *SSHClient) StartIncusContainer(ctx context.Context, name string) error { + result, err := c.Exec(ctx, fmt.Sprintf("incus start %s", name)) + if err != nil { + return fmt.Errorf("incus start: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("incus start %s failed: %s", name, result.Stderr) + } + return nil +} + +// StopIncusContainer arrête un container. +func (c *SSHClient) StopIncusContainer(ctx context.Context, name string) error { + result, err := c.Exec(ctx, fmt.Sprintf("incus stop %s", name)) + if err != nil { + return fmt.Errorf("incus stop: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("incus stop %s failed: %s", name, result.Stderr) + } + return nil +} + +// RestartIncusContainer redémarre un container. +func (c *SSHClient) RestartIncusContainer(ctx context.Context, name string) error { + result, err := c.Exec(ctx, fmt.Sprintf("incus restart %s", name)) + if err != nil { + return fmt.Errorf("incus restart: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("incus restart %s failed: %s", name, result.Stderr) + } + return nil +} + +// ExecInContainer exécute une commande dans un container. +func (c *SSHClient) ExecInContainer(ctx context.Context, containerName, cmd string) (*SSHResult, error) { + fullCmd := fmt.Sprintf("incus exec %s -- %s", containerName, cmd) + return c.Exec(ctx, fullCmd) +} + +// ExecInContainerSimple exécute une commande et retourne stdout. +func (c *SSHClient) ExecInContainerSimple(ctx context.Context, containerName, cmd string) (string, error) { + result, err := c.ExecInContainer(ctx, containerName, cmd) + if err != nil { + return "", err + } + if result.ExitCode != 0 { + return "", fmt.Errorf("command failed in %s (exit %d): %s", containerName, result.ExitCode, result.Stderr) + } + return strings.TrimSpace(result.Stdout), nil +} + +// PushFileToContainer envoie un fichier vers un container. +func (c *SSHClient) PushFileToContainer(ctx context.Context, containerName, localPath, remotePath string) error { + // D'abord copier vers le serveur hôte + tempPath := fmt.Sprintf("/tmp/incus-push-%d", ctx.Value("request_id")) + if err := c.CopyFile(ctx, localPath, tempPath); err != nil { + return fmt.Errorf("copy to host: %w", err) + } + + // Puis push vers le container + result, err := c.Exec(ctx, fmt.Sprintf("incus file push %s %s%s && rm %s", + tempPath, containerName, remotePath, tempPath)) + if err != nil { + return fmt.Errorf("incus file push: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("incus file push failed: %s", result.Stderr) + } + + return nil +} + +// PullFileFromContainer récupère un fichier depuis un container. +func (c *SSHClient) PullFileFromContainer(ctx context.Context, containerName, remotePath, localPath string) error { + tempPath := fmt.Sprintf("/tmp/incus-pull-%d", ctx.Value("request_id")) + + // Pull depuis le container vers l'hôte + result, err := c.Exec(ctx, fmt.Sprintf("incus file pull %s%s %s", + containerName, remotePath, tempPath)) + if err != nil { + return fmt.Errorf("incus file pull: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("incus file pull failed: %s", result.Stderr) + } + + // Puis copier vers local + if err := c.CopyFrom(ctx, tempPath, localPath); err != nil { + return fmt.Errorf("copy from host: %w", err) + } + + // Nettoyer + c.Exec(ctx, fmt.Sprintf("rm %s", tempPath)) + + return nil +} + +// GetContainerLogs récupère les logs d'un container. +func (c *SSHClient) GetContainerLogs(ctx context.Context, containerName string, lines int) (string, error) { + if lines <= 0 { + lines = 100 + } + return c.ExecInContainerSimple(ctx, containerName, fmt.Sprintf("journalctl -n %d --no-pager", lines)) +} diff --git a/internal/infra/migrations.go b/internal/infra/migrations.go new file mode 100644 index 0000000..b4e2d07 --- /dev/null +++ b/internal/infra/migrations.go @@ -0,0 +1,92 @@ +// Package infra gère l'infrastructure (serveurs, containers, nginx). +package infra + +import "fmt" + +// migrations contient les migrations SQL à exécuter dans l'ordre. +var migrations = []string{ + // Table servers + `CREATE TABLE IF NOT EXISTS servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + host TEXT NOT NULL, + vpn_ip TEXT, + ssh_port INTEGER NOT NULL DEFAULT 22, + ssh_user TEXT NOT NULL DEFAULT 'root', + ssh_key_file TEXT NOT NULL, + has_incus INTEGER NOT NULL DEFAULT 0, + has_nginx INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'unknown', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + + // Table containers + `CREATE TABLE IF NOT EXISTS containers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL, + name TEXT NOT NULL, + incus_name TEXT NOT NULL, + ip TEXT, + vpn_ip TEXT, + image TEXT, + status TEXT NOT NULL DEFAULT 'unknown', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE, + UNIQUE(server_id, incus_name) + )`, + + // Table nginx_configs + `CREATE TABLE IF NOT EXISTS nginx_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL, + domain TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'proxy', + template TEXT, + upstream TEXT, + ssl_enabled INTEGER NOT NULL DEFAULT 1, + config_content TEXT, + status TEXT NOT NULL DEFAULT 'inactive', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE, + UNIQUE(server_id, domain) + )`, + + // Table app_bindings + `CREATE TABLE IF NOT EXISTS app_bindings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + app_id TEXT NOT NULL, + container_id INTEGER, + nginx_config_id INTEGER, + server_id INTEGER, + type TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (container_id) REFERENCES containers(id) ON DELETE SET NULL, + FOREIGN KEY (nginx_config_id) REFERENCES nginx_configs(id) ON DELETE SET NULL, + FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL + )`, + + // Index pour accélérer les requêtes + `CREATE INDEX IF NOT EXISTS idx_containers_server ON containers(server_id)`, + `CREATE INDEX IF NOT EXISTS idx_nginx_configs_server ON nginx_configs(server_id)`, + `CREATE INDEX IF NOT EXISTS idx_nginx_configs_domain ON nginx_configs(domain)`, + `CREATE INDEX IF NOT EXISTS idx_app_bindings_app ON app_bindings(app_id)`, + + // Table migrations pour tracking des versions + `CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, +} + +// Migrate exécute toutes les migrations. +func (db *DB) Migrate() error { + for i, migration := range migrations { + if _, err := db.Exec(migration); err != nil { + return fmt.Errorf("migration %d failed: %w", i+1, err) + } + } + return nil +} diff --git a/internal/infra/models.go b/internal/infra/models.go new file mode 100644 index 0000000..d0b15d8 --- /dev/null +++ b/internal/infra/models.go @@ -0,0 +1,129 @@ +// Package infra gère l'infrastructure (serveurs, containers, nginx). +package infra + +import "time" + +// ServerStatus représente l'état d'un serveur. +type ServerStatus string + +const ( + ServerStatusOnline ServerStatus = "online" + ServerStatusOffline ServerStatus = "offline" + ServerStatusUnknown ServerStatus = "unknown" +) + +// ContainerStatus représente l'état d'un container. +type ContainerStatus string + +const ( + ContainerStatusRunning ContainerStatus = "running" + ContainerStatusStopped ContainerStatus = "stopped" + ContainerStatusUnknown ContainerStatus = "unknown" +) + +// NginxConfigStatus représente l'état d'une config Nginx. +type NginxConfigStatus string + +const ( + NginxConfigStatusActive NginxConfigStatus = "active" + NginxConfigStatusInactive NginxConfigStatus = "inactive" + NginxConfigStatusError NginxConfigStatus = "error" +) + +// NginxConfigType représente le type de config Nginx. +type NginxConfigType string + +const ( + NginxTypeProxy NginxConfigType = "proxy" + NginxTypeStatic NginxConfigType = "static" + NginxTypeSocket NginxConfigType = "socket" +) + +// BindingType représente le type de binding app. +type BindingType string + +const ( + BindingTypeContainer BindingType = "container" + BindingTypeNginx BindingType = "nginx" + BindingTypeServer BindingType = "server" +) + +// Server représente un serveur physique ou VM. +type Server struct { + ID int64 `json:"id"` + Name string `json:"name"` + Host string `json:"host"` + VpnIP string `json:"vpn_ip,omitempty"` + SSHPort int `json:"ssh_port"` + SSHUser string `json:"ssh_user"` + SSHKeyFile string `json:"ssh_key_file"` + HasIncus bool `json:"has_incus"` + HasNginx bool `json:"has_nginx"` + Status ServerStatus `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Container représente un container Incus. +type Container struct { + ID int64 `json:"id"` + ServerID int64 `json:"server_id"` + Name string `json:"name"` + IncusName string `json:"incus_name"` + IP string `json:"ip"` + VpnIP string `json:"vpn_ip,omitempty"` + Image string `json:"image"` + Status ContainerStatus `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relation (populé par query) + Server *Server `json:"server,omitempty"` +} + +// NginxConfig représente une configuration Nginx. +type NginxConfig struct { + ID int64 `json:"id"` + ServerID int64 `json:"server_id"` + Domain string `json:"domain"` + Type NginxConfigType `json:"type"` + Template string `json:"template,omitempty"` + Upstream string `json:"upstream,omitempty"` + SSLEnabled bool `json:"ssl_enabled"` + ConfigContent string `json:"config_content,omitempty"` + Status NginxConfigStatus `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relation (populé par query) + Server *Server `json:"server,omitempty"` +} + +// AppBinding représente le lien entre une app SOGOMS et l'infrastructure. +type AppBinding struct { + ID int64 `json:"id"` + AppID string `json:"app_id"` + ContainerID *int64 `json:"container_id,omitempty"` + NginxConfigID *int64 `json:"nginx_config_id,omitempty"` + ServerID *int64 `json:"server_id,omitempty"` + Type BindingType `json:"type"` + CreatedAt time.Time `json:"created_at"` + + // Relations (populées par query) + Container *Container `json:"container,omitempty"` + NginxConfig *NginxConfig `json:"nginx_config,omitempty"` + Server *Server `json:"server,omitempty"` +} + +// ServerWithContainers représente un serveur avec ses containers. +type ServerWithContainers struct { + Server + Containers []Container `json:"containers"` +} + +// InfraOverview représente une vue globale de l'infrastructure. +type InfraOverview struct { + Servers []ServerWithContainers `json:"servers"` + NginxConfigs []NginxConfig `json:"nginx_configs"` + AppBindings []AppBinding `json:"app_bindings"` +} diff --git a/internal/infra/nginx.go b/internal/infra/nginx.go new file mode 100644 index 0000000..8d4069e --- /dev/null +++ b/internal/infra/nginx.go @@ -0,0 +1,306 @@ +// Package infra gère l'infrastructure (serveurs, containers, nginx). +package infra + +import ( + "context" + "fmt" + "path/filepath" + "strings" +) + +const ( + // NginxSitesAvailable est le répertoire des sites disponibles. + NginxSitesAvailable = "/etc/nginx/sites-available" + // NginxSitesEnabled est le répertoire des sites activés. + NginxSitesEnabled = "/etc/nginx/sites-enabled" +) + +// NginxSiteInfo représente les infos d'un site Nginx. +type NginxSiteInfo struct { + Name string + Enabled bool + Config string + HasSSL bool + Domains []string + Upstream string +} + +// TestNginxConfig teste la configuration Nginx. +func (c *SSHClient) TestNginxConfig(ctx context.Context) error { + result, err := c.Exec(ctx, "nginx -t") + if err != nil { + return fmt.Errorf("nginx test: %w", err) + } + // nginx -t écrit sur stderr même en cas de succès + if result.ExitCode != 0 { + return fmt.Errorf("nginx config invalid: %s", result.Stderr) + } + return nil +} + +// ReloadNginx recharge la configuration Nginx. +func (c *SSHClient) ReloadNginx(ctx context.Context) error { + // D'abord tester la config + if err := c.TestNginxConfig(ctx); err != nil { + return err + } + + result, err := c.Exec(ctx, "systemctl reload nginx") + if err != nil { + return fmt.Errorf("nginx reload: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("nginx reload failed: %s", result.Stderr) + } + return nil +} + +// RestartNginx redémarre Nginx. +func (c *SSHClient) RestartNginx(ctx context.Context) error { + result, err := c.Exec(ctx, "systemctl restart nginx") + if err != nil { + return fmt.Errorf("nginx restart: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("nginx restart failed: %s", result.Stderr) + } + return nil +} + +// NginxStatus retourne le statut de Nginx. +func (c *SSHClient) NginxStatus(ctx context.Context) (string, error) { + result, err := c.Exec(ctx, "systemctl is-active nginx") + if err != nil { + return "unknown", nil + } + return strings.TrimSpace(result.Stdout), nil +} + +// ListNginxSites liste les sites Nginx configurés. +func (c *SSHClient) ListNginxSites(ctx context.Context) ([]NginxSiteInfo, error) { + // Lister sites-available + result, err := c.Exec(ctx, fmt.Sprintf("ls -1 %s", NginxSitesAvailable)) + if err != nil { + return nil, fmt.Errorf("list sites-available: %w", err) + } + + if result.ExitCode != 0 { + return nil, fmt.Errorf("list sites-available failed: %s", result.Stderr) + } + + // Lister sites-enabled + enabledResult, err := c.Exec(ctx, fmt.Sprintf("ls -1 %s", NginxSitesEnabled)) + if err != nil { + return nil, fmt.Errorf("list sites-enabled: %w", err) + } + + enabledSet := make(map[string]bool) + for _, name := range strings.Split(enabledResult.Stdout, "\n") { + name = strings.TrimSpace(name) + if name != "" { + enabledSet[name] = true + } + } + + var sites []NginxSiteInfo + for _, name := range strings.Split(result.Stdout, "\n") { + name = strings.TrimSpace(name) + if name == "" || name == "default" { + continue + } + + site := NginxSiteInfo{ + Name: name, + Enabled: enabledSet[name], + } + sites = append(sites, site) + } + + return sites, nil +} + +// GetNginxSiteConfig récupère la config d'un site. +func (c *SSHClient) GetNginxSiteConfig(ctx context.Context, name string) (string, error) { + path := filepath.Join(NginxSitesAvailable, name) + content, err := c.ReadFile(ctx, path) + if err != nil { + return "", fmt.Errorf("read site config: %w", err) + } + return string(content), nil +} + +// WriteNginxSiteConfig écrit la config d'un site. +func (c *SSHClient) WriteNginxSiteConfig(ctx context.Context, name string, config string) error { + path := filepath.Join(NginxSitesAvailable, name) + return c.WriteFile(ctx, path, []byte(config), "644") +} + +// EnableNginxSite active un site (crée le lien symbolique). +func (c *SSHClient) EnableNginxSite(ctx context.Context, name string) error { + src := filepath.Join(NginxSitesAvailable, name) + dst := filepath.Join(NginxSitesEnabled, name) + + // Vérifier que le site existe + exists, err := c.FileExists(ctx, src) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("site %s does not exist", name) + } + + // Créer le lien + result, err := c.Exec(ctx, fmt.Sprintf("ln -sf %s %s", src, dst)) + if err != nil { + return fmt.Errorf("enable site: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("enable site failed: %s", result.Stderr) + } + + return nil +} + +// DisableNginxSite désactive un site (supprime le lien symbolique). +func (c *SSHClient) DisableNginxSite(ctx context.Context, name string) error { + path := filepath.Join(NginxSitesEnabled, name) + + result, err := c.Exec(ctx, fmt.Sprintf("rm -f %s", path)) + if err != nil { + return fmt.Errorf("disable site: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("disable site failed: %s", result.Stderr) + } + + return nil +} + +// DeleteNginxSite supprime un site (désactive puis supprime). +func (c *SSHClient) DeleteNginxSite(ctx context.Context, name string) error { + // D'abord désactiver + if err := c.DisableNginxSite(ctx, name); err != nil { + return err + } + + // Puis supprimer + path := filepath.Join(NginxSitesAvailable, name) + result, err := c.Exec(ctx, fmt.Sprintf("rm -f %s", path)) + if err != nil { + return fmt.Errorf("delete site: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("delete site failed: %s", result.Stderr) + } + + return nil +} + +// DeployNginxSite déploie un site complet (écrire, activer, recharger). +func (c *SSHClient) DeployNginxSite(ctx context.Context, name string, config string) error { + // Écrire la config + if err := c.WriteNginxSiteConfig(ctx, name, config); err != nil { + return fmt.Errorf("write config: %w", err) + } + + // Activer le site + if err := c.EnableNginxSite(ctx, name); err != nil { + return fmt.Errorf("enable site: %w", err) + } + + // Recharger Nginx + if err := c.ReloadNginx(ctx); err != nil { + // En cas d'erreur, désactiver le site + c.DisableNginxSite(ctx, name) + return fmt.Errorf("reload nginx: %w", err) + } + + return nil +} + +// GenerateNginxProxyConfig génère une config proxy standard. +func GenerateNginxProxyConfig(domain, upstream string, ssl bool) string { + var config strings.Builder + + if ssl { + // Redirect HTTP to HTTPS + config.WriteString(fmt.Sprintf(`server { + listen 80; + listen [::]:80; + server_name %s; + return 301 https://$server_name$request_uri; +} + +`, domain)) + } + + // Main server block + if ssl { + config.WriteString(fmt.Sprintf(`server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name %s; + + ssl_certificate /etc/letsencrypt/live/%s/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/%s/privkey.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; + ssl_prefer_server_ciphers off; + + location / { + proxy_pass %s; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + } +} +`, domain, domain, domain, upstream)) + } else { + config.WriteString(fmt.Sprintf(`server { + listen 80; + listen [::]:80; + server_name %s; + + location / { + proxy_pass %s; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + } +} +`, domain, upstream)) + } + + return config.String() +} + +// RequestSSLCertificate demande un certificat Let's Encrypt. +func (c *SSHClient) RequestSSLCertificate(ctx context.Context, domain, email string) error { + cmd := fmt.Sprintf("certbot certonly --nginx -d %s --non-interactive --agree-tos -m %s", domain, email) + result, err := c.Exec(ctx, cmd) + if err != nil { + return fmt.Errorf("certbot: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("certbot failed: %s", result.Stderr) + } + return nil +} + +// CheckSSLCertificate vérifie si un certificat SSL existe. +func (c *SSHClient) CheckSSLCertificate(ctx context.Context, domain string) (bool, error) { + path := fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", domain) + return c.FileExists(ctx, path) +} diff --git a/internal/infra/repository.go b/internal/infra/repository.go new file mode 100644 index 0000000..1647086 --- /dev/null +++ b/internal/infra/repository.go @@ -0,0 +1,469 @@ +// Package infra gère l'infrastructure (serveurs, containers, nginx). +package infra + +import ( + "database/sql" + "fmt" + "time" +) + +// ============================================================================ +// Servers +// ============================================================================ + +// CreateServer crée un nouveau serveur. +func (db *DB) CreateServer(s *Server) error { + now := time.Now() + result, err := db.Exec(` + INSERT INTO servers (name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + s.Name, s.Host, nullString(s.VpnIP), s.SSHPort, s.SSHUser, s.SSHKeyFile, + boolToInt(s.HasIncus), boolToInt(s.HasNginx), s.Status, now, now) + if err != nil { + return fmt.Errorf("insert server: %w", err) + } + id, _ := result.LastInsertId() + s.ID = id + s.CreatedAt = now + s.UpdatedAt = now + return nil +} + +// GetServer récupère un serveur par ID. +func (db *DB) GetServer(id int64) (*Server, error) { + s := &Server{} + err := db.QueryRow(` + SELECT id, name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at + FROM servers WHERE id = ?`, id).Scan( + &s.ID, &s.Name, &s.Host, &s.VpnIP, &s.SSHPort, &s.SSHUser, &s.SSHKeyFile, + &s.HasIncus, &s.HasNginx, &s.Status, &s.CreatedAt, &s.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get server: %w", err) + } + return s, nil +} + +// GetServerByName récupère un serveur par nom. +func (db *DB) GetServerByName(name string) (*Server, error) { + s := &Server{} + err := db.QueryRow(` + SELECT id, name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at + FROM servers WHERE name = ?`, name).Scan( + &s.ID, &s.Name, &s.Host, &s.VpnIP, &s.SSHPort, &s.SSHUser, &s.SSHKeyFile, + &s.HasIncus, &s.HasNginx, &s.Status, &s.CreatedAt, &s.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get server by name: %w", err) + } + return s, nil +} + +// ListServers retourne tous les serveurs. +func (db *DB) ListServers() ([]Server, error) { + rows, err := db.Query(` + SELECT id, name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at + FROM servers ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("list servers: %w", err) + } + defer rows.Close() + + var servers []Server + for rows.Next() { + var s Server + if err := rows.Scan(&s.ID, &s.Name, &s.Host, &s.VpnIP, &s.SSHPort, &s.SSHUser, &s.SSHKeyFile, + &s.HasIncus, &s.HasNginx, &s.Status, &s.CreatedAt, &s.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan server: %w", err) + } + servers = append(servers, s) + } + return servers, nil +} + +// UpdateServer met à jour un serveur. +func (db *DB) UpdateServer(s *Server) error { + s.UpdatedAt = time.Now() + _, err := db.Exec(` + UPDATE servers SET name=?, host=?, vpn_ip=?, ssh_port=?, ssh_user=?, ssh_key_file=?, + has_incus=?, has_nginx=?, status=?, updated_at=? WHERE id=?`, + s.Name, s.Host, nullString(s.VpnIP), s.SSHPort, s.SSHUser, s.SSHKeyFile, + boolToInt(s.HasIncus), boolToInt(s.HasNginx), s.Status, s.UpdatedAt, s.ID) + if err != nil { + return fmt.Errorf("update server: %w", err) + } + return nil +} + +// UpdateServerStatus met à jour le statut d'un serveur. +func (db *DB) UpdateServerStatus(id int64, status ServerStatus) error { + _, err := db.Exec(`UPDATE servers SET status=?, updated_at=? WHERE id=?`, + status, time.Now(), id) + if err != nil { + return fmt.Errorf("update server status: %w", err) + } + return nil +} + +// DeleteServer supprime un serveur. +func (db *DB) DeleteServer(id int64) error { + _, err := db.Exec(`DELETE FROM servers WHERE id=?`, id) + if err != nil { + return fmt.Errorf("delete server: %w", err) + } + return nil +} + +// ============================================================================ +// Containers +// ============================================================================ + +// CreateContainer crée un nouveau container. +func (db *DB) CreateContainer(c *Container) error { + now := time.Now() + result, err := db.Exec(` + INSERT INTO containers (server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + c.ServerID, c.Name, c.IncusName, nullString(c.IP), nullString(c.VpnIP), c.Image, c.Status, now, now) + if err != nil { + return fmt.Errorf("insert container: %w", err) + } + id, _ := result.LastInsertId() + c.ID = id + c.CreatedAt = now + c.UpdatedAt = now + return nil +} + +// GetContainer récupère un container par ID. +func (db *DB) GetContainer(id int64) (*Container, error) { + c := &Container{} + err := db.QueryRow(` + SELECT id, server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at + FROM containers WHERE id = ?`, id).Scan( + &c.ID, &c.ServerID, &c.Name, &c.IncusName, &c.IP, &c.VpnIP, &c.Image, &c.Status, &c.CreatedAt, &c.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get container: %w", err) + } + return c, nil +} + +// ListContainersByServer retourne les containers d'un serveur. +func (db *DB) ListContainersByServer(serverID int64) ([]Container, error) { + rows, err := db.Query(` + SELECT id, server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at + FROM containers WHERE server_id = ? ORDER BY name`, serverID) + if err != nil { + return nil, fmt.Errorf("list containers: %w", err) + } + defer rows.Close() + + var containers []Container + for rows.Next() { + var c Container + if err := rows.Scan(&c.ID, &c.ServerID, &c.Name, &c.IncusName, &c.IP, &c.VpnIP, &c.Image, &c.Status, &c.CreatedAt, &c.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan container: %w", err) + } + containers = append(containers, c) + } + return containers, nil +} + +// ListAllContainers retourne tous les containers. +func (db *DB) ListAllContainers() ([]Container, error) { + rows, err := db.Query(` + SELECT id, server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at + FROM containers ORDER BY server_id, name`) + if err != nil { + return nil, fmt.Errorf("list all containers: %w", err) + } + defer rows.Close() + + var containers []Container + for rows.Next() { + var c Container + if err := rows.Scan(&c.ID, &c.ServerID, &c.Name, &c.IncusName, &c.IP, &c.VpnIP, &c.Image, &c.Status, &c.CreatedAt, &c.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan container: %w", err) + } + containers = append(containers, c) + } + return containers, nil +} + +// UpdateContainer met à jour un container. +func (db *DB) UpdateContainer(c *Container) error { + c.UpdatedAt = time.Now() + _, err := db.Exec(` + UPDATE containers SET server_id=?, name=?, incus_name=?, ip=?, vpn_ip=?, image=?, status=?, updated_at=? + WHERE id=?`, + c.ServerID, c.Name, c.IncusName, nullString(c.IP), nullString(c.VpnIP), c.Image, c.Status, c.UpdatedAt, c.ID) + if err != nil { + return fmt.Errorf("update container: %w", err) + } + return nil +} + +// UpdateContainerStatus met à jour le statut d'un container. +func (db *DB) UpdateContainerStatus(id int64, status ContainerStatus) error { + _, err := db.Exec(`UPDATE containers SET status=?, updated_at=? WHERE id=?`, + status, time.Now(), id) + if err != nil { + return fmt.Errorf("update container status: %w", err) + } + return nil +} + +// DeleteContainer supprime un container. +func (db *DB) DeleteContainer(id int64) error { + _, err := db.Exec(`DELETE FROM containers WHERE id=?`, id) + if err != nil { + return fmt.Errorf("delete container: %w", err) + } + return nil +} + +// ============================================================================ +// NginxConfigs +// ============================================================================ + +// CreateNginxConfig crée une nouvelle config Nginx. +func (db *DB) CreateNginxConfig(n *NginxConfig) error { + now := time.Now() + result, err := db.Exec(` + INSERT INTO nginx_configs (server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + n.ServerID, n.Domain, n.Type, nullString(n.Template), nullString(n.Upstream), + boolToInt(n.SSLEnabled), nullString(n.ConfigContent), n.Status, now, now) + if err != nil { + return fmt.Errorf("insert nginx config: %w", err) + } + id, _ := result.LastInsertId() + n.ID = id + n.CreatedAt = now + n.UpdatedAt = now + return nil +} + +// GetNginxConfig récupère une config Nginx par ID. +func (db *DB) GetNginxConfig(id int64) (*NginxConfig, error) { + n := &NginxConfig{} + err := db.QueryRow(` + SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at + FROM nginx_configs WHERE id = ?`, id).Scan( + &n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream, + &n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get nginx config: %w", err) + } + return n, nil +} + +// GetNginxConfigByDomain récupère une config par domaine. +func (db *DB) GetNginxConfigByDomain(serverID int64, domain string) (*NginxConfig, error) { + n := &NginxConfig{} + err := db.QueryRow(` + SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at + FROM nginx_configs WHERE server_id = ? AND domain = ?`, serverID, domain).Scan( + &n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream, + &n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get nginx config by domain: %w", err) + } + return n, nil +} + +// ListNginxConfigsByServer retourne les configs d'un serveur. +func (db *DB) ListNginxConfigsByServer(serverID int64) ([]NginxConfig, error) { + rows, err := db.Query(` + SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at + FROM nginx_configs WHERE server_id = ? ORDER BY domain`, serverID) + if err != nil { + return nil, fmt.Errorf("list nginx configs: %w", err) + } + defer rows.Close() + + var configs []NginxConfig + for rows.Next() { + var n NginxConfig + if err := rows.Scan(&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream, + &n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan nginx config: %w", err) + } + configs = append(configs, n) + } + return configs, nil +} + +// ListAllNginxConfigs retourne toutes les configs. +func (db *DB) ListAllNginxConfigs() ([]NginxConfig, error) { + rows, err := db.Query(` + SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at + FROM nginx_configs ORDER BY server_id, domain`) + if err != nil { + return nil, fmt.Errorf("list all nginx configs: %w", err) + } + defer rows.Close() + + var configs []NginxConfig + for rows.Next() { + var n NginxConfig + if err := rows.Scan(&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream, + &n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan nginx config: %w", err) + } + configs = append(configs, n) + } + return configs, nil +} + +// UpdateNginxConfig met à jour une config. +func (db *DB) UpdateNginxConfig(n *NginxConfig) error { + n.UpdatedAt = time.Now() + _, err := db.Exec(` + UPDATE nginx_configs SET server_id=?, domain=?, type=?, template=?, upstream=?, + ssl_enabled=?, config_content=?, status=?, updated_at=? WHERE id=?`, + n.ServerID, n.Domain, n.Type, nullString(n.Template), nullString(n.Upstream), + boolToInt(n.SSLEnabled), nullString(n.ConfigContent), n.Status, n.UpdatedAt, n.ID) + if err != nil { + return fmt.Errorf("update nginx config: %w", err) + } + return nil +} + +// DeleteNginxConfig supprime une config. +func (db *DB) DeleteNginxConfig(id int64) error { + _, err := db.Exec(`DELETE FROM nginx_configs WHERE id=?`, id) + if err != nil { + return fmt.Errorf("delete nginx config: %w", err) + } + return nil +} + +// ============================================================================ +// AppBindings +// ============================================================================ + +// CreateAppBinding crée un nouveau binding. +func (db *DB) CreateAppBinding(b *AppBinding) error { + now := time.Now() + result, err := db.Exec(` + INSERT INTO app_bindings (app_id, container_id, nginx_config_id, server_id, type, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + b.AppID, nullInt64(b.ContainerID), nullInt64(b.NginxConfigID), nullInt64(b.ServerID), b.Type, now) + if err != nil { + return fmt.Errorf("insert app binding: %w", err) + } + id, _ := result.LastInsertId() + b.ID = id + b.CreatedAt = now + return nil +} + +// ListAppBindings retourne les bindings d'une app. +func (db *DB) ListAppBindings(appID string) ([]AppBinding, error) { + rows, err := db.Query(` + SELECT id, app_id, container_id, nginx_config_id, server_id, type, created_at + FROM app_bindings WHERE app_id = ?`, appID) + if err != nil { + return nil, fmt.Errorf("list app bindings: %w", err) + } + defer rows.Close() + + var bindings []AppBinding + for rows.Next() { + var b AppBinding + if err := rows.Scan(&b.ID, &b.AppID, &b.ContainerID, &b.NginxConfigID, &b.ServerID, &b.Type, &b.CreatedAt); err != nil { + return nil, fmt.Errorf("scan app binding: %w", err) + } + bindings = append(bindings, b) + } + return bindings, nil +} + +// DeleteAppBinding supprime un binding. +func (db *DB) DeleteAppBinding(id int64) error { + _, err := db.Exec(`DELETE FROM app_bindings WHERE id=?`, id) + if err != nil { + return fmt.Errorf("delete app binding: %w", err) + } + return nil +} + +// DeleteAppBindingsByApp supprime tous les bindings d'une app. +func (db *DB) DeleteAppBindingsByApp(appID string) error { + _, err := db.Exec(`DELETE FROM app_bindings WHERE app_id=?`, appID) + if err != nil { + return fmt.Errorf("delete app bindings: %w", err) + } + return nil +} + +// ============================================================================ +// Helpers +// ============================================================================ + +func nullString(s string) interface{} { + if s == "" { + return nil + } + return s +} + +func nullInt64(i *int64) interface{} { + if i == nil { + return nil + } + return *i +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// ============================================================================ +// Aggregate queries +// ============================================================================ + +// GetInfraOverview retourne une vue complète de l'infrastructure. +func (db *DB) GetInfraOverview() (*InfraOverview, error) { + servers, err := db.ListServers() + if err != nil { + return nil, err + } + + overview := &InfraOverview{ + Servers: make([]ServerWithContainers, len(servers)), + } + + for i, s := range servers { + overview.Servers[i].Server = s + containers, err := db.ListContainersByServer(s.ID) + if err != nil { + return nil, err + } + overview.Servers[i].Containers = containers + } + + overview.NginxConfigs, err = db.ListAllNginxConfigs() + if err != nil { + return nil, err + } + + return overview, nil +} diff --git a/internal/infra/ssh.go b/internal/infra/ssh.go new file mode 100644 index 0000000..c66deee --- /dev/null +++ b/internal/infra/ssh.go @@ -0,0 +1,335 @@ +// Package infra gère l'infrastructure (serveurs, containers, nginx). +package infra + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "strings" + "sync" + "time" + + "golang.org/x/crypto/ssh" +) + +// SSHClient représente une connexion SSH à un serveur. +type SSHClient struct { + server *Server + client *ssh.Client + mu sync.Mutex +} + +// SSHPool gère un pool de connexions SSH. +type SSHPool struct { + clients map[int64]*SSHClient + mu sync.RWMutex + timeout time.Duration +} + +// SSHResult représente le résultat d'une commande SSH. +type SSHResult struct { + Stdout string + Stderr string + ExitCode int + Duration time.Duration +} + +// NewSSHPool crée un nouveau pool SSH. +func NewSSHPool(timeout time.Duration) *SSHPool { + if timeout == 0 { + timeout = 30 * time.Second + } + return &SSHPool{ + clients: make(map[int64]*SSHClient), + timeout: timeout, + } +} + +// Connect établit une connexion SSH à un serveur. +func (p *SSHPool) Connect(server *Server) (*SSHClient, error) { + p.mu.Lock() + defer p.mu.Unlock() + + // Vérifier si déjà connecté + if client, ok := p.clients[server.ID]; ok { + if client.isAlive() { + return client, nil + } + // Connexion morte, la supprimer + client.Close() + delete(p.clients, server.ID) + } + + // Lire la clé SSH + keyData, err := os.ReadFile(server.SSHKeyFile) + if err != nil { + return nil, fmt.Errorf("read ssh key: %w", err) + } + + signer, err := ssh.ParsePrivateKey(keyData) + if err != nil { + return nil, fmt.Errorf("parse ssh key: %w", err) + } + + // Config SSH + config := &ssh.ClientConfig{ + User: server.SSHUser, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: gérer les known_hosts + Timeout: p.timeout, + } + + // Connexion + addr := fmt.Sprintf("%s:%d", server.Host, server.SSHPort) + sshClient, err := ssh.Dial("tcp", addr, config) + if err != nil { + return nil, fmt.Errorf("ssh dial %s: %w", addr, err) + } + + client := &SSHClient{ + server: server, + client: sshClient, + } + p.clients[server.ID] = client + + return client, nil +} + +// Get récupère un client existant ou nil. +func (p *SSHPool) Get(serverID int64) *SSHClient { + p.mu.RLock() + defer p.mu.RUnlock() + return p.clients[serverID] +} + +// Disconnect ferme la connexion à un serveur. +func (p *SSHPool) Disconnect(serverID int64) { + p.mu.Lock() + defer p.mu.Unlock() + + if client, ok := p.clients[serverID]; ok { + client.Close() + delete(p.clients, serverID) + } +} + +// CloseAll ferme toutes les connexions. +func (p *SSHPool) CloseAll() { + p.mu.Lock() + defer p.mu.Unlock() + + for id, client := range p.clients { + client.Close() + delete(p.clients, id) + } +} + +// isAlive vérifie si la connexion est active. +func (c *SSHClient) isAlive() bool { + c.mu.Lock() + defer c.mu.Unlock() + + if c.client == nil { + return false + } + + // Test rapide avec une session + session, err := c.client.NewSession() + if err != nil { + return false + } + session.Close() + return true +} + +// Close ferme la connexion. +func (c *SSHClient) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.client != nil { + err := c.client.Close() + c.client = nil + return err + } + return nil +} + +// Exec exécute une commande sur le serveur distant. +func (c *SSHClient) Exec(ctx context.Context, cmd string) (*SSHResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.client == nil { + return nil, fmt.Errorf("ssh client not connected") + } + + session, err := c.client.NewSession() + if err != nil { + return nil, fmt.Errorf("create session: %w", err) + } + defer session.Close() + + var stdout, stderr bytes.Buffer + session.Stdout = &stdout + session.Stderr = &stderr + + start := time.Now() + + // Exécuter avec timeout + done := make(chan error, 1) + go func() { + done <- session.Run(cmd) + }() + + select { + case <-ctx.Done(): + session.Signal(ssh.SIGKILL) + return nil, ctx.Err() + case err := <-done: + result := &SSHResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + Duration: time.Since(start), + } + + if err != nil { + if exitErr, ok := err.(*ssh.ExitError); ok { + result.ExitCode = exitErr.ExitStatus() + } else { + return nil, fmt.Errorf("exec command: %w", err) + } + } + + return result, nil + } +} + +// ExecSimple exécute une commande et retourne stdout. +func (c *SSHClient) ExecSimple(ctx context.Context, cmd string) (string, error) { + result, err := c.Exec(ctx, cmd) + if err != nil { + return "", err + } + if result.ExitCode != 0 { + return "", fmt.Errorf("command failed (exit %d): %s", result.ExitCode, result.Stderr) + } + return strings.TrimSpace(result.Stdout), nil +} + +// WriteFile écrit un fichier sur le serveur distant via cat. +func (c *SSHClient) WriteFile(ctx context.Context, path string, content []byte, mode string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.client == nil { + return fmt.Errorf("ssh client not connected") + } + + session, err := c.client.NewSession() + if err != nil { + return fmt.Errorf("create session: %w", err) + } + defer session.Close() + + // Utiliser cat pour écrire le fichier + stdin, err := session.StdinPipe() + if err != nil { + return fmt.Errorf("stdin pipe: %w", err) + } + + cmd := fmt.Sprintf("cat > %s && chmod %s %s", path, mode, path) + if err := session.Start(cmd); err != nil { + return fmt.Errorf("start command: %w", err) + } + + if _, err := stdin.Write(content); err != nil { + return fmt.Errorf("write content: %w", err) + } + stdin.Close() + + if err := session.Wait(); err != nil { + return fmt.Errorf("wait command: %w", err) + } + + return nil +} + +// ReadFile lit un fichier depuis le serveur distant. +func (c *SSHClient) ReadFile(ctx context.Context, path string) ([]byte, error) { + result, err := c.Exec(ctx, fmt.Sprintf("cat %s", path)) + if err != nil { + return nil, err + } + if result.ExitCode != 0 { + return nil, fmt.Errorf("read file failed: %s", result.Stderr) + } + return []byte(result.Stdout), nil +} + +// FileExists vérifie si un fichier existe. +func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) { + result, err := c.Exec(ctx, fmt.Sprintf("test -e %s && echo yes || echo no", path)) + if err != nil { + return false, err + } + return strings.TrimSpace(result.Stdout) == "yes", nil +} + +// SFTP retourne un client SFTP pour des opérations de fichiers avancées. +// Note: nécessite github.com/pkg/sftp si on veut un vrai SFTP. +// Pour l'instant on utilise des commandes shell. + +// CopyFile copie un fichier local vers le serveur distant. +func (c *SSHClient) CopyFile(ctx context.Context, localPath, remotePath string) error { + content, err := os.ReadFile(localPath) + if err != nil { + return fmt.Errorf("read local file: %w", err) + } + return c.WriteFile(ctx, remotePath, content, "644") +} + +// CopyFrom copie un fichier du serveur distant vers local. +func (c *SSHClient) CopyFrom(ctx context.Context, remotePath, localPath string) error { + content, err := c.ReadFile(ctx, remotePath) + if err != nil { + return err + } + return os.WriteFile(localPath, content, 0644) +} + +// StreamExec exécute une commande et stream la sortie. +func (c *SSHClient) StreamExec(ctx context.Context, cmd string, stdout, stderr io.Writer) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.client == nil { + return fmt.Errorf("ssh client not connected") + } + + session, err := c.client.NewSession() + if err != nil { + return fmt.Errorf("create session: %w", err) + } + defer session.Close() + + session.Stdout = stdout + session.Stderr = stderr + + done := make(chan error, 1) + go func() { + done <- session.Run(cmd) + }() + + select { + case <-ctx.Done(): + session.Signal(ssh.SIGKILL) + return ctx.Err() + case err := <-done: + return err + } +}