Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b1977e0c4 |
34
DOCTECH.md
34
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$..."
|
||||
|
||||
306
TODO.md
306
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).
|
||||
|
||||
@@ -11,33 +11,26 @@ import (
|
||||
"sogoms.com/internal/admin"
|
||||
"sogoms.com/internal/auth"
|
||||
"sogoms.com/internal/config"
|
||||
"sogoms.com/internal/infra"
|
||||
)
|
||||
|
||||
// AdminServer contient les dépendances des handlers.
|
||||
type AdminServer struct {
|
||||
adminCfg *admin.AdminConfig
|
||||
registry *config.Registry
|
||||
sessions *SessionStore
|
||||
version string
|
||||
rateLimiter *RateLimiter
|
||||
perms *admin.PermissionChecker
|
||||
audit *admin.AuditLogger
|
||||
services *ServicePool
|
||||
templates *template.Template
|
||||
templatesDir string
|
||||
devMode bool
|
||||
adminCfg *admin.AdminConfig
|
||||
registry *config.Registry
|
||||
sessions *SessionStore
|
||||
version string
|
||||
rateLimiter *RateLimiter
|
||||
perms *admin.PermissionChecker
|
||||
audit *admin.AuditLogger
|
||||
services *ServicePool
|
||||
templates *template.Template
|
||||
infraDB *infra.DB
|
||||
sshPool *infra.SSHPool
|
||||
}
|
||||
|
||||
// getTemplates retourne les templates, en les rechargeant si devMode est activé.
|
||||
// getTemplates retourne les templates.
|
||||
func (s *AdminServer) getTemplates() *template.Template {
|
||||
if s.devMode && s.templatesDir != "" {
|
||||
tmpl, err := loadTemplates(s.templatesDir)
|
||||
if err != nil {
|
||||
log.Printf("[admin] reload templates error: %v", err)
|
||||
return s.templates
|
||||
}
|
||||
return tmpl
|
||||
}
|
||||
return s.templates
|
||||
}
|
||||
|
||||
@@ -99,8 +92,34 @@ func (s *AdminServer) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Créer la session
|
||||
session, err := s.sessions.Create(username, user.Role, ip, userAgent)
|
||||
// Vérifier si 2FA est requis
|
||||
needsTwoFA := user.NeedsTwoFA(&s.adminCfg.TwoFA)
|
||||
|
||||
var session *Session
|
||||
var err error
|
||||
|
||||
if needsTwoFA && user.TwoFAEnabled {
|
||||
// Créer une session en attente de validation 2FA
|
||||
session, err = s.sessions.CreatePending(username, user.Role, ip, userAgent)
|
||||
if err != nil {
|
||||
log.Printf("[admin] failed to create pending session: %v", err)
|
||||
http.Redirect(w, r, "/admin/login?error=Erreur+serveur", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Définir le cookie
|
||||
s.sessions.SetCookie(w, session)
|
||||
|
||||
// Log tentative
|
||||
s.audit.LogLogin(true, username, ip, userAgent, "pending_2fa")
|
||||
|
||||
// Rediriger vers vérification 2FA
|
||||
http.Redirect(w, r, "/admin/2fa/verify", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Pas de 2FA requis ou pas configuré - créer session normale
|
||||
session, err = s.sessions.Create(username, user.Role, ip, userAgent)
|
||||
if err != nil {
|
||||
log.Printf("[admin] failed to create session: %v", err)
|
||||
http.Redirect(w, r, "/admin/login?error=Erreur+serveur", http.StatusSeeOther)
|
||||
@@ -113,6 +132,12 @@ func (s *AdminServer) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// Log succès
|
||||
s.audit.LogLogin(true, username, ip, userAgent, "")
|
||||
|
||||
// Si 2FA requis mais pas encore configuré, rediriger vers setup
|
||||
if needsTwoFA && !user.TwoFAEnabled {
|
||||
http.Redirect(w, r, "/admin/2fa/setup?required=true", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Rediriger vers dashboard
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
@@ -238,6 +263,9 @@ type TableInfo struct {
|
||||
Name string
|
||||
ColumnCount int
|
||||
PrimaryKey string
|
||||
SoftDelete bool
|
||||
Cascade bool
|
||||
ForeignKeys []string // Ex: ["project_id → projects", "user_id → users"]
|
||||
}
|
||||
|
||||
// RouteInfo contient les infos d'une route pour le template.
|
||||
@@ -377,14 +405,32 @@ func (s *AdminServer) HandleAppDetailPage(w http.ResponseWriter, r *http.Request
|
||||
var tables []TableInfo
|
||||
if cfg.Schema != nil {
|
||||
for name, table := range cfg.Schema.Tables {
|
||||
// Clé primaire : composite ou simple
|
||||
pk := ""
|
||||
if len(table.Primary) > 0 {
|
||||
if len(table.Primary) > 1 {
|
||||
pk = strings.Join(table.Primary, ", ")
|
||||
} else {
|
||||
pk = table.GetPrimaryKey()
|
||||
}
|
||||
|
||||
// Collecter les clés étrangères
|
||||
var fks []string
|
||||
for colName, col := range table.Columns {
|
||||
if col.Foreign != "" {
|
||||
// col.Foreign = "table.column", on extrait juste la table
|
||||
parts := strings.Split(col.Foreign, ".")
|
||||
refTable := parts[0]
|
||||
fks = append(fks, colName+" → "+refTable)
|
||||
}
|
||||
}
|
||||
|
||||
tables = append(tables, TableInfo{
|
||||
Name: name,
|
||||
ColumnCount: len(table.Columns),
|
||||
PrimaryKey: pk,
|
||||
SoftDelete: table.SoftDelete,
|
||||
Cascade: table.Cascade,
|
||||
ForeignKeys: fks,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -540,6 +586,16 @@ func (s *AdminServer) HandleAppCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Recharger le registry local
|
||||
if err := s.registry.Load(); err != nil {
|
||||
log.Printf("[admin] reload registry error: %v", err)
|
||||
}
|
||||
|
||||
// Recharger sogoway pour qu'il connaisse la nouvelle app
|
||||
if err := s.services.ReloadGateway(); err != nil {
|
||||
log.Printf("[admin] reload gateway error: %v", err)
|
||||
}
|
||||
|
||||
// Log l'action
|
||||
s.audit.LogAction(user.Username, "create_app", appName, map[string]any{
|
||||
"ip": getClientIP(r),
|
||||
@@ -586,14 +642,27 @@ func (s *AdminServer) HandleAppScanDB(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Générer les fichiers queries depuis le schema
|
||||
if err := GenerateQueriesFromSchema(appID); err != nil {
|
||||
log.Printf("[admin] generate queries error: %v", err)
|
||||
} else {
|
||||
log.Printf("[admin] queries generated for app: %s", appID)
|
||||
}
|
||||
|
||||
// Mettre à jour login_data dans auth.yaml
|
||||
if err := UpdateLoginData(appID); err != nil {
|
||||
log.Printf("[admin] update login_data error: %v", err)
|
||||
// On ne bloque pas, le scan a réussi
|
||||
} else {
|
||||
log.Printf("[admin] login_data updated for app: %s", appID)
|
||||
}
|
||||
|
||||
// Générer les routes dans app.yaml
|
||||
if err := GenerateRoutesFromSchema(appID); err != nil {
|
||||
log.Printf("[admin] generate routes error: %v", err)
|
||||
} else {
|
||||
log.Printf("[admin] routes generated for app: %s", appID)
|
||||
}
|
||||
|
||||
// Recharger le registry local
|
||||
if err := s.registry.Load(); err != nil {
|
||||
log.Printf("[admin] reload registry error: %v", err)
|
||||
|
||||
455
cmd/sogoms/admin/handlers_2fa.go
Normal file
455
cmd/sogoms/admin/handlers_2fa.go
Normal file
@@ -0,0 +1,455 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"sogoms.com/internal/admin"
|
||||
)
|
||||
|
||||
// HandleTwoFAPage affiche la page de vérification 2FA.
|
||||
func (s *AdminServer) HandleTwoFAPage(w http.ResponseWriter, r *http.Request) {
|
||||
// Récupérer la session (doit être pending)
|
||||
session, err := s.sessions.GetSessionFromRequest(r)
|
||||
if err != nil || session == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que la session est en attente de 2FA
|
||||
if !session.TwoFAPending {
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Title": "Vérification 2FA",
|
||||
"CSRFToken": session.CSRFToken,
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
"Username": session.Username,
|
||||
}
|
||||
|
||||
s.render(w, "2fa_verify.html", data)
|
||||
}
|
||||
|
||||
// HandleTwoFAVerify valide le code TOTP ou le code de secours.
|
||||
func (s *AdminServer) HandleTwoFAVerify(w http.ResponseWriter, r *http.Request) {
|
||||
ip := getClientIP(r)
|
||||
|
||||
// Rate limiting
|
||||
if !s.rateLimiter.Allow(ip) {
|
||||
http.Redirect(w, r, "/admin/2fa/verify?error=Trop+de+tentatives", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer la session
|
||||
session, err := s.sessions.GetSessionFromRequest(r)
|
||||
if err != nil || session == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if !session.TwoFAPending {
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/admin/2fa/verify?error=Formulaire+invalide", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier le CSRF token
|
||||
if r.FormValue("csrf_token") != session.CSRFToken {
|
||||
http.Redirect(w, r, "/admin/2fa/verify?error=Token+CSRF+invalide", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
s.rateLimiter.Record(ip)
|
||||
|
||||
// Récupérer l'utilisateur
|
||||
user := s.adminCfg.GetUser(session.Username)
|
||||
if user == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(r.FormValue("code"))
|
||||
useBackup := r.FormValue("use_backup") == "true"
|
||||
backupCode := strings.TrimSpace(r.FormValue("backup_code"))
|
||||
|
||||
var verified bool
|
||||
|
||||
if useBackup && backupCode != "" {
|
||||
// Vérifier le code de secours
|
||||
index := VerifyBackupCode(backupCode, user.BackupCodes)
|
||||
if index >= 0 {
|
||||
verified = true
|
||||
// Supprimer le code utilisé
|
||||
user.BackupCodes = RemoveBackupCode(user.BackupCodes, index)
|
||||
// TODO: sauvegarder la config mise à jour
|
||||
log.Printf("[admin] 2FA backup code used by %s, %d remaining", session.Username, len(user.BackupCodes))
|
||||
}
|
||||
} else if code != "" {
|
||||
// Vérifier le code TOTP
|
||||
if ValidateTOTPCode(user.TwoFASecret, code) {
|
||||
verified = true
|
||||
}
|
||||
}
|
||||
|
||||
if !verified {
|
||||
s.audit.LogAction(session.Username, "2fa_failed", "", map[string]any{
|
||||
"ip": ip,
|
||||
"use_backup": useBackup,
|
||||
})
|
||||
http.Redirect(w, r, "/admin/2fa/verify?error=Code+invalide", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// 2FA validé - compléter la session
|
||||
s.sessions.CompleteTwoFA(session.ID)
|
||||
|
||||
// Mettre à jour le cookie avec la nouvelle expiration
|
||||
session, _ = s.sessions.Get(session.ID)
|
||||
s.sessions.SetCookie(w, session)
|
||||
|
||||
// Log succès
|
||||
s.audit.LogAction(session.Username, "2fa_verified", "", map[string]any{
|
||||
"ip": ip,
|
||||
"use_backup": useBackup,
|
||||
})
|
||||
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleTwoFASetupPage affiche la page de configuration 2FA.
|
||||
func (s *AdminServer) HandleTwoFASetupPage(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Si 2FA déjà activé, rediriger vers les paramètres
|
||||
if user.TwoFAEnabled {
|
||||
http.Redirect(w, r, "/admin/security?info=2FA+déjà+activé", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Générer un nouveau secret TOTP
|
||||
key, err := GenerateTOTPSecret(s.adminCfg.TwoFA.IssuerName, user.Username)
|
||||
if err != nil {
|
||||
log.Printf("[admin] generate TOTP secret error: %v", err)
|
||||
http.Error(w, "Erreur génération secret", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Générer le QR code
|
||||
qrDataURL, err := GenerateQRCodeDataURL(key)
|
||||
if err != nil {
|
||||
log.Printf("[admin] generate QR code error: %v", err)
|
||||
http.Error(w, "Erreur génération QR code", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Générer les codes de secours
|
||||
backupCodes, err := GenerateBackupCodes(10)
|
||||
if err != nil {
|
||||
log.Printf("[admin] generate backup codes error: %v", err)
|
||||
http.Error(w, "Erreur génération codes secours", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Title": "Activer 2FA",
|
||||
"User": user,
|
||||
"Session": session,
|
||||
"CSRFToken": session.CSRFToken,
|
||||
"QRCodeDataURL": qrDataURL,
|
||||
"TwoFASecret": key.Secret(),
|
||||
"BackupCodes": backupCodes,
|
||||
"BackupCodesFormatted": FormatBackupCodes(backupCodes),
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
}
|
||||
|
||||
s.render(w, "2fa_setup.html", data)
|
||||
}
|
||||
|
||||
// HandleTwoFASetupConfirm confirme l'activation du 2FA.
|
||||
func (s *AdminServer) HandleTwoFASetupConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/admin/2fa/setup?error=Formulaire+invalide", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier le CSRF token
|
||||
if r.FormValue("csrf_token") != session.CSRFToken {
|
||||
http.Redirect(w, r, "/admin/2fa/setup?error=Token+CSRF+invalide", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
secret := r.FormValue("temp_secret")
|
||||
verifyCode := strings.TrimSpace(r.FormValue("verify_code"))
|
||||
backupCodesRaw := r.FormValue("backup_codes") // JSON array
|
||||
|
||||
// Valider le code TOTP
|
||||
if !ValidateTOTPCode(secret, verifyCode) {
|
||||
http.Redirect(w, r, "/admin/2fa/setup?error=Code+invalide", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Parser et hasher les backup codes
|
||||
backupCodes := strings.Split(backupCodesRaw, ",")
|
||||
hashedCodes, err := HashBackupCodes(backupCodes)
|
||||
if err != nil {
|
||||
log.Printf("[admin] hash backup codes error: %v", err)
|
||||
http.Error(w, "Erreur hash codes secours", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Mettre à jour l'utilisateur
|
||||
user.TwoFAEnabled = true
|
||||
user.TwoFASecret = secret
|
||||
user.BackupCodes = hashedCodes
|
||||
|
||||
// Sauvegarder la configuration
|
||||
if err := s.saveAdminConfig(); err != nil {
|
||||
log.Printf("[admin] save admin config error: %v", err)
|
||||
http.Error(w, "Erreur sauvegarde configuration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Log l'action
|
||||
s.audit.LogAction(user.Username, "2fa_enabled", "", map[string]any{
|
||||
"ip": getClientIP(r),
|
||||
})
|
||||
|
||||
http.Redirect(w, r, "/admin/?flash=success&msg=2FA+activé+avec+succès", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleTwoFADisable désactive le 2FA.
|
||||
func (s *AdminServer) HandleTwoFADisable(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier le CSRF token
|
||||
if r.FormValue("csrf_token") != session.CSRFToken {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier si le rôle exige 2FA
|
||||
if user.NeedsTwoFA(&s.adminCfg.TwoFA) && !user.TwoFAEnabled {
|
||||
http.Error(w, "2FA obligatoire pour votre rôle", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Demander le mot de passe pour confirmer
|
||||
password := r.FormValue("password")
|
||||
if !verifyUserPassword(user, password) {
|
||||
http.Redirect(w, r, "/admin/security?error=Mot+de+passe+incorrect", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Désactiver 2FA
|
||||
user.TwoFAEnabled = false
|
||||
user.TwoFASecret = ""
|
||||
user.BackupCodes = nil
|
||||
|
||||
// Sauvegarder la configuration
|
||||
if err := s.saveAdminConfig(); err != nil {
|
||||
log.Printf("[admin] save admin config error: %v", err)
|
||||
http.Error(w, "Erreur sauvegarde configuration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Log l'action
|
||||
s.audit.LogAction(user.Username, "2fa_disabled", "", map[string]any{
|
||||
"ip": getClientIP(r),
|
||||
})
|
||||
|
||||
http.Redirect(w, r, "/admin/?flash=success&msg=2FA+désactivé", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleSecurityPage affiche la page de sécurité (2FA settings).
|
||||
func (s *AdminServer) HandleSecurityPage(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier si 2FA est requis pour ce user
|
||||
twoFARequired := user.NeedsTwoFA(&s.adminCfg.TwoFA)
|
||||
|
||||
data := map[string]any{
|
||||
"Title": "Sécurité",
|
||||
"User": user,
|
||||
"Session": session,
|
||||
"CSRFToken": session.CSRFToken,
|
||||
"IsSuperAdmin": user.IsSuperAdmin(),
|
||||
"TwoFAEnabled": user.TwoFAEnabled,
|
||||
"TwoFARequired": twoFARequired,
|
||||
"BackupCount": len(user.BackupCodes),
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
"Info": r.URL.Query().Get("info"),
|
||||
}
|
||||
|
||||
s.render(w, "security.html", data)
|
||||
}
|
||||
|
||||
// saveAdminConfig sauvegarde la configuration admin dans le fichier YAML.
|
||||
func (s *AdminServer) saveAdminConfig() error {
|
||||
return admin.SaveAdminConfig(s.adminCfg, "/secrets/admin_users.yaml")
|
||||
}
|
||||
|
||||
// verifyUserPassword vérifie le mot de passe d'un utilisateur.
|
||||
func verifyUserPassword(user *admin.AdminUser, password string) bool {
|
||||
if user == nil || password == "" {
|
||||
return false
|
||||
}
|
||||
// Utiliser bcrypt pour vérifier
|
||||
return checkPasswordHash(password, user.PasswordHash)
|
||||
}
|
||||
|
||||
// checkPasswordHash vérifie un mot de passe contre son hash bcrypt.
|
||||
func checkPasswordHash(password, hash string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// HandleUsersPage affiche la liste des utilisateurs admin (super_admin only).
|
||||
func (s *AdminServer) HandleUsersPage(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Seul super_admin peut voir cette page
|
||||
if !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Construire la liste des utilisateurs
|
||||
type UserInfo struct {
|
||||
Username string
|
||||
Email string
|
||||
Role string
|
||||
TwoFAEnabled bool
|
||||
BackupCount int
|
||||
}
|
||||
|
||||
users := make([]UserInfo, 0, len(s.adminCfg.Users))
|
||||
for _, u := range s.adminCfg.Users {
|
||||
users = append(users, UserInfo{
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Role: u.Role,
|
||||
TwoFAEnabled: u.TwoFAEnabled,
|
||||
BackupCount: len(u.BackupCodes),
|
||||
})
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Title": "Utilisateurs",
|
||||
"User": user,
|
||||
"Session": session,
|
||||
"CSRFToken": session.CSRFToken,
|
||||
"IsSuperAdmin": user.IsSuperAdmin(),
|
||||
"Users": users,
|
||||
"Flash": r.URL.Query().Get("flash"),
|
||||
"FlashMessage": r.URL.Query().Get("msg"),
|
||||
}
|
||||
|
||||
s.render(w, "users.html", data)
|
||||
}
|
||||
|
||||
// HandleReset2FA reset le 2FA d'un utilisateur (super_admin only).
|
||||
func (s *AdminServer) HandleReset2FA(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
currentUser := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || currentUser == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Seul super_admin peut reset le 2FA
|
||||
if !currentUser.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier CSRF
|
||||
if r.FormValue("csrf_token") != session.CSRFToken {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
targetUsername := r.FormValue("username")
|
||||
if targetUsername == "" {
|
||||
http.Redirect(w, r, "/admin/users?flash=error&msg=Username+manquant", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Trouver l'utilisateur cible
|
||||
targetUser := s.adminCfg.GetUser(targetUsername)
|
||||
if targetUser == nil {
|
||||
http.Redirect(w, r, "/admin/users?flash=error&msg=Utilisateur+non+trouvé", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Reset le 2FA
|
||||
targetUser.TwoFAEnabled = false
|
||||
targetUser.TwoFASecret = ""
|
||||
targetUser.BackupCodes = nil
|
||||
|
||||
// Sauvegarder
|
||||
if err := s.saveAdminConfig(); err != nil {
|
||||
log.Printf("[admin] save admin config error: %v", err)
|
||||
http.Redirect(w, r, "/admin/users?flash=error&msg=Erreur+sauvegarde", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Log l'action
|
||||
s.audit.LogAction(currentUser.Username, "2fa_reset", targetUsername, map[string]any{
|
||||
"ip": getClientIP(r),
|
||||
"target_user": targetUsername,
|
||||
})
|
||||
|
||||
log.Printf("[admin] 2FA reset for user %s by %s", targetUsername, currentUser.Username)
|
||||
|
||||
http.Redirect(w, r, "/admin/users?flash=success&msg=2FA+réinitialisé+pour+"+targetUsername, http.StatusSeeOther)
|
||||
}
|
||||
659
cmd/sogoms/admin/handlers_infra.go
Normal file
659
cmd/sogoms/admin/handlers_infra.go
Normal file
@@ -0,0 +1,659 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"sogoms.com/internal/infra"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Servers
|
||||
// ============================================================================
|
||||
|
||||
// HandleInfraPage affiche la page principale de l'infrastructure.
|
||||
func (s *AdminServer) HandleInfraPage(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Seul super_admin peut gérer l'infra
|
||||
if !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les serveurs
|
||||
servers, err := s.infraDB.ListServers()
|
||||
if err != nil {
|
||||
log.Printf("[admin] list servers error: %v", err)
|
||||
servers = []infra.Server{}
|
||||
}
|
||||
|
||||
// Pour chaque serveur, récupérer les containers
|
||||
type ServerView struct {
|
||||
infra.Server
|
||||
Containers []infra.Container
|
||||
ContainerCount int
|
||||
NginxCount int
|
||||
}
|
||||
|
||||
serverViews := make([]ServerView, 0, len(servers))
|
||||
for _, srv := range servers {
|
||||
containers, _ := s.infraDB.ListContainersByServer(srv.ID)
|
||||
nginxConfigs, _ := s.infraDB.ListNginxConfigsByServer(srv.ID)
|
||||
|
||||
serverViews = append(serverViews, ServerView{
|
||||
Server: srv,
|
||||
Containers: containers,
|
||||
ContainerCount: len(containers),
|
||||
NginxCount: len(nginxConfigs),
|
||||
})
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Title": "Infrastructure",
|
||||
"User": user,
|
||||
"Session": session,
|
||||
"CSRFToken": session.CSRFToken,
|
||||
"IsSuperAdmin": user.IsSuperAdmin(),
|
||||
"Servers": serverViews,
|
||||
}
|
||||
|
||||
// Flash message
|
||||
if flash := r.URL.Query().Get("flash"); flash != "" {
|
||||
data["Flash"] = flash
|
||||
data["FlashMessage"] = r.URL.Query().Get("msg")
|
||||
}
|
||||
|
||||
s.render(w, "infra.html", data)
|
||||
}
|
||||
|
||||
// HandleServerNewPage affiche le formulaire d'ajout de serveur.
|
||||
func (s *AdminServer) HandleServerNewPage(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Title": "Nouveau Serveur",
|
||||
"User": user,
|
||||
"Session": session,
|
||||
"CSRFToken": session.CSRFToken,
|
||||
"IsSuperAdmin": true,
|
||||
}
|
||||
|
||||
s.render(w, "server_new.html", data)
|
||||
}
|
||||
|
||||
// HandleServerCreate crée un nouveau serveur.
|
||||
func (s *AdminServer) HandleServerCreate(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Parser les valeurs
|
||||
sshPort, _ := strconv.Atoi(r.FormValue("ssh_port"))
|
||||
if sshPort == 0 {
|
||||
sshPort = 22
|
||||
}
|
||||
|
||||
server := &infra.Server{
|
||||
Name: r.FormValue("name"),
|
||||
Host: r.FormValue("host"),
|
||||
VpnIP: r.FormValue("vpn_ip"),
|
||||
SSHPort: sshPort,
|
||||
SSHUser: r.FormValue("ssh_user"),
|
||||
SSHKeyFile: r.FormValue("ssh_key_file"),
|
||||
HasIncus: r.FormValue("has_incus") == "on",
|
||||
HasNginx: r.FormValue("has_nginx") == "on",
|
||||
Status: infra.ServerStatusUnknown,
|
||||
}
|
||||
|
||||
if server.Name == "" || server.Host == "" {
|
||||
http.Error(w, "Nom et host requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.infraDB.CreateServer(server); err != nil {
|
||||
log.Printf("[admin] create server error: %v", err)
|
||||
http.Error(w, "Erreur création serveur", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.audit.LogAction(user.Username, "create_server", server.Name, map[string]any{
|
||||
"ip": getClientIP(r),
|
||||
"host": server.Host,
|
||||
})
|
||||
|
||||
http.Redirect(w, r, "/admin/infra?flash=success&msg=Serveur+créé", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleServerDetailPage affiche les détails d'un serveur.
|
||||
func (s *AdminServer) HandleServerDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid server ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
server, err := s.infraDB.GetServer(serverID)
|
||||
if err != nil || server == nil {
|
||||
http.Error(w, "Server not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les containers et nginx configs
|
||||
containers, _ := s.infraDB.ListContainersByServer(serverID)
|
||||
nginxConfigs, _ := s.infraDB.ListNginxConfigsByServer(serverID)
|
||||
|
||||
data := map[string]any{
|
||||
"Title": server.Name,
|
||||
"User": user,
|
||||
"Session": session,
|
||||
"CSRFToken": session.CSRFToken,
|
||||
"IsSuperAdmin": true,
|
||||
"Server": server,
|
||||
"Containers": containers,
|
||||
"NginxConfigs": nginxConfigs,
|
||||
}
|
||||
|
||||
// Flash message
|
||||
if flash := r.URL.Query().Get("flash"); flash != "" {
|
||||
data["Flash"] = flash
|
||||
data["FlashMessage"] = r.URL.Query().Get("msg")
|
||||
}
|
||||
|
||||
s.render(w, "server_detail.html", data)
|
||||
}
|
||||
|
||||
// HandleServerDelete supprime un serveur.
|
||||
func (s *AdminServer) HandleServerDelete(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid server ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
server, _ := s.infraDB.GetServer(serverID)
|
||||
if server == nil {
|
||||
http.Error(w, "Server not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Déconnecter SSH si connecté
|
||||
s.sshPool.Disconnect(serverID)
|
||||
|
||||
if err := s.infraDB.DeleteServer(serverID); err != nil {
|
||||
log.Printf("[admin] delete server error: %v", err)
|
||||
http.Error(w, "Erreur suppression", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.audit.LogAction(user.Username, "delete_server", server.Name, nil)
|
||||
|
||||
http.Redirect(w, r, "/admin/infra?flash=success&msg=Serveur+supprimé", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleServerTestSSH teste la connexion SSH à un serveur.
|
||||
func (s *AdminServer) HandleServerTestSSH(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid server ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
server, err := s.infraDB.GetServer(serverID)
|
||||
if err != nil || server == nil {
|
||||
http.Error(w, "Server not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Tester la connexion SSH
|
||||
client, err := s.sshPool.Connect(server)
|
||||
if err != nil {
|
||||
s.infraDB.UpdateServerStatus(serverID, infra.ServerStatusOffline)
|
||||
msg := fmt.Sprintf("Erreur SSH: %v", err)
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Exécuter une commande de test
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := client.ExecSimple(ctx, "hostname && uptime")
|
||||
if err != nil {
|
||||
s.infraDB.UpdateServerStatus(serverID, infra.ServerStatusOffline)
|
||||
msg := fmt.Sprintf("Erreur commande: %v", err)
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Mise à jour du statut
|
||||
s.infraDB.UpdateServerStatus(serverID, infra.ServerStatusOnline)
|
||||
|
||||
s.audit.LogAction(user.Username, "test_ssh", server.Name, map[string]any{
|
||||
"result": result,
|
||||
})
|
||||
|
||||
msg := fmt.Sprintf("Connexion OK: %s", result)
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=success&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleServerSyncContainers synchronise les containers depuis Incus.
|
||||
func (s *AdminServer) HandleServerSyncContainers(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid server ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
server, err := s.infraDB.GetServer(serverID)
|
||||
if err != nil || server == nil {
|
||||
http.Error(w, "Server not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !server.HasIncus {
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Incus+non+activé", serverID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Connexion SSH
|
||||
client, err := s.sshPool.Connect(server)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Erreur+SSH", serverID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Récupérer les containers Incus
|
||||
incusContainers, err := client.ListIncusContainers(ctx)
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("Erreur Incus: %v", err)
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Synchroniser avec la base
|
||||
synced := 0
|
||||
for _, ic := range incusContainers {
|
||||
// Vérifier si existe déjà
|
||||
existing, _ := s.infraDB.ListContainersByServer(serverID)
|
||||
found := false
|
||||
for _, c := range existing {
|
||||
if c.IncusName == ic.Name {
|
||||
// Mettre à jour le statut
|
||||
status := infra.ContainerStatusUnknown
|
||||
if ic.State == "running" {
|
||||
status = infra.ContainerStatusRunning
|
||||
} else if ic.State == "stopped" {
|
||||
status = infra.ContainerStatusStopped
|
||||
}
|
||||
s.infraDB.UpdateContainerStatus(c.ID, status)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
// Créer le container
|
||||
ip := ""
|
||||
if len(ic.IPv4) > 0 {
|
||||
ip = ic.IPv4[0]
|
||||
}
|
||||
|
||||
status := infra.ContainerStatusUnknown
|
||||
if ic.State == "running" {
|
||||
status = infra.ContainerStatusRunning
|
||||
} else if ic.State == "stopped" {
|
||||
status = infra.ContainerStatusStopped
|
||||
}
|
||||
|
||||
container := &infra.Container{
|
||||
ServerID: serverID,
|
||||
Name: ic.Name,
|
||||
IncusName: ic.Name,
|
||||
IP: ip,
|
||||
Image: ic.Image,
|
||||
Status: status,
|
||||
}
|
||||
if err := s.infraDB.CreateContainer(container); err == nil {
|
||||
synced++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.audit.LogAction(user.Username, "sync_containers", server.Name, map[string]any{
|
||||
"synced": synced,
|
||||
"total": len(incusContainers),
|
||||
})
|
||||
|
||||
msg := fmt.Sprintf("Sync OK: %d nouveaux containers", synced)
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=success&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Containers
|
||||
// ============================================================================
|
||||
|
||||
// HandleContainerAction effectue une action sur un container (start/stop/restart).
|
||||
func (s *AdminServer) HandleContainerAction(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
containerID, err := strconv.ParseInt(r.PathValue("containerID"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid container ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
action := r.FormValue("action")
|
||||
if action != "start" && action != "stop" && action != "restart" {
|
||||
http.Error(w, "Invalid action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
container, err := s.infraDB.GetContainer(containerID)
|
||||
if err != nil || container == nil {
|
||||
http.Error(w, "Container not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
server, err := s.infraDB.GetServer(container.ServerID)
|
||||
if err != nil || server == nil {
|
||||
http.Error(w, "Server not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connexion SSH
|
||||
client, err := s.sshPool.Connect(server)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Erreur+SSH", server.ID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Exécuter l'action
|
||||
switch action {
|
||||
case "start":
|
||||
err = client.StartIncusContainer(ctx, container.IncusName)
|
||||
if err == nil {
|
||||
s.infraDB.UpdateContainerStatus(containerID, infra.ContainerStatusRunning)
|
||||
}
|
||||
case "stop":
|
||||
err = client.StopIncusContainer(ctx, container.IncusName)
|
||||
if err == nil {
|
||||
s.infraDB.UpdateContainerStatus(containerID, infra.ContainerStatusStopped)
|
||||
}
|
||||
case "restart":
|
||||
err = client.RestartIncusContainer(ctx, container.IncusName)
|
||||
if err == nil {
|
||||
s.infraDB.UpdateContainerStatus(containerID, infra.ContainerStatusRunning)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("Erreur %s: %v", action, err)
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", server.ID, msg), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
s.audit.LogAction(user.Username, "container_"+action, container.Name, map[string]any{
|
||||
"server": server.Name,
|
||||
})
|
||||
|
||||
msg := fmt.Sprintf("Container %s: %s OK", container.Name, action)
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=success&msg=%s", server.ID, msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Nginx
|
||||
// ============================================================================
|
||||
|
||||
// HandleNginxReload recharge Nginx sur un serveur.
|
||||
func (s *AdminServer) HandleNginxReload(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid server ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
server, err := s.infraDB.GetServer(serverID)
|
||||
if err != nil || server == nil {
|
||||
http.Error(w, "Server not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !server.HasNginx {
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Nginx+non+activé", serverID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := s.sshPool.Connect(server)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Erreur+SSH", serverID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := client.ReloadNginx(ctx); err != nil {
|
||||
msg := fmt.Sprintf("Erreur reload: %v", err)
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
s.audit.LogAction(user.Username, "nginx_reload", server.Name, nil)
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=success&msg=Nginx+rechargé", serverID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleNginxSyncSites synchronise les sites Nginx depuis le serveur.
|
||||
func (s *AdminServer) HandleNginxSyncSites(w http.ResponseWriter, r *http.Request) {
|
||||
session := GetSessionFromContext(r.Context())
|
||||
user := GetUserFromContext(r.Context())
|
||||
|
||||
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid server ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
server, err := s.infraDB.GetServer(serverID)
|
||||
if err != nil || server == nil {
|
||||
http.Error(w, "Server not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !server.HasNginx {
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Nginx+non+activé", serverID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := s.sshPool.Connect(server)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Erreur+SSH", serverID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sites, err := client.ListNginxSites(ctx)
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("Erreur liste sites: %v", err)
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
synced := 0
|
||||
for _, site := range sites {
|
||||
// Vérifier si existe déjà
|
||||
existing, _ := s.infraDB.GetNginxConfigByDomain(serverID, site.Name)
|
||||
if existing != nil {
|
||||
// Mettre à jour le statut
|
||||
status := infra.NginxConfigStatusInactive
|
||||
if site.Enabled {
|
||||
status = infra.NginxConfigStatusActive
|
||||
}
|
||||
existing.Status = status
|
||||
s.infraDB.UpdateNginxConfig(existing)
|
||||
continue
|
||||
}
|
||||
|
||||
// Créer la config
|
||||
status := infra.NginxConfigStatusInactive
|
||||
if site.Enabled {
|
||||
status = infra.NginxConfigStatusActive
|
||||
}
|
||||
|
||||
config := &infra.NginxConfig{
|
||||
ServerID: serverID,
|
||||
Domain: site.Name,
|
||||
Type: infra.NginxTypeProxy,
|
||||
Status: status,
|
||||
}
|
||||
if err := s.infraDB.CreateNginxConfig(config); err == nil {
|
||||
synced++
|
||||
}
|
||||
}
|
||||
|
||||
s.audit.LogAction(user.Username, "sync_nginx", server.Name, map[string]any{
|
||||
"synced": synced,
|
||||
"total": len(sites),
|
||||
})
|
||||
|
||||
msg := fmt.Sprintf("Sync OK: %d nouveaux sites", synced)
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=success&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API (htmx)
|
||||
// ============================================================================
|
||||
|
||||
// HandleAPIInfraStatus retourne le statut de l'infrastructure (partial htmx).
|
||||
func (s *AdminServer) HandleAPIInfraStatus(w http.ResponseWriter, r *http.Request) {
|
||||
user := GetUserFromContext(r.Context())
|
||||
if user == nil || !user.IsSuperAdmin() {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
servers, _ := s.infraDB.ListServers()
|
||||
|
||||
// Compter les statuts
|
||||
online := 0
|
||||
offline := 0
|
||||
for _, srv := range servers {
|
||||
if srv.Status == infra.ServerStatusOnline {
|
||||
online++
|
||||
} else if srv.Status == infra.ServerStatusOffline {
|
||||
offline++
|
||||
}
|
||||
}
|
||||
|
||||
containers, _ := s.infraDB.ListAllContainers()
|
||||
running := 0
|
||||
stopped := 0
|
||||
for _, c := range containers {
|
||||
if c.Status == infra.ContainerStatusRunning {
|
||||
running++
|
||||
} else if c.Status == infra.ContainerStatusStopped {
|
||||
stopped++
|
||||
}
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"ServerCount": len(servers),
|
||||
"ServersOnline": online,
|
||||
"ServersOffline": offline,
|
||||
"ContainerCount": len(containers),
|
||||
"Running": running,
|
||||
"Stopped": stopped,
|
||||
}
|
||||
|
||||
s.render(w, "partials/infra_status.html", data)
|
||||
}
|
||||
@@ -13,9 +13,11 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"sogoms.com/internal/admin"
|
||||
"sogoms.com/internal/config"
|
||||
"sogoms.com/internal/infra"
|
||||
"sogoms.com/internal/protocol"
|
||||
"sogoms.com/internal/version"
|
||||
)
|
||||
@@ -30,11 +32,10 @@ var (
|
||||
port = flag.Int("port", 9000, "HTTP server port")
|
||||
configDir = flag.String("config", "/config", "Configuration directory")
|
||||
secretsDir = flag.String("secrets", "/secrets", "Secrets directory")
|
||||
templatesDir = flag.String("templates", "", "Templates directory (empty = use embedded)")
|
||||
devMode = flag.Bool("dev", false, "Dev mode: reload templates on each request")
|
||||
dbSocket = flag.String("db-socket", "/run/sogoms-db.1.sock", "DB service socket")
|
||||
logsSocket = flag.String("logs-socket", "/run/sogoms-logs.1.sock", "Logs service socket")
|
||||
cronSocket = flag.String("cron-socket", "/run/sogoms-cron.1.sock", "Cron service socket")
|
||||
infraDBPath = flag.String("infra-db", "/data/infra.db", "Infrastructure SQLite database path")
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -81,33 +82,38 @@ func main() {
|
||||
// Audit logger
|
||||
audit := admin.NewAuditLogger(services.Logs)
|
||||
|
||||
// Charger les templates
|
||||
templates, err := loadTemplates(*templatesDir)
|
||||
// Infrastructure DB
|
||||
infraDB, err := infra.Open(*infraDBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open infra db: %v", err)
|
||||
}
|
||||
defer infraDB.Close()
|
||||
log.Printf("[admin] infra db opened: %s", *infraDBPath)
|
||||
|
||||
// SSH Pool
|
||||
sshPool := infra.NewSSHPool(30 * time.Second)
|
||||
defer sshPool.CloseAll()
|
||||
|
||||
// Charger les templates (embedded)
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
log.Fatalf("load templates: %v", err)
|
||||
}
|
||||
if *templatesDir != "" {
|
||||
log.Printf("[admin] templates loaded from filesystem: %s", *templatesDir)
|
||||
if *devMode {
|
||||
log.Printf("[admin] dev mode: templates will reload on each request")
|
||||
}
|
||||
} else {
|
||||
log.Printf("[admin] templates loaded from embedded")
|
||||
}
|
||||
log.Printf("[admin] templates loaded (embedded)")
|
||||
|
||||
// Créer le serveur
|
||||
server := &AdminServer{
|
||||
adminCfg: adminCfg,
|
||||
registry: registry,
|
||||
sessions: sessions,
|
||||
version: version.Version,
|
||||
rateLimiter: rateLimiter,
|
||||
perms: perms,
|
||||
audit: audit,
|
||||
services: services,
|
||||
templates: templates,
|
||||
templatesDir: *templatesDir,
|
||||
devMode: *devMode,
|
||||
adminCfg: adminCfg,
|
||||
registry: registry,
|
||||
sessions: sessions,
|
||||
version: version.Version,
|
||||
rateLimiter: rateLimiter,
|
||||
perms: perms,
|
||||
audit: audit,
|
||||
services: services,
|
||||
templates: templates,
|
||||
infraDB: infraDB,
|
||||
sshPool: sshPool,
|
||||
}
|
||||
|
||||
// Router
|
||||
@@ -125,6 +131,19 @@ func main() {
|
||||
mux.HandleFunc("GET /admin/login", server.HandleLoginPage)
|
||||
mux.HandleFunc("POST /admin/login", server.HandleLogin)
|
||||
|
||||
// Routes 2FA (session requise mais pas forcément complète)
|
||||
mux.HandleFunc("GET /admin/2fa/verify", server.HandleTwoFAPage)
|
||||
mux.HandleFunc("POST /admin/2fa/verify", server.HandleTwoFAVerify)
|
||||
mux.HandleFunc("GET /admin/2fa/setup", TwoFASetupMiddleware(sessions, adminCfg, server.HandleTwoFASetupPage))
|
||||
mux.HandleFunc("POST /admin/2fa/setup", TwoFASetupMiddleware(sessions, adminCfg,
|
||||
CSRFMiddleware(sessions, server.HandleTwoFASetupConfirm)))
|
||||
mux.HandleFunc("POST /admin/2fa/disable", AuthMiddleware(sessions, adminCfg,
|
||||
CSRFMiddleware(sessions, server.HandleTwoFADisable)))
|
||||
mux.HandleFunc("GET /admin/security", AuthMiddleware(sessions, adminCfg, server.HandleSecurityPage))
|
||||
mux.HandleFunc("GET /admin/users", AuthMiddleware(sessions, adminCfg, server.HandleUsersPage))
|
||||
mux.HandleFunc("POST /admin/users/reset-2fa", AuthMiddleware(sessions, adminCfg,
|
||||
CSRFMiddleware(sessions, server.HandleReset2FA)))
|
||||
|
||||
// Routes protégées
|
||||
mux.HandleFunc("GET /admin/{$}", AuthMiddleware(sessions, adminCfg, server.HandleDashboard))
|
||||
mux.HandleFunc("GET /admin/apps", AuthMiddleware(sessions, adminCfg, server.HandleAppsPage))
|
||||
@@ -141,6 +160,26 @@ func main() {
|
||||
mux.HandleFunc("GET /admin/api/apps", AuthMiddleware(sessions, adminCfg, server.HandleAPIApps))
|
||||
mux.HandleFunc("GET /admin/api/services/health", AuthMiddleware(sessions, adminCfg, server.HandleAPIServicesHealth))
|
||||
mux.HandleFunc("GET /admin/api/cron/jobs", AuthMiddleware(sessions, adminCfg, server.HandleAPICronJobs))
|
||||
mux.HandleFunc("GET /admin/api/infra/status", AuthMiddleware(sessions, adminCfg, server.HandleAPIInfraStatus))
|
||||
|
||||
// Routes Infrastructure (super_admin only)
|
||||
mux.HandleFunc("GET /admin/infra", AuthMiddleware(sessions, adminCfg, server.HandleInfraPage))
|
||||
mux.HandleFunc("GET /admin/infra/servers/new", AuthMiddleware(sessions, adminCfg, server.HandleServerNewPage))
|
||||
mux.HandleFunc("POST /admin/infra/servers/new", AuthMiddleware(sessions, adminCfg,
|
||||
CSRFMiddleware(sessions, server.HandleServerCreate)))
|
||||
mux.HandleFunc("GET /admin/infra/servers/{serverID}", AuthMiddleware(sessions, adminCfg, server.HandleServerDetailPage))
|
||||
mux.HandleFunc("POST /admin/infra/servers/{serverID}/delete", AuthMiddleware(sessions, adminCfg,
|
||||
CSRFMiddleware(sessions, server.HandleServerDelete)))
|
||||
mux.HandleFunc("POST /admin/infra/servers/{serverID}/test-ssh", AuthMiddleware(sessions, adminCfg,
|
||||
CSRFMiddleware(sessions, server.HandleServerTestSSH)))
|
||||
mux.HandleFunc("POST /admin/infra/servers/{serverID}/sync-containers", AuthMiddleware(sessions, adminCfg,
|
||||
CSRFMiddleware(sessions, server.HandleServerSyncContainers)))
|
||||
mux.HandleFunc("POST /admin/infra/servers/{serverID}/sync-nginx", AuthMiddleware(sessions, adminCfg,
|
||||
CSRFMiddleware(sessions, server.HandleNginxSyncSites)))
|
||||
mux.HandleFunc("POST /admin/infra/servers/{serverID}/nginx-reload", AuthMiddleware(sessions, adminCfg,
|
||||
CSRFMiddleware(sessions, server.HandleNginxReload)))
|
||||
mux.HandleFunc("POST /admin/infra/containers/{containerID}/action", AuthMiddleware(sessions, adminCfg,
|
||||
CSRFMiddleware(sessions, server.HandleContainerAction)))
|
||||
|
||||
// Handler avec logging
|
||||
handler := LoggingMiddleware(mux)
|
||||
@@ -168,29 +207,17 @@ func main() {
|
||||
httpServer.Close()
|
||||
}
|
||||
|
||||
// loadTemplates charge les templates depuis le filesystem ou embedded.
|
||||
func loadTemplates(dir string) (*template.Template, error) {
|
||||
// loadTemplates charge les templates depuis embedded.
|
||||
func loadTemplates() (*template.Template, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"safe": func(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
},
|
||||
"safeURL": func(s string) template.URL {
|
||||
return template.URL(s)
|
||||
},
|
||||
}
|
||||
|
||||
if dir != "" {
|
||||
// Charger depuis le filesystem
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseGlob(dir + "/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Charger les partials
|
||||
tmpl, err = tmpl.ParseGlob(dir + "/partials/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
// Charger depuis embedded
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html", "templates/partials/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -54,6 +54,12 @@ func AuthMiddleware(sessions *SessionStore, adminCfg *admin.AdminConfig, next ht
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier si la session est en attente de 2FA
|
||||
if session.TwoFAPending {
|
||||
http.Redirect(w, r, "/admin/2fa/verify", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Prolonger la session (sliding expiration)
|
||||
sessions.Refresh(session.ID)
|
||||
|
||||
@@ -65,6 +71,36 @@ func AuthMiddleware(sessions *SessionStore, adminCfg *admin.AdminConfig, next ht
|
||||
}
|
||||
}
|
||||
|
||||
// TwoFASetupMiddleware permet l'accès à la page de setup 2FA même sans 2FA vérifié.
|
||||
// Utilisé uniquement pour les routes de configuration 2FA.
|
||||
func TwoFASetupMiddleware(sessions *SessionStore, adminCfg *admin.AdminConfig, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := sessions.GetSessionFromRequest(r)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur
|
||||
user := adminCfg.GetUser(session.Username)
|
||||
if user == nil {
|
||||
sessions.Delete(session.ID)
|
||||
sessions.ClearCookie(w)
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Prolonger la session
|
||||
sessions.Refresh(session.ID)
|
||||
|
||||
// Injecter dans le contexte
|
||||
ctx := context.WithValue(r.Context(), ctxSession, session)
|
||||
ctx = context.WithValue(ctx, ctxUser, user)
|
||||
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
// CSRFMiddleware vérifie le token CSRF pour les requêtes POST/PUT/DELETE.
|
||||
func CSRFMiddleware(sessions *SessionStore, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -307,6 +307,7 @@ func convertTablesToSchema(tablesRaw map[string]any) map[string]any {
|
||||
}
|
||||
sort.Strings(tableNames)
|
||||
|
||||
// Première passe : créer toutes les tables
|
||||
for _, tableName := range tableNames {
|
||||
tableData, ok := tablesRaw[tableName].(map[string]any)
|
||||
if !ok {
|
||||
@@ -395,6 +396,11 @@ func convertTablesToSchema(tablesRaw map[string]any) map[string]any {
|
||||
table["primary"] = pkStrings
|
||||
}
|
||||
|
||||
// Détecter soft_delete (colonne deleted_at)
|
||||
if hasSoftDelete(tableData) {
|
||||
table["soft_delete"] = true
|
||||
}
|
||||
|
||||
// CRUD par défaut (sauf tables de liaison)
|
||||
if hasUserID(tableData) {
|
||||
table["crud"] = []string{"list", "show", "create", "update", "delete"}
|
||||
@@ -405,6 +411,60 @@ func convertTablesToSchema(tablesRaw map[string]any) map[string]any {
|
||||
tables[tableName] = table
|
||||
}
|
||||
|
||||
// Deuxième passe : détecter cascade sur les tables parent
|
||||
// Une table parent a cascade si elle a soft_delete ET des tables enfants avec soft_delete
|
||||
for parentName, parentTable := range tables {
|
||||
parent, ok := parentTable.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Si la table parent n'a pas soft_delete, pas de cascade
|
||||
if sd, ok := parent["soft_delete"].(bool); !ok || !sd {
|
||||
continue
|
||||
}
|
||||
|
||||
// Chercher si des tables enfants ont une FK vers cette table
|
||||
hasChildWithSoftDelete := false
|
||||
for childName, childTable := range tables {
|
||||
if childName == parentName {
|
||||
continue
|
||||
}
|
||||
child, ok := childTable.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Vérifier si l'enfant a soft_delete
|
||||
childSD, _ := child["soft_delete"].(bool)
|
||||
if !childSD {
|
||||
continue
|
||||
}
|
||||
|
||||
// Vérifier si l'enfant a une FK vers le parent
|
||||
if cols, ok := child["columns"].(map[string]any); ok {
|
||||
for _, colData := range cols {
|
||||
if col, ok := colData.(map[string]any); ok {
|
||||
if fk, ok := col["foreign"].(string); ok {
|
||||
// fk = "table.column"
|
||||
if strings.HasPrefix(fk, parentName+".") {
|
||||
hasChildWithSoftDelete = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasChildWithSoftDelete {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasChildWithSoftDelete {
|
||||
parent["cascade"] = true
|
||||
}
|
||||
}
|
||||
|
||||
return tables
|
||||
}
|
||||
|
||||
@@ -417,6 +477,18 @@ func hasUserID(tableData map[string]any) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// hasSoftDelete vérifie si une table a une colonne deleted_at (TIMESTAMP/DATETIME).
|
||||
func hasSoftDelete(tableData map[string]any) bool {
|
||||
if columnsRaw, ok := tableData["columns"].(map[string]any); ok {
|
||||
if col, ok := columnsRaw["deleted_at"].(map[string]any); ok {
|
||||
if colType, ok := col["type"].(string); ok && colType == "datetime" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdateLoginData met à jour le bloc login_data dans auth.yaml
|
||||
// en se basant sur le schema généré (tables avec filter: owner).
|
||||
func UpdateLoginData(appID string) error {
|
||||
@@ -549,6 +621,315 @@ func UpdateLoginData(appID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateRoutesFromSchema génère les routes CRUD dans app.yaml basées sur schema.yaml.
|
||||
func GenerateRoutesFromSchema(appID string) error {
|
||||
// 1. Lire le schema.yaml
|
||||
schemaPath := filepath.Join("/config", "apps", appID, "schema.yaml")
|
||||
schemaData, err := os.ReadFile(schemaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read schema: %w", err)
|
||||
}
|
||||
|
||||
var schema map[string]any
|
||||
if err := yaml.Unmarshal(schemaData, &schema); err != nil {
|
||||
return fmt.Errorf("parse schema: %w", err)
|
||||
}
|
||||
|
||||
// 2. Lire app.yaml existant
|
||||
appPath := filepath.Join("/config", "apps", appID, "app.yaml")
|
||||
appData, err := os.ReadFile(appPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read app.yaml: %w", err)
|
||||
}
|
||||
|
||||
var appConfig map[string]any
|
||||
if err := yaml.Unmarshal(appData, &appConfig); err != nil {
|
||||
return fmt.Errorf("parse app.yaml: %w", err)
|
||||
}
|
||||
|
||||
// 3. Extraire les tables avec CRUD
|
||||
tablesRaw, ok := schema["tables"].(map[string]any)
|
||||
if !ok {
|
||||
return nil // Pas de tables
|
||||
}
|
||||
|
||||
// Trier les tables
|
||||
tableNames := make([]string, 0, len(tablesRaw))
|
||||
for name := range tablesRaw {
|
||||
tableNames = append(tableNames, name)
|
||||
}
|
||||
sort.Strings(tableNames)
|
||||
|
||||
// 4. Générer les routes
|
||||
routes := []map[string]any{
|
||||
// Routes auth par défaut
|
||||
{"path": "/auth/register", "method": "POST", "scenario": appID + "/auth/register", "auth": false},
|
||||
{"path": "/auth/login", "method": "POST", "scenario": appID + "/auth/login", "auth": false},
|
||||
{"path": "/auth/logout", "method": "POST", "scenario": appID + "/auth/logout"},
|
||||
{"path": "/auth/me", "method": "GET", "scenario": appID + "/auth/me"},
|
||||
}
|
||||
|
||||
for _, tableName := range tableNames {
|
||||
tableRaw := tablesRaw[tableName]
|
||||
table, ok := tableRaw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Vérifier si CRUD est défini
|
||||
crudRaw, ok := table["crud"].([]any)
|
||||
if !ok || len(crudRaw) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Convertir en map pour lookup rapide
|
||||
crudOps := make(map[string]bool)
|
||||
for _, op := range crudRaw {
|
||||
if opStr, ok := op.(string); ok {
|
||||
crudOps[opStr] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Générer les routes pour cette table
|
||||
if crudOps["list"] {
|
||||
routes = append(routes, map[string]any{
|
||||
"path": "/" + tableName,
|
||||
"method": "GET",
|
||||
"scenario": appID + "/" + tableName + "/list",
|
||||
})
|
||||
}
|
||||
if crudOps["create"] {
|
||||
routes = append(routes, map[string]any{
|
||||
"path": "/" + tableName,
|
||||
"method": "POST",
|
||||
"scenario": appID + "/" + tableName + "/create",
|
||||
})
|
||||
}
|
||||
if crudOps["show"] {
|
||||
routes = append(routes, map[string]any{
|
||||
"path": "/" + tableName + "/{id}",
|
||||
"method": "GET",
|
||||
"scenario": appID + "/" + tableName + "/show",
|
||||
})
|
||||
}
|
||||
if crudOps["update"] {
|
||||
routes = append(routes, map[string]any{
|
||||
"path": "/" + tableName + "/{id}",
|
||||
"method": "PUT",
|
||||
"scenario": appID + "/" + tableName + "/update",
|
||||
})
|
||||
}
|
||||
if crudOps["delete"] {
|
||||
routes = append(routes, map[string]any{
|
||||
"path": "/" + tableName + "/{id}",
|
||||
"method": "DELETE",
|
||||
"scenario": appID + "/" + tableName + "/delete",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Mettre à jour app.yaml
|
||||
appConfig["routes"] = routes
|
||||
|
||||
// 6. Réécrire le fichier
|
||||
yamlData, err := yaml.Marshal(appConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal app.yaml: %w", err)
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("# Application %s\n# Routes générées automatiquement depuis schema.yaml\n\n", appID)
|
||||
if err := os.WriteFile(appPath, []byte(header+string(yamlData)), 0644); err != nil {
|
||||
return fmt.Errorf("write app.yaml: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateQueriesFromSchema génère les fichiers queries/*.yaml basés sur schema.yaml.
|
||||
func GenerateQueriesFromSchema(appID string) error {
|
||||
// 1. Lire le schema.yaml
|
||||
schemaPath := filepath.Join("/config", "apps", appID, "schema.yaml")
|
||||
schemaData, err := os.ReadFile(schemaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read schema: %w", err)
|
||||
}
|
||||
|
||||
var schema map[string]any
|
||||
if err := yaml.Unmarshal(schemaData, &schema); err != nil {
|
||||
return fmt.Errorf("parse schema: %w", err)
|
||||
}
|
||||
|
||||
// 2. Créer le dossier queries
|
||||
queriesDir := filepath.Join("/config", "apps", appID, "queries")
|
||||
if err := os.MkdirAll(queriesDir, 0755); err != nil {
|
||||
return fmt.Errorf("create queries dir: %w", err)
|
||||
}
|
||||
|
||||
// 3. Extraire les tables
|
||||
tablesRaw, ok := schema["tables"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
for tableName, tableRaw := range tablesRaw {
|
||||
table, ok := tableRaw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Vérifier si CRUD est défini
|
||||
crudRaw, ok := table["crud"].([]any)
|
||||
if !ok || len(crudRaw) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Générer le fichier queries pour cette table
|
||||
if err := generateTableQueries(queriesDir, tableName, table); err != nil {
|
||||
return fmt.Errorf("generate queries for %s: %w", tableName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateTableQueries génère le fichier queries pour une table.
|
||||
func generateTableQueries(queriesDir, tableName string, table map[string]any) error {
|
||||
columns, ok := table["columns"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collecter les colonnes
|
||||
colNames := make([]string, 0, len(columns))
|
||||
createFields := make([]string, 0, len(columns))
|
||||
updateFields := make([]string, 0, len(columns))
|
||||
hasPosition := false
|
||||
hasUserID := false
|
||||
|
||||
for colName, colRaw := range columns {
|
||||
col, ok := colRaw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
colNames = append(colNames, colName)
|
||||
|
||||
if colName == "position" {
|
||||
hasPosition = true
|
||||
}
|
||||
if colName == "user_id" {
|
||||
hasUserID = true
|
||||
}
|
||||
|
||||
// Exclure les colonnes auto-générées du CREATE
|
||||
isAuto, _ := col["auto"].(bool)
|
||||
isPrimary, _ := col["primary"].(bool)
|
||||
isAutoGenerated := colName == "created_at" || colName == "updated_at" || colName == "deleted_at"
|
||||
|
||||
if !isAuto && !isAutoGenerated {
|
||||
createFields = append(createFields, colName)
|
||||
}
|
||||
|
||||
// Exclure id, user_id et auto-générées de l'UPDATE
|
||||
if !isPrimary && !isAuto && !isAutoGenerated && colName != "user_id" {
|
||||
updateFields = append(updateFields, colName)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(colNames)
|
||||
sort.Strings(createFields)
|
||||
sort.Strings(updateFields)
|
||||
|
||||
// Mettre id en premier dans les colonnes SELECT
|
||||
selectCols := make([]string, 0, len(colNames))
|
||||
for _, name := range colNames {
|
||||
if name == "id" {
|
||||
selectCols = append([]string{"id"}, selectCols...)
|
||||
} else if name != "user_id" { // Exclure user_id du SELECT
|
||||
selectCols = append(selectCols, name)
|
||||
}
|
||||
}
|
||||
|
||||
// Construire le contenu YAML
|
||||
queries := make(map[string]any)
|
||||
|
||||
// LIST
|
||||
listQuery := fmt.Sprintf("SELECT %s\nFROM %s", strings.Join(selectCols, ", "), tableName)
|
||||
orderBy := ""
|
||||
if hasPosition {
|
||||
orderBy = "position ASC"
|
||||
}
|
||||
|
||||
listConfig := map[string]any{
|
||||
"query": listQuery,
|
||||
}
|
||||
if hasUserID {
|
||||
listConfig["filters"] = map[string]string{
|
||||
"default": "user_id = :user_id",
|
||||
"admin": "",
|
||||
}
|
||||
}
|
||||
if orderBy != "" {
|
||||
listConfig["order"] = orderBy
|
||||
}
|
||||
queries["list"] = listConfig
|
||||
|
||||
// SHOW
|
||||
showQuery := fmt.Sprintf("SELECT %s\nFROM %s WHERE id = :id", strings.Join(selectCols, ", "), tableName)
|
||||
showConfig := map[string]any{
|
||||
"query": showQuery,
|
||||
}
|
||||
if hasUserID {
|
||||
showConfig["filters"] = map[string]string{
|
||||
"default": "user_id = :user_id",
|
||||
"admin": "",
|
||||
}
|
||||
}
|
||||
queries["show"] = showConfig
|
||||
|
||||
// CREATE
|
||||
queries["create"] = map[string]any{
|
||||
"table": tableName,
|
||||
"fields": createFields,
|
||||
}
|
||||
|
||||
// UPDATE
|
||||
updateConfig := map[string]any{
|
||||
"table": tableName,
|
||||
"fields": updateFields,
|
||||
}
|
||||
if hasUserID {
|
||||
updateConfig["filters"] = map[string]string{
|
||||
"default": "user_id = :user_id",
|
||||
"admin": "",
|
||||
}
|
||||
}
|
||||
queries["update"] = updateConfig
|
||||
|
||||
// DELETE
|
||||
deleteConfig := map[string]any{
|
||||
"table": tableName,
|
||||
}
|
||||
if hasUserID {
|
||||
deleteConfig["filters"] = map[string]string{
|
||||
"default": "user_id = :user_id",
|
||||
"admin": "",
|
||||
}
|
||||
}
|
||||
queries["delete"] = deleteConfig
|
||||
|
||||
// Sérialiser
|
||||
yamlData, err := yaml.Marshal(queries)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("# Requêtes CRUD %s\n# Généré automatiquement depuis schema.yaml\n\n", tableName)
|
||||
queryFile := filepath.Join(queriesDir, tableName+".yaml")
|
||||
|
||||
return os.WriteFile(queryFile, []byte(header+string(yamlData)), 0644)
|
||||
}
|
||||
|
||||
// ReloadGateway demande à sogoctl de recharger sogoway.
|
||||
func (sp *ServicePool) ReloadGateway() error {
|
||||
conn, err := net.DialTimeout("unix", "/run/sogoctl.sock", 2*time.Second)
|
||||
|
||||
@@ -25,6 +25,9 @@ type Session struct {
|
||||
ExpiresAt time.Time
|
||||
IP string
|
||||
UserAgent string
|
||||
// 2FA
|
||||
TwoFAPending bool // true si en attente de validation 2FA
|
||||
TwoFAVerified bool // true après validation 2FA réussie
|
||||
}
|
||||
|
||||
// IsExpired vérifie si la session a expiré.
|
||||
@@ -78,6 +81,51 @@ func (s *SessionStore) Create(username, role, ip, userAgent string) (*Session, e
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// CreatePending crée une session en attente de validation 2FA (expire en 5 min).
|
||||
func (s *SessionStore) CreatePending(username, role, ip, userAgent string) (*Session, error) {
|
||||
sessionID, err := generateSecureToken(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
csrfToken, err := generateSecureToken(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
session := &Session{
|
||||
ID: sessionID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
CSRFToken: csrfToken,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(5 * time.Minute), // expiration courte pour 2FA
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
TwoFAPending: true,
|
||||
TwoFAVerified: false,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.sessions[sessionID] = session
|
||||
s.mu.Unlock()
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// CompleteTwoFA marque la session comme ayant passé la 2FA.
|
||||
func (s *SessionStore) CompleteTwoFA(sessionID string) {
|
||||
s.mu.Lock()
|
||||
if session, ok := s.sessions[sessionID]; ok {
|
||||
session.TwoFAPending = false
|
||||
session.TwoFAVerified = true
|
||||
// Prolonger l'expiration après validation 2FA
|
||||
session.ExpiresAt = time.Now().Add(time.Duration(s.config.MaxAge) * time.Second)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get récupère une session par son ID.
|
||||
func (s *SessionStore) Get(sessionID string) (*Session, bool) {
|
||||
s.mu.RLock()
|
||||
|
||||
154
cmd/sogoms/admin/templates/2fa_setup.html
Normal file
154
cmd/sogoms/admin/templates/2fa_setup.html
Normal file
@@ -0,0 +1,154 @@
|
||||
{{define "2fa_setup.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Activer 2FA - SOGOMS Admin</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
<style>
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.logo {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.logo span {
|
||||
color: var(--pico-primary);
|
||||
}
|
||||
.error-message {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.success-message {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
color: #16a34a;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.qr-container {
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: var(--pico-border-radius);
|
||||
}
|
||||
.qr-container img {
|
||||
max-width: 200px;
|
||||
}
|
||||
.secret-code {
|
||||
font-family: monospace;
|
||||
font-size: 1.1rem;
|
||||
background: var(--pico-form-element-background-color);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
word-break: break-all;
|
||||
}
|
||||
.backup-codes {
|
||||
font-family: monospace;
|
||||
font-size: 0.95rem;
|
||||
background: var(--pico-form-element-background-color);
|
||||
padding: 1rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.warning {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
margin: 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.step {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.step h3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.code-input {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.5rem;
|
||||
font-family: monospace;
|
||||
max-width: 200px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">SOGO<span>MS</span> Admin</div>
|
||||
|
||||
<h1>Activer l'authentification à deux facteurs</h1>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="error-message">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<div class="step">
|
||||
<h3>Étape 1 : Scanner le QR Code</h3>
|
||||
<p>Scannez ce code avec votre application d'authentification (Google Authenticator, Authy, Microsoft Authenticator...).</p>
|
||||
|
||||
<div class="qr-container">
|
||||
{{if .QRCodeDataURL}}
|
||||
<img src="{{.QRCodeDataURL | safeURL}}" alt="QR Code pour 2FA">
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>Ou entrez le secret manuellement</summary>
|
||||
<p style="margin-top: 1rem;">
|
||||
<code class="secret-code">{{.TwoFASecret}}</code>
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h3>Étape 2 : Sauvegardez vos codes de secours</h3>
|
||||
<div class="warning">
|
||||
Conservez ces codes dans un endroit sûr. Chaque code ne peut être utilisé qu'une seule fois en cas de perte de votre téléphone.
|
||||
</div>
|
||||
<div class="backup-codes">{{.BackupCodesFormatted}}</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h3>Étape 3 : Vérifier le code</h3>
|
||||
<p>Entrez le code à 6 chiffres affiché dans votre application pour confirmer l'activation.</p>
|
||||
|
||||
<form action="/admin/2fa/setup" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="temp_secret" value="{{.TwoFASecret}}">
|
||||
<input type="hidden" name="backup_codes" value="{{range $i, $code := .BackupCodes}}{{if $i}},{{end}}{{$code}}{{end}}">
|
||||
|
||||
<label for="verify_code">
|
||||
Code d'authentification
|
||||
<input type="text" id="verify_code" name="verify_code" class="code-input"
|
||||
inputmode="numeric" maxlength="6" pattern="[0-9]{6}"
|
||||
placeholder="000000" required autofocus
|
||||
autocomplete="one-time-code">
|
||||
</label>
|
||||
|
||||
<button type="submit">Vérifier et activer le 2FA</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<footer style="text-align: center; margin-top: 2rem;">
|
||||
<a href="/admin/" style="font-size: 0.9rem;">Annuler</a>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
107
cmd/sogoms/admin/templates/2fa_verify.html
Normal file
107
cmd/sogoms/admin/templates/2fa_verify.html
Normal file
@@ -0,0 +1,107 @@
|
||||
{{define "2fa_verify.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vérification 2FA - SOGOMS Admin</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.verify-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.logo {
|
||||
text-align: center;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.logo span {
|
||||
color: var(--pico-primary);
|
||||
}
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: var(--pico-muted-color);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.error-message {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.code-input {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.5rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
details {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
details summary {
|
||||
cursor: pointer;
|
||||
color: var(--pico-muted-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<article class="verify-card">
|
||||
<div class="logo">SOGO<span>MS</span> Admin</div>
|
||||
<p class="subtitle">Vérification en deux étapes</p>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="error-message">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p>Entrez le code à 6 chiffres de votre application d'authentification.</p>
|
||||
|
||||
<form action="/admin/2fa/verify" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
|
||||
<label for="code">
|
||||
Code d'authentification
|
||||
<input type="text" id="code" name="code" class="code-input"
|
||||
inputmode="numeric" maxlength="6" pattern="[0-9]{6}"
|
||||
placeholder="000000" required autofocus
|
||||
autocomplete="one-time-code">
|
||||
</label>
|
||||
|
||||
<button type="submit">Vérifier</button>
|
||||
</form>
|
||||
|
||||
<details>
|
||||
<summary>Utiliser un code de secours</summary>
|
||||
<form action="/admin/2fa/verify" method="post" style="margin-top: 1rem;">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="use_backup" value="true">
|
||||
|
||||
<label for="backup_code">
|
||||
Code de secours
|
||||
<input type="text" id="backup_code" name="backup_code"
|
||||
placeholder="XXXX-XXXX" style="font-family: monospace;">
|
||||
</label>
|
||||
|
||||
<button type="submit" class="secondary">Utiliser le code</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<footer style="text-align: center; margin-top: 2rem;">
|
||||
<a href="/admin/login" style="font-size: 0.9rem;">Annuler et se reconnecter</a>
|
||||
</footer>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -71,7 +71,9 @@
|
||||
<tr>
|
||||
<th>Table</th>
|
||||
<th>Colonnes</th>
|
||||
<th>Clé primaire</th>
|
||||
<th>PK</th>
|
||||
<th>Relations (FK)</th>
|
||||
<th>SD/C</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -80,10 +82,15 @@
|
||||
<td><strong>{{.Name}}</strong></td>
|
||||
<td>{{.ColumnCount}}</td>
|
||||
<td><code>{{.PrimaryKey}}</code></td>
|
||||
<td>{{range .ForeignKeys}}<code>{{.}}</code><br>{{end}}</td>
|
||||
<td>{{if .SoftDelete}}*{{end}}{{if .Cascade}}↓{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<footer>
|
||||
<small>PK = Clé primaire | FK = Clé étrangère | * = Soft Delete | ↓ = Cascade (supprime aussi les enfants)</small>
|
||||
</footer>
|
||||
</article>
|
||||
{{end}}
|
||||
|
||||
|
||||
159
cmd/sogoms/admin/templates/infra.html
Normal file
159
cmd/sogoms/admin/templates/infra.html
Normal file
@@ -0,0 +1,159 @@
|
||||
{{define "infra.html"}}
|
||||
{{template "partials/header.html" .}}
|
||||
|
||||
<style>
|
||||
.server-card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.server-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.server-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.server-info dt {
|
||||
color: var(--pico-muted-color);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.server-info dd {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-online { background: #dcfce7; color: #166534; }
|
||||
.badge-offline { background: #fef2f2; color: #dc2626; }
|
||||
.badge-unknown { background: #f3f4f6; color: #6b7280; }
|
||||
.badge-incus { background: #dbeafe; color: #1d4ed8; }
|
||||
.badge-nginx { background: #fef3c7; color: #92400e; }
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--pico-card-background-color);
|
||||
border: 1px solid var(--pico-muted-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--pico-primary);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1>Infrastructure</h1>
|
||||
|
||||
<p class="user-info">
|
||||
Gestion des serveurs, containers Incus et configurations Nginx.
|
||||
</p>
|
||||
|
||||
{{if eq .Flash "success"}}
|
||||
<div style="background:#f0fdf4;border:1px solid #bbf7d0;color:#16a34a;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||
{{.FlashMessage}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Flash "error"}}
|
||||
<div style="background:#fef2f2;border:1px solid #fecaca;color:#dc2626;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||
{{.FlashMessage}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Statistiques -->
|
||||
<div class="stats-grid" hx-get="/admin/api/infra/status" hx-trigger="every 30s" hx-swap="innerHTML">
|
||||
{{$online := 0}}
|
||||
{{$containers := 0}}
|
||||
{{range .Servers}}
|
||||
{{if eq .Status "online"}}{{$online = 1}}{{end}}
|
||||
{{$containers = .ContainerCount}}
|
||||
{{end}}
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{len .Servers}}</div>
|
||||
<div class="stat-label">Serveurs</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{range .Servers}}{{.ContainerCount}}{{end}}</div>
|
||||
<div class="stat-label">Containers</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{range .Servers}}{{.NginxCount}}{{end}}</div>
|
||||
<div class="stat-label">Sites Nginx</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<a href="/admin/infra/servers/new" role="button">+ Nouveau Serveur</a>
|
||||
</div>
|
||||
|
||||
<!-- Liste des serveurs -->
|
||||
{{if .Servers}}
|
||||
{{range .Servers}}
|
||||
<article class="server-card">
|
||||
<header class="server-header">
|
||||
<div>
|
||||
<strong>{{.Name}}</strong>
|
||||
{{if eq .Status "online"}}<span class="badge badge-online">Online</span>{{end}}
|
||||
{{if eq .Status "offline"}}<span class="badge badge-offline">Offline</span>{{end}}
|
||||
{{if eq .Status "unknown"}}<span class="badge badge-unknown">?</span>{{end}}
|
||||
{{if .HasIncus}}<span class="badge badge-incus">Incus</span>{{end}}
|
||||
{{if .HasNginx}}<span class="badge badge-nginx">Nginx</span>{{end}}
|
||||
</div>
|
||||
<a href="/admin/infra/servers/{{.ID}}" role="button" class="outline">Détails</a>
|
||||
</header>
|
||||
<dl class="server-info">
|
||||
<div>
|
||||
<dt>Host</dt>
|
||||
<dd>{{.Host}}</dd>
|
||||
</div>
|
||||
{{if .VpnIP}}
|
||||
<div>
|
||||
<dt>VPN IP</dt>
|
||||
<dd>{{.VpnIP}}</dd>
|
||||
</div>
|
||||
{{end}}
|
||||
<div>
|
||||
<dt>SSH</dt>
|
||||
<dd>{{.SSHUser}}@:{{.SSHPort}}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Containers</dt>
|
||||
<dd>{{.ContainerCount}}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Sites Nginx</dt>
|
||||
<dd>{{.NginxCount}}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<article>
|
||||
<p style="text-align:center;color:var(--pico-muted-color);">
|
||||
Aucun serveur configuré. <a href="/admin/infra/servers/new">Ajouter un serveur</a>
|
||||
</p>
|
||||
</article>
|
||||
{{end}}
|
||||
|
||||
{{template "partials/footer.html" .}}
|
||||
{{end}}
|
||||
@@ -32,7 +32,10 @@
|
||||
<li><a href="/admin/"{{if eq .Title "Dashboard"}} aria-current="page"{{end}}>Dashboard</a></li>
|
||||
{{if .IsSuperAdmin}}
|
||||
<li><a href="/admin/apps"{{if eq .Title "Applications"}} aria-current="page"{{end}}>Apps</a></li>
|
||||
<li><a href="/admin/infra"{{if eq .Title "Infrastructure"}} aria-current="page"{{end}}>Infra</a></li>
|
||||
<li><a href="/admin/users"{{if eq .Title "Utilisateurs"}} aria-current="page"{{end}}>Utilisateurs</a></li>
|
||||
{{end}}
|
||||
<li><a href="/admin/security"{{if eq .Title "Sécurité"}} aria-current="page"{{end}}>Sécurité</a></li>
|
||||
<li>
|
||||
<form action="/admin/logout" method="post" style="margin:0">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
|
||||
10
cmd/sogoms/admin/templates/partials/infra_status.html
Normal file
10
cmd/sogoms/admin/templates/partials/infra_status.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{{define "partials/infra_status.html"}}
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{.ServerCount}}</div>
|
||||
<div class="stat-label">Serveurs ({{.ServersOnline}} online)</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{.ContainerCount}}</div>
|
||||
<div class="stat-label">Containers ({{.Running}} running)</div>
|
||||
</div>
|
||||
{{end}}
|
||||
118
cmd/sogoms/admin/templates/security.html
Normal file
118
cmd/sogoms/admin/templates/security.html
Normal file
@@ -0,0 +1,118 @@
|
||||
{{define "security.html"}}
|
||||
{{template "partials/header.html" .}}
|
||||
|
||||
<style>
|
||||
.security-card {
|
||||
max-width: 600px;
|
||||
}
|
||||
.status-enabled {
|
||||
color: #16a34a;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-disabled {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
.backup-count {
|
||||
font-size: 0.9rem;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
.warning-box {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1>Sécurité</h1>
|
||||
|
||||
<p class="user-info">
|
||||
Paramètres de sécurité pour <strong>{{.User.Username}}</strong>
|
||||
</p>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="error-message" style="background:#fef2f2;border:1px solid #fecaca;color:#dc2626;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Info}}
|
||||
<div style="background:#eff6ff;border:1px solid #bfdbfe;color:#1d4ed8;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||
{{.Info}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<article class="security-card">
|
||||
<header>
|
||||
<strong>Authentification à deux facteurs (2FA)</strong>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
Statut :
|
||||
{{if .TwoFAEnabled}}
|
||||
<span class="status-enabled">Activé</span>
|
||||
{{else}}
|
||||
<span class="status-disabled">Désactivé</span>
|
||||
{{end}}
|
||||
</p>
|
||||
|
||||
{{if .TwoFAEnabled}}
|
||||
<p class="backup-count">
|
||||
Codes de secours restants : <strong>{{.BackupCount}}</strong>
|
||||
</p>
|
||||
|
||||
{{if .TwoFARequired}}
|
||||
<div class="warning-box">
|
||||
Le 2FA est obligatoire pour votre rôle. Vous ne pouvez pas le désactiver.
|
||||
</div>
|
||||
{{else}}
|
||||
<form action="/admin/2fa/disable" method="post" onsubmit="return confirm('Êtes-vous sûr de vouloir désactiver le 2FA ?');">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
|
||||
<label for="password">
|
||||
Mot de passe (confirmation)
|
||||
<input type="password" id="password" name="password" required placeholder="Votre mot de passe">
|
||||
</label>
|
||||
|
||||
<button type="submit" class="secondary">Désactiver le 2FA</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
{{else}}
|
||||
|
||||
{{if .TwoFARequired}}
|
||||
<div class="warning-box">
|
||||
Le 2FA est obligatoire pour votre rôle. Veuillez l'activer.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p>
|
||||
Protégez votre compte avec une couche de sécurité supplémentaire.
|
||||
Vous aurez besoin d'une application d'authentification (Google Authenticator, Authy, etc.).
|
||||
</p>
|
||||
|
||||
<a href="/admin/2fa/setup" role="button">Activer le 2FA</a>
|
||||
|
||||
{{end}}
|
||||
</article>
|
||||
|
||||
<article class="security-card" style="margin-top: 1rem;">
|
||||
<header>
|
||||
<strong>Informations de connexion</strong>
|
||||
</header>
|
||||
<dl>
|
||||
<dt>Nom d'utilisateur</dt>
|
||||
<dd>{{.User.Username}}</dd>
|
||||
<dt>Email</dt>
|
||||
<dd>{{.User.Email}}</dd>
|
||||
<dt>Rôle</dt>
|
||||
<dd>{{.User.Role}}</dd>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
{{template "partials/footer.html" .}}
|
||||
{{end}}
|
||||
245
cmd/sogoms/admin/templates/server_detail.html
Normal file
245
cmd/sogoms/admin/templates/server_detail.html
Normal file
@@ -0,0 +1,245 @@
|
||||
{{define "server_detail.html"}}
|
||||
{{template "partials/header.html" .}}
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-online { background: #dcfce7; color: #166534; }
|
||||
.badge-offline { background: #fef2f2; color: #dc2626; }
|
||||
.badge-unknown { background: #f3f4f6; color: #6b7280; }
|
||||
.badge-running { background: #dcfce7; color: #166534; }
|
||||
.badge-stopped { background: #fef2f2; color: #dc2626; }
|
||||
.badge-active { background: #dcfce7; color: #166534; }
|
||||
.badge-inactive { background: #f3f4f6; color: #6b7280; }
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.info-item dt {
|
||||
color: var(--pico-muted-color);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.info-item dd {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.action-bar button, .action-bar a {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
margin: 0.1rem;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
|
||||
<nav aria-label="breadcrumb">
|
||||
<ul>
|
||||
<li><a href="/admin/infra">Infrastructure</a></li>
|
||||
<li>{{.Server.Name}}</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<h1>
|
||||
{{.Server.Name}}
|
||||
{{if eq .Server.Status "online"}}<span class="badge badge-online">Online</span>{{end}}
|
||||
{{if eq .Server.Status "offline"}}<span class="badge badge-offline">Offline</span>{{end}}
|
||||
{{if eq .Server.Status "unknown"}}<span class="badge badge-unknown">?</span>{{end}}
|
||||
</h1>
|
||||
|
||||
{{if eq .Flash "success"}}
|
||||
<div style="background:#f0fdf4;border:1px solid #bbf7d0;color:#16a34a;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||
{{.FlashMessage}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Flash "error"}}
|
||||
<div style="background:#fef2f2;border:1px solid #fecaca;color:#dc2626;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||
{{.FlashMessage}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Informations -->
|
||||
<article>
|
||||
<header>Informations</header>
|
||||
<dl class="info-grid">
|
||||
<div class="info-item">
|
||||
<dt>Host</dt>
|
||||
<dd>{{.Server.Host}}</dd>
|
||||
</div>
|
||||
{{if .Server.VpnIP}}
|
||||
<div class="info-item">
|
||||
<dt>VPN IP</dt>
|
||||
<dd>{{.Server.VpnIP}}</dd>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="info-item">
|
||||
<dt>SSH</dt>
|
||||
<dd>{{.Server.SSHUser}}@{{.Server.Host}}:{{.Server.SSHPort}}</dd>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<dt>Clé SSH</dt>
|
||||
<dd style="font-size:0.8rem;">{{.Server.SSHKeyFile}}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="action-bar">
|
||||
<form action="/admin/infra/servers/{{.Server.ID}}/test-ssh" method="post" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="outline">Tester SSH</button>
|
||||
</form>
|
||||
<form action="/admin/infra/servers/{{.Server.ID}}/delete" method="post" style="display:inline;"
|
||||
onsubmit="return confirm('Supprimer ce serveur ?');">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn-danger outline">Supprimer</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Containers Incus -->
|
||||
{{if .Server.HasIncus}}
|
||||
<article>
|
||||
<header style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>Containers Incus ({{len .Containers}})</span>
|
||||
<form action="/admin/infra/servers/{{.Server.ID}}/sync-containers" method="post" style="margin:0;">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="outline btn-sm">Synchroniser</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
{{if .Containers}}
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>IP</th>
|
||||
<th>Image</th>
|
||||
<th>Statut</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Containers}}
|
||||
<tr>
|
||||
<td><strong>{{.Name}}</strong></td>
|
||||
<td>{{if .IP}}{{.IP}}{{else}}-{{end}}</td>
|
||||
<td style="font-size:0.8rem;">{{if .Image}}{{.Image}}{{else}}-{{end}}</td>
|
||||
<td>
|
||||
{{if eq .Status "running"}}<span class="badge badge-running">Running</span>{{end}}
|
||||
{{if eq .Status "stopped"}}<span class="badge badge-stopped">Stopped</span>{{end}}
|
||||
{{if eq .Status "unknown"}}<span class="badge badge-unknown">?</span>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if eq .Status "stopped"}}
|
||||
<form action="/admin/infra/containers/{{.ID}}/action" method="post" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="action" value="start">
|
||||
<button type="submit" class="btn-sm outline">Start</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if eq .Status "running"}}
|
||||
<form action="/admin/infra/containers/{{.ID}}/action" method="post" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="action" value="stop">
|
||||
<button type="submit" class="btn-sm outline secondary">Stop</button>
|
||||
</form>
|
||||
<form action="/admin/infra/containers/{{.ID}}/action" method="post" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="action" value="restart">
|
||||
<button type="submit" class="btn-sm outline">Restart</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p style="color:var(--pico-muted-color);text-align:center;">
|
||||
Aucun container. Cliquez sur "Synchroniser" pour importer depuis Incus.
|
||||
</p>
|
||||
{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
|
||||
<!-- Configurations Nginx -->
|
||||
{{if .Server.HasNginx}}
|
||||
<article>
|
||||
<header style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>Sites Nginx ({{len .NginxConfigs}})</span>
|
||||
<div>
|
||||
<form action="/admin/infra/servers/{{.Server.ID}}/sync-nginx" method="post" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="outline btn-sm">Synchroniser</button>
|
||||
</form>
|
||||
<form action="/admin/infra/servers/{{.Server.ID}}/nginx-reload" method="post" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="outline btn-sm">Reload Nginx</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{if .NginxConfigs}}
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domaine</th>
|
||||
<th>Type</th>
|
||||
<th>SSL</th>
|
||||
<th>Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .NginxConfigs}}
|
||||
<tr>
|
||||
<td><strong>{{.Domain}}</strong></td>
|
||||
<td>{{.Type}}</td>
|
||||
<td>{{if .SSLEnabled}}Oui{{else}}Non{{end}}</td>
|
||||
<td>
|
||||
{{if eq .Status "active"}}<span class="badge badge-active">Actif</span>{{end}}
|
||||
{{if eq .Status "inactive"}}<span class="badge badge-inactive">Inactif</span>{{end}}
|
||||
{{if eq .Status "error"}}<span class="badge badge-offline">Erreur</span>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p style="color:var(--pico-muted-color);text-align:center;">
|
||||
Aucun site. Cliquez sur "Synchroniser" pour importer depuis Nginx.
|
||||
</p>
|
||||
{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
|
||||
{{template "partials/footer.html" .}}
|
||||
{{end}}
|
||||
71
cmd/sogoms/admin/templates/server_new.html
Normal file
71
cmd/sogoms/admin/templates/server_new.html
Normal file
@@ -0,0 +1,71 @@
|
||||
{{define "server_new.html"}}
|
||||
{{template "partials/header.html" .}}
|
||||
|
||||
<h1>Nouveau Serveur</h1>
|
||||
|
||||
<p class="user-info">
|
||||
Ajoutez un serveur pour le gérer depuis l'interface admin.
|
||||
</p>
|
||||
|
||||
<article>
|
||||
<form action="/admin/infra/servers/new" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
|
||||
<div class="grid">
|
||||
<label>
|
||||
Nom *
|
||||
<input type="text" name="name" placeholder="in3" required>
|
||||
<small>Identifiant unique du serveur</small>
|
||||
</label>
|
||||
<label>
|
||||
Host *
|
||||
<input type="text" name="host" placeholder="192.168.1.100 ou hostname.local" required>
|
||||
<small>IP ou hostname pour la connexion SSH</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<label>
|
||||
VPN IP
|
||||
<input type="text" name="vpn_ip" placeholder="11.1.2.1">
|
||||
<small>IP WireGuard (optionnel)</small>
|
||||
</label>
|
||||
<label>
|
||||
Port SSH
|
||||
<input type="number" name="ssh_port" value="22" min="1" max="65535">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<label>
|
||||
Utilisateur SSH
|
||||
<input type="text" name="ssh_user" value="root" required>
|
||||
</label>
|
||||
<label>
|
||||
Fichier clé SSH *
|
||||
<input type="text" name="ssh_key_file" placeholder="/root/.ssh/id_ed25519" required>
|
||||
<small>Chemin vers la clé privée sur le container admin</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>Services disponibles</legend>
|
||||
<label>
|
||||
<input type="checkbox" name="has_incus">
|
||||
Incus (gestion de containers)
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="has_nginx">
|
||||
Nginx (reverse proxy)
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-top:1rem;">
|
||||
<button type="submit">Créer le serveur</button>
|
||||
<a href="/admin/infra" role="button" class="outline secondary">Annuler</a>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
{{template "partials/footer.html" .}}
|
||||
{{end}}
|
||||
105
cmd/sogoms/admin/templates/users.html
Normal file
105
cmd/sogoms/admin/templates/users.html
Normal file
@@ -0,0 +1,105 @@
|
||||
{{define "users.html"}}
|
||||
{{template "partials/header.html" .}}
|
||||
|
||||
<style>
|
||||
.users-table {
|
||||
width: 100%;
|
||||
}
|
||||
.users-table th, .users-table td {
|
||||
text-align: left;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-success {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.badge-warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.badge-role {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: #b91c1c;
|
||||
border-color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1>Utilisateurs Admin</h1>
|
||||
|
||||
<p class="user-info">
|
||||
Gestion des utilisateurs de l'interface d'administration.
|
||||
</p>
|
||||
|
||||
{{if eq .Flash "success"}}
|
||||
<div style="background:#f0fdf4;border:1px solid #bbf7d0;color:#16a34a;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||
{{.FlashMessage}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Flash "error"}}
|
||||
<div style="background:#fef2f2;border:1px solid #fecaca;color:#dc2626;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||
{{.FlashMessage}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<article>
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Utilisateur</th>
|
||||
<th>Email</th>
|
||||
<th>Rôle</th>
|
||||
<th>2FA</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td><strong>{{.Username}}</strong></td>
|
||||
<td>{{.Email}}</td>
|
||||
<td><span class="badge badge-role">{{.Role}}</span></td>
|
||||
<td>
|
||||
{{if .TwoFAEnabled}}
|
||||
<span class="badge badge-success">Activé</span>
|
||||
<small style="color:var(--pico-muted-color);">({{.BackupCount}} codes)</small>
|
||||
{{else}}
|
||||
<span class="badge badge-warning">Désactivé</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .TwoFAEnabled}}
|
||||
<form action="/admin/users/reset-2fa" method="post" style="display:inline;"
|
||||
onsubmit="return confirm('Réinitialiser le 2FA pour {{.Username}} ?');">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="username" value="{{.Username}}">
|
||||
<button type="submit" class="btn-danger" style="padding:0.4rem 0.75rem;font-size:0.85rem;">
|
||||
Reset 2FA
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<span style="color:var(--pico-muted-color);font-size:0.85rem;">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
{{template "partials/footer.html" .}}
|
||||
{{end}}
|
||||
138
cmd/sogoms/admin/totp.go
Normal file
138
cmd/sogoms/admin/totp.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"math/big"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/skip2/go-qrcode"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// GenerateTOTPSecret génère un nouveau secret TOTP pour un utilisateur.
|
||||
func GenerateTOTPSecret(issuer, username string) (*otp.Key, error) {
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: issuer,
|
||||
AccountName: username,
|
||||
Period: 30,
|
||||
SecretSize: 20,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate TOTP key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// ValidateTOTPCode valide un code TOTP à 6 chiffres.
|
||||
func ValidateTOTPCode(secret, code string) bool {
|
||||
return totp.Validate(code, secret)
|
||||
}
|
||||
|
||||
// GenerateQRCodeDataURL génère une image QR code en data URL base64.
|
||||
func GenerateQRCodeDataURL(key *otp.Key) (string, error) {
|
||||
// Générer l'image QR
|
||||
qr, err := qrcode.New(key.URL(), qrcode.Medium)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create QR code: %w", err)
|
||||
}
|
||||
|
||||
// Encoder en PNG
|
||||
var buf bytes.Buffer
|
||||
err = png.Encode(&buf, qr.Image(256))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode QR code: %w", err)
|
||||
}
|
||||
|
||||
// Convertir en data URL
|
||||
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
return dataURL, nil
|
||||
}
|
||||
|
||||
// GenerateBackupCodes génère 10 codes de secours au format XXXX-XXXX.
|
||||
func GenerateBackupCodes(count int) ([]string, error) {
|
||||
if count <= 0 {
|
||||
count = 10
|
||||
}
|
||||
|
||||
codes := make([]string, count)
|
||||
for i := 0; i < count; i++ {
|
||||
// Générer 2 groupes de 4 chiffres
|
||||
part1, err := randomDigits(4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
part2, err := randomDigits(4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
codes[i] = fmt.Sprintf("%s-%s", part1, part2)
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
// randomDigits génère une chaîne de n chiffres aléatoires.
|
||||
func randomDigits(n int) (string, error) {
|
||||
max := new(big.Int)
|
||||
max.Exp(big.NewInt(10), big.NewInt(int64(n)), nil)
|
||||
|
||||
num, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate random digits: %w", err)
|
||||
}
|
||||
|
||||
format := fmt.Sprintf("%%0%dd", n)
|
||||
return fmt.Sprintf(format, num), nil
|
||||
}
|
||||
|
||||
// HashBackupCodes hache tous les codes de secours avec bcrypt.
|
||||
func HashBackupCodes(codes []string) ([]string, error) {
|
||||
hashed := make([]string, len(codes))
|
||||
for i, code := range codes {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash backup code: %w", err)
|
||||
}
|
||||
hashed[i] = string(hash)
|
||||
}
|
||||
return hashed, nil
|
||||
}
|
||||
|
||||
// VerifyBackupCode vérifie un code de secours contre une liste de hashes.
|
||||
// Retourne l'index du code trouvé ou -1 si non trouvé.
|
||||
func VerifyBackupCode(code string, hashedCodes []string) int {
|
||||
for i, hash := range hashedCodes {
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(code)) == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// RemoveBackupCode supprime un code de secours de la liste (après utilisation).
|
||||
func RemoveBackupCode(codes []string, index int) []string {
|
||||
if index < 0 || index >= len(codes) {
|
||||
return codes
|
||||
}
|
||||
return append(codes[:index], codes[index+1:]...)
|
||||
}
|
||||
|
||||
// FormatBackupCodes formate les codes pour affichage (2 colonnes).
|
||||
func FormatBackupCodes(codes []string) string {
|
||||
var buf bytes.Buffer
|
||||
for i, code := range codes {
|
||||
buf.WriteString(code)
|
||||
if i%2 == 0 && i < len(codes)-1 {
|
||||
buf.WriteString(" ")
|
||||
} else {
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
@@ -292,6 +292,7 @@ func handleInsert(req *protocol.Request, db *sql.DB, appID string) *protocol.Res
|
||||
}
|
||||
|
||||
// handleUpdate exécute un UPDATE et retourne le nombre de lignes affectées.
|
||||
// Supporte le paramètre "raw" ([]string) pour les colonnes avec expressions SQL brutes.
|
||||
func handleUpdate(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
|
||||
table, ok := req.Params["table"].(string)
|
||||
if !ok {
|
||||
@@ -308,13 +309,28 @@ func handleUpdate(req *protocol.Request, db *sql.DB, appID string) *protocol.Res
|
||||
return protocol.Failure(req.ID, "INVALID_PARAMS", "where is required")
|
||||
}
|
||||
|
||||
// Récupérer les colonnes avec expressions SQL brutes
|
||||
rawCols := make(map[string]bool)
|
||||
if rawList, ok := req.Params["raw"].([]any); ok {
|
||||
for _, col := range rawList {
|
||||
if colStr, ok := col.(string); ok {
|
||||
rawCols[colStr] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Construire SET
|
||||
setClauses := make([]string, 0, len(data))
|
||||
values := make([]any, 0, len(data)+len(where))
|
||||
|
||||
for col, val := range data {
|
||||
setClauses = append(setClauses, col+" = ?")
|
||||
values = append(values, val)
|
||||
if rawCols[col] {
|
||||
// Expression SQL brute (ex: NOW(), NULL, etc.)
|
||||
setClauses = append(setClauses, col+" = "+val.(string))
|
||||
} else {
|
||||
setClauses = append(setClauses, col+" = ?")
|
||||
values = append(values, val)
|
||||
}
|
||||
}
|
||||
|
||||
// Construire WHERE
|
||||
@@ -612,6 +628,13 @@ func handleIntrospect(req *protocol.Request, db *sql.DB, appID string) *protocol
|
||||
table["primary"] = primaryKeys
|
||||
}
|
||||
|
||||
// Détecter soft_delete (colonne deleted_at de type TIMESTAMP ou DATETIME)
|
||||
if col, ok := columns["deleted_at"].(map[string]any); ok {
|
||||
if colType, ok := col["type"].(string); ok && colType == "datetime" {
|
||||
table["soft_delete"] = true
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD par défaut (à affiner manuellement)
|
||||
table["crud"] = []string{"list", "show", "create", "update", "delete"}
|
||||
|
||||
|
||||
@@ -69,9 +69,6 @@ services:
|
||||
- "/config"
|
||||
- "-secrets"
|
||||
- "/secrets"
|
||||
- "-templates"
|
||||
- "/config/admin/templates"
|
||||
- "-dev"
|
||||
- "-port"
|
||||
- "9000"
|
||||
- "-db-socket"
|
||||
|
||||
@@ -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."
|
||||
@@ -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)
|
||||
|
||||
5
go.mod
5
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
|
||||
)
|
||||
|
||||
14
go.sum
14
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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
52
internal/infra/db.go
Normal file
52
internal/infra/db.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// DB représente la connexion à la base SQLite.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// Open ouvre ou crée la base de données SQLite.
|
||||
func Open(dbPath string) (*DB, error) {
|
||||
// Créer le répertoire si nécessaire
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create db directory: %w", err)
|
||||
}
|
||||
|
||||
// Ouvrir la connexion
|
||||
sqlDB, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
// Tester la connexion
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
db := &DB{sqlDB}
|
||||
|
||||
// Exécuter les migrations
|
||||
if err := db.Migrate(); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("migrate database: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Close ferme la connexion.
|
||||
func (db *DB) Close() error {
|
||||
return db.DB.Close()
|
||||
}
|
||||
227
internal/infra/incus.go
Normal file
227
internal/infra/incus.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IncusContainer représente un container Incus retourné par incus list.
|
||||
type IncusContainer struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
IPv4 []string `json:"ipv4"`
|
||||
IPv6 []string `json:"ipv6"`
|
||||
Type string `json:"type"`
|
||||
Snapshots int `json:"snapshots"`
|
||||
Location string `json:"location"`
|
||||
Image string `json:"-"` // Rempli séparément
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// incusListJSON représente le format JSON de incus list.
|
||||
type incusListJSON struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
Snapshots int `json:"snapshots"`
|
||||
Location string `json:"location"`
|
||||
IPv4 string `json:"ipv4"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
}
|
||||
|
||||
// ListIncusContainers liste les containers Incus sur le serveur.
|
||||
func (c *SSHClient) ListIncusContainers(ctx context.Context) ([]IncusContainer, error) {
|
||||
// Récupérer la liste en format JSON
|
||||
result, err := c.Exec(ctx, "incus list --format json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("incus list: %w", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return nil, fmt.Errorf("incus list failed: %s", result.Stderr)
|
||||
}
|
||||
|
||||
// Parser le JSON
|
||||
var rawContainers []struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Type string `json:"type"`
|
||||
Config struct {
|
||||
Image string `json:"image.description"`
|
||||
} `json:"config"`
|
||||
State struct {
|
||||
Network map[string]struct {
|
||||
Addresses []struct {
|
||||
Address string `json:"address"`
|
||||
Family string `json:"family"`
|
||||
Scope string `json:"scope"`
|
||||
} `json:"addresses"`
|
||||
} `json:"network"`
|
||||
} `json:"state"`
|
||||
Snapshots []interface{} `json:"snapshots"`
|
||||
Location string `json:"location"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(result.Stdout), &rawContainers); err != nil {
|
||||
return nil, fmt.Errorf("parse incus json: %w", err)
|
||||
}
|
||||
|
||||
// Convertir en notre format
|
||||
containers := make([]IncusContainer, 0, len(rawContainers))
|
||||
for _, rc := range rawContainers {
|
||||
container := IncusContainer{
|
||||
Name: rc.Name,
|
||||
State: strings.ToLower(rc.Status),
|
||||
Type: rc.Type,
|
||||
Snapshots: len(rc.Snapshots),
|
||||
Location: rc.Location,
|
||||
Image: rc.Config.Image,
|
||||
CreatedAt: rc.CreatedAt,
|
||||
}
|
||||
|
||||
// Extraire les IPs
|
||||
for _, net := range rc.State.Network {
|
||||
for _, addr := range net.Addresses {
|
||||
if addr.Scope == "global" {
|
||||
if addr.Family == "inet" {
|
||||
container.IPv4 = append(container.IPv4, addr.Address)
|
||||
} else if addr.Family == "inet6" {
|
||||
container.IPv6 = append(container.IPv6, addr.Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
containers = append(containers, container)
|
||||
}
|
||||
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
// GetIncusContainer récupère les infos d'un container spécifique.
|
||||
func (c *SSHClient) GetIncusContainer(ctx context.Context, name string) (*IncusContainer, error) {
|
||||
containers, err := c.ListIncusContainers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, container := range containers {
|
||||
if container.Name == name {
|
||||
return &container, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("container %s not found", name)
|
||||
}
|
||||
|
||||
// StartIncusContainer démarre un container.
|
||||
func (c *SSHClient) StartIncusContainer(ctx context.Context, name string) error {
|
||||
result, err := c.Exec(ctx, fmt.Sprintf("incus start %s", name))
|
||||
if err != nil {
|
||||
return fmt.Errorf("incus start: %w", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("incus start %s failed: %s", name, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopIncusContainer arrête un container.
|
||||
func (c *SSHClient) StopIncusContainer(ctx context.Context, name string) error {
|
||||
result, err := c.Exec(ctx, fmt.Sprintf("incus stop %s", name))
|
||||
if err != nil {
|
||||
return fmt.Errorf("incus stop: %w", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("incus stop %s failed: %s", name, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestartIncusContainer redémarre un container.
|
||||
func (c *SSHClient) RestartIncusContainer(ctx context.Context, name string) error {
|
||||
result, err := c.Exec(ctx, fmt.Sprintf("incus restart %s", name))
|
||||
if err != nil {
|
||||
return fmt.Errorf("incus restart: %w", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("incus restart %s failed: %s", name, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecInContainer exécute une commande dans un container.
|
||||
func (c *SSHClient) ExecInContainer(ctx context.Context, containerName, cmd string) (*SSHResult, error) {
|
||||
fullCmd := fmt.Sprintf("incus exec %s -- %s", containerName, cmd)
|
||||
return c.Exec(ctx, fullCmd)
|
||||
}
|
||||
|
||||
// ExecInContainerSimple exécute une commande et retourne stdout.
|
||||
func (c *SSHClient) ExecInContainerSimple(ctx context.Context, containerName, cmd string) (string, error) {
|
||||
result, err := c.ExecInContainer(ctx, containerName, cmd)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return "", fmt.Errorf("command failed in %s (exit %d): %s", containerName, result.ExitCode, result.Stderr)
|
||||
}
|
||||
return strings.TrimSpace(result.Stdout), nil
|
||||
}
|
||||
|
||||
// PushFileToContainer envoie un fichier vers un container.
|
||||
func (c *SSHClient) PushFileToContainer(ctx context.Context, containerName, localPath, remotePath string) error {
|
||||
// D'abord copier vers le serveur hôte
|
||||
tempPath := fmt.Sprintf("/tmp/incus-push-%d", ctx.Value("request_id"))
|
||||
if err := c.CopyFile(ctx, localPath, tempPath); err != nil {
|
||||
return fmt.Errorf("copy to host: %w", err)
|
||||
}
|
||||
|
||||
// Puis push vers le container
|
||||
result, err := c.Exec(ctx, fmt.Sprintf("incus file push %s %s%s && rm %s",
|
||||
tempPath, containerName, remotePath, tempPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("incus file push: %w", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("incus file push failed: %s", result.Stderr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PullFileFromContainer récupère un fichier depuis un container.
|
||||
func (c *SSHClient) PullFileFromContainer(ctx context.Context, containerName, remotePath, localPath string) error {
|
||||
tempPath := fmt.Sprintf("/tmp/incus-pull-%d", ctx.Value("request_id"))
|
||||
|
||||
// Pull depuis le container vers l'hôte
|
||||
result, err := c.Exec(ctx, fmt.Sprintf("incus file pull %s%s %s",
|
||||
containerName, remotePath, tempPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("incus file pull: %w", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("incus file pull failed: %s", result.Stderr)
|
||||
}
|
||||
|
||||
// Puis copier vers local
|
||||
if err := c.CopyFrom(ctx, tempPath, localPath); err != nil {
|
||||
return fmt.Errorf("copy from host: %w", err)
|
||||
}
|
||||
|
||||
// Nettoyer
|
||||
c.Exec(ctx, fmt.Sprintf("rm %s", tempPath))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetContainerLogs récupère les logs d'un container.
|
||||
func (c *SSHClient) GetContainerLogs(ctx context.Context, containerName string, lines int) (string, error) {
|
||||
if lines <= 0 {
|
||||
lines = 100
|
||||
}
|
||||
return c.ExecInContainerSimple(ctx, containerName, fmt.Sprintf("journalctl -n %d --no-pager", lines))
|
||||
}
|
||||
92
internal/infra/migrations.go
Normal file
92
internal/infra/migrations.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||
package infra
|
||||
|
||||
import "fmt"
|
||||
|
||||
// migrations contient les migrations SQL à exécuter dans l'ordre.
|
||||
var migrations = []string{
|
||||
// Table servers
|
||||
`CREATE TABLE IF NOT EXISTS servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
host TEXT NOT NULL,
|
||||
vpn_ip TEXT,
|
||||
ssh_port INTEGER NOT NULL DEFAULT 22,
|
||||
ssh_user TEXT NOT NULL DEFAULT 'root',
|
||||
ssh_key_file TEXT NOT NULL,
|
||||
has_incus INTEGER NOT NULL DEFAULT 0,
|
||||
has_nginx INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
|
||||
// Table containers
|
||||
`CREATE TABLE IF NOT EXISTS containers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
incus_name TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
vpn_ip TEXT,
|
||||
image TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
UNIQUE(server_id, incus_name)
|
||||
)`,
|
||||
|
||||
// Table nginx_configs
|
||||
`CREATE TABLE IF NOT EXISTS nginx_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'proxy',
|
||||
template TEXT,
|
||||
upstream TEXT,
|
||||
ssl_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
config_content TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'inactive',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
UNIQUE(server_id, domain)
|
||||
)`,
|
||||
|
||||
// Table app_bindings
|
||||
`CREATE TABLE IF NOT EXISTS app_bindings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
app_id TEXT NOT NULL,
|
||||
container_id INTEGER,
|
||||
nginx_config_id INTEGER,
|
||||
server_id INTEGER,
|
||||
type TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (container_id) REFERENCES containers(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (nginx_config_id) REFERENCES nginx_configs(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL
|
||||
)`,
|
||||
|
||||
// Index pour accélérer les requêtes
|
||||
`CREATE INDEX IF NOT EXISTS idx_containers_server ON containers(server_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_nginx_configs_server ON nginx_configs(server_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_nginx_configs_domain ON nginx_configs(domain)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_app_bindings_app ON app_bindings(app_id)`,
|
||||
|
||||
// Table migrations pour tracking des versions
|
||||
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
}
|
||||
|
||||
// Migrate exécute toutes les migrations.
|
||||
func (db *DB) Migrate() error {
|
||||
for i, migration := range migrations {
|
||||
if _, err := db.Exec(migration); err != nil {
|
||||
return fmt.Errorf("migration %d failed: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
129
internal/infra/models.go
Normal file
129
internal/infra/models.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||
package infra
|
||||
|
||||
import "time"
|
||||
|
||||
// ServerStatus représente l'état d'un serveur.
|
||||
type ServerStatus string
|
||||
|
||||
const (
|
||||
ServerStatusOnline ServerStatus = "online"
|
||||
ServerStatusOffline ServerStatus = "offline"
|
||||
ServerStatusUnknown ServerStatus = "unknown"
|
||||
)
|
||||
|
||||
// ContainerStatus représente l'état d'un container.
|
||||
type ContainerStatus string
|
||||
|
||||
const (
|
||||
ContainerStatusRunning ContainerStatus = "running"
|
||||
ContainerStatusStopped ContainerStatus = "stopped"
|
||||
ContainerStatusUnknown ContainerStatus = "unknown"
|
||||
)
|
||||
|
||||
// NginxConfigStatus représente l'état d'une config Nginx.
|
||||
type NginxConfigStatus string
|
||||
|
||||
const (
|
||||
NginxConfigStatusActive NginxConfigStatus = "active"
|
||||
NginxConfigStatusInactive NginxConfigStatus = "inactive"
|
||||
NginxConfigStatusError NginxConfigStatus = "error"
|
||||
)
|
||||
|
||||
// NginxConfigType représente le type de config Nginx.
|
||||
type NginxConfigType string
|
||||
|
||||
const (
|
||||
NginxTypeProxy NginxConfigType = "proxy"
|
||||
NginxTypeStatic NginxConfigType = "static"
|
||||
NginxTypeSocket NginxConfigType = "socket"
|
||||
)
|
||||
|
||||
// BindingType représente le type de binding app.
|
||||
type BindingType string
|
||||
|
||||
const (
|
||||
BindingTypeContainer BindingType = "container"
|
||||
BindingTypeNginx BindingType = "nginx"
|
||||
BindingTypeServer BindingType = "server"
|
||||
)
|
||||
|
||||
// Server représente un serveur physique ou VM.
|
||||
type Server struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
VpnIP string `json:"vpn_ip,omitempty"`
|
||||
SSHPort int `json:"ssh_port"`
|
||||
SSHUser string `json:"ssh_user"`
|
||||
SSHKeyFile string `json:"ssh_key_file"`
|
||||
HasIncus bool `json:"has_incus"`
|
||||
HasNginx bool `json:"has_nginx"`
|
||||
Status ServerStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Container représente un container Incus.
|
||||
type Container struct {
|
||||
ID int64 `json:"id"`
|
||||
ServerID int64 `json:"server_id"`
|
||||
Name string `json:"name"`
|
||||
IncusName string `json:"incus_name"`
|
||||
IP string `json:"ip"`
|
||||
VpnIP string `json:"vpn_ip,omitempty"`
|
||||
Image string `json:"image"`
|
||||
Status ContainerStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Relation (populé par query)
|
||||
Server *Server `json:"server,omitempty"`
|
||||
}
|
||||
|
||||
// NginxConfig représente une configuration Nginx.
|
||||
type NginxConfig struct {
|
||||
ID int64 `json:"id"`
|
||||
ServerID int64 `json:"server_id"`
|
||||
Domain string `json:"domain"`
|
||||
Type NginxConfigType `json:"type"`
|
||||
Template string `json:"template,omitempty"`
|
||||
Upstream string `json:"upstream,omitempty"`
|
||||
SSLEnabled bool `json:"ssl_enabled"`
|
||||
ConfigContent string `json:"config_content,omitempty"`
|
||||
Status NginxConfigStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Relation (populé par query)
|
||||
Server *Server `json:"server,omitempty"`
|
||||
}
|
||||
|
||||
// AppBinding représente le lien entre une app SOGOMS et l'infrastructure.
|
||||
type AppBinding struct {
|
||||
ID int64 `json:"id"`
|
||||
AppID string `json:"app_id"`
|
||||
ContainerID *int64 `json:"container_id,omitempty"`
|
||||
NginxConfigID *int64 `json:"nginx_config_id,omitempty"`
|
||||
ServerID *int64 `json:"server_id,omitempty"`
|
||||
Type BindingType `json:"type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Relations (populées par query)
|
||||
Container *Container `json:"container,omitempty"`
|
||||
NginxConfig *NginxConfig `json:"nginx_config,omitempty"`
|
||||
Server *Server `json:"server,omitempty"`
|
||||
}
|
||||
|
||||
// ServerWithContainers représente un serveur avec ses containers.
|
||||
type ServerWithContainers struct {
|
||||
Server
|
||||
Containers []Container `json:"containers"`
|
||||
}
|
||||
|
||||
// InfraOverview représente une vue globale de l'infrastructure.
|
||||
type InfraOverview struct {
|
||||
Servers []ServerWithContainers `json:"servers"`
|
||||
NginxConfigs []NginxConfig `json:"nginx_configs"`
|
||||
AppBindings []AppBinding `json:"app_bindings"`
|
||||
}
|
||||
306
internal/infra/nginx.go
Normal file
306
internal/infra/nginx.go
Normal file
@@ -0,0 +1,306 @@
|
||||
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// NginxSitesAvailable est le répertoire des sites disponibles.
|
||||
NginxSitesAvailable = "/etc/nginx/sites-available"
|
||||
// NginxSitesEnabled est le répertoire des sites activés.
|
||||
NginxSitesEnabled = "/etc/nginx/sites-enabled"
|
||||
)
|
||||
|
||||
// NginxSiteInfo représente les infos d'un site Nginx.
|
||||
type NginxSiteInfo struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
Config string
|
||||
HasSSL bool
|
||||
Domains []string
|
||||
Upstream string
|
||||
}
|
||||
|
||||
// TestNginxConfig teste la configuration Nginx.
|
||||
func (c *SSHClient) TestNginxConfig(ctx context.Context) error {
|
||||
result, err := c.Exec(ctx, "nginx -t")
|
||||
if err != nil {
|
||||
return fmt.Errorf("nginx test: %w", err)
|
||||
}
|
||||
// nginx -t écrit sur stderr même en cas de succès
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("nginx config invalid: %s", result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadNginx recharge la configuration Nginx.
|
||||
func (c *SSHClient) ReloadNginx(ctx context.Context) error {
|
||||
// D'abord tester la config
|
||||
if err := c.TestNginxConfig(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := c.Exec(ctx, "systemctl reload nginx")
|
||||
if err != nil {
|
||||
return fmt.Errorf("nginx reload: %w", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("nginx reload failed: %s", result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestartNginx redémarre Nginx.
|
||||
func (c *SSHClient) RestartNginx(ctx context.Context) error {
|
||||
result, err := c.Exec(ctx, "systemctl restart nginx")
|
||||
if err != nil {
|
||||
return fmt.Errorf("nginx restart: %w", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("nginx restart failed: %s", result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NginxStatus retourne le statut de Nginx.
|
||||
func (c *SSHClient) NginxStatus(ctx context.Context) (string, error) {
|
||||
result, err := c.Exec(ctx, "systemctl is-active nginx")
|
||||
if err != nil {
|
||||
return "unknown", nil
|
||||
}
|
||||
return strings.TrimSpace(result.Stdout), nil
|
||||
}
|
||||
|
||||
// ListNginxSites liste les sites Nginx configurés.
|
||||
func (c *SSHClient) ListNginxSites(ctx context.Context) ([]NginxSiteInfo, error) {
|
||||
// Lister sites-available
|
||||
result, err := c.Exec(ctx, fmt.Sprintf("ls -1 %s", NginxSitesAvailable))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sites-available: %w", err)
|
||||
}
|
||||
|
||||
if result.ExitCode != 0 {
|
||||
return nil, fmt.Errorf("list sites-available failed: %s", result.Stderr)
|
||||
}
|
||||
|
||||
// Lister sites-enabled
|
||||
enabledResult, err := c.Exec(ctx, fmt.Sprintf("ls -1 %s", NginxSitesEnabled))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sites-enabled: %w", err)
|
||||
}
|
||||
|
||||
enabledSet := make(map[string]bool)
|
||||
for _, name := range strings.Split(enabledResult.Stdout, "\n") {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
enabledSet[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
var sites []NginxSiteInfo
|
||||
for _, name := range strings.Split(result.Stdout, "\n") {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == "default" {
|
||||
continue
|
||||
}
|
||||
|
||||
site := NginxSiteInfo{
|
||||
Name: name,
|
||||
Enabled: enabledSet[name],
|
||||
}
|
||||
sites = append(sites, site)
|
||||
}
|
||||
|
||||
return sites, nil
|
||||
}
|
||||
|
||||
// GetNginxSiteConfig récupère la config d'un site.
|
||||
func (c *SSHClient) GetNginxSiteConfig(ctx context.Context, name string) (string, error) {
|
||||
path := filepath.Join(NginxSitesAvailable, name)
|
||||
content, err := c.ReadFile(ctx, path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read site config: %w", err)
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// WriteNginxSiteConfig écrit la config d'un site.
|
||||
func (c *SSHClient) WriteNginxSiteConfig(ctx context.Context, name string, config string) error {
|
||||
path := filepath.Join(NginxSitesAvailable, name)
|
||||
return c.WriteFile(ctx, path, []byte(config), "644")
|
||||
}
|
||||
|
||||
// EnableNginxSite active un site (crée le lien symbolique).
|
||||
func (c *SSHClient) EnableNginxSite(ctx context.Context, name string) error {
|
||||
src := filepath.Join(NginxSitesAvailable, name)
|
||||
dst := filepath.Join(NginxSitesEnabled, name)
|
||||
|
||||
// Vérifier que le site existe
|
||||
exists, err := c.FileExists(ctx, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("site %s does not exist", name)
|
||||
}
|
||||
|
||||
// Créer le lien
|
||||
result, err := c.Exec(ctx, fmt.Sprintf("ln -sf %s %s", src, dst))
|
||||
if err != nil {
|
||||
return fmt.Errorf("enable site: %w", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("enable site failed: %s", result.Stderr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableNginxSite désactive un site (supprime le lien symbolique).
|
||||
func (c *SSHClient) DisableNginxSite(ctx context.Context, name string) error {
|
||||
path := filepath.Join(NginxSitesEnabled, name)
|
||||
|
||||
result, err := c.Exec(ctx, fmt.Sprintf("rm -f %s", path))
|
||||
if err != nil {
|
||||
return fmt.Errorf("disable site: %w", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("disable site failed: %s", result.Stderr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteNginxSite supprime un site (désactive puis supprime).
|
||||
func (c *SSHClient) DeleteNginxSite(ctx context.Context, name string) error {
|
||||
// D'abord désactiver
|
||||
if err := c.DisableNginxSite(ctx, name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Puis supprimer
|
||||
path := filepath.Join(NginxSitesAvailable, name)
|
||||
result, err := c.Exec(ctx, fmt.Sprintf("rm -f %s", path))
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete site: %w", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("delete site failed: %s", result.Stderr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployNginxSite déploie un site complet (écrire, activer, recharger).
|
||||
func (c *SSHClient) DeployNginxSite(ctx context.Context, name string, config string) error {
|
||||
// Écrire la config
|
||||
if err := c.WriteNginxSiteConfig(ctx, name, config); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
|
||||
// Activer le site
|
||||
if err := c.EnableNginxSite(ctx, name); err != nil {
|
||||
return fmt.Errorf("enable site: %w", err)
|
||||
}
|
||||
|
||||
// Recharger Nginx
|
||||
if err := c.ReloadNginx(ctx); err != nil {
|
||||
// En cas d'erreur, désactiver le site
|
||||
c.DisableNginxSite(ctx, name)
|
||||
return fmt.Errorf("reload nginx: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateNginxProxyConfig génère une config proxy standard.
|
||||
func GenerateNginxProxyConfig(domain, upstream string, ssl bool) string {
|
||||
var config strings.Builder
|
||||
|
||||
if ssl {
|
||||
// Redirect HTTP to HTTPS
|
||||
config.WriteString(fmt.Sprintf(`server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name %s;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
`, domain))
|
||||
}
|
||||
|
||||
// Main server block
|
||||
if ssl {
|
||||
config.WriteString(fmt.Sprintf(`server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name %s;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/%s/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/%s/privkey.pem;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:50m;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
location / {
|
||||
proxy_pass %s;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
`, domain, domain, domain, upstream))
|
||||
} else {
|
||||
config.WriteString(fmt.Sprintf(`server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name %s;
|
||||
|
||||
location / {
|
||||
proxy_pass %s;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
`, domain, upstream))
|
||||
}
|
||||
|
||||
return config.String()
|
||||
}
|
||||
|
||||
// RequestSSLCertificate demande un certificat Let's Encrypt.
|
||||
func (c *SSHClient) RequestSSLCertificate(ctx context.Context, domain, email string) error {
|
||||
cmd := fmt.Sprintf("certbot certonly --nginx -d %s --non-interactive --agree-tos -m %s", domain, email)
|
||||
result, err := c.Exec(ctx, cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("certbot: %w", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("certbot failed: %s", result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckSSLCertificate vérifie si un certificat SSL existe.
|
||||
func (c *SSHClient) CheckSSLCertificate(ctx context.Context, domain string) (bool, error) {
|
||||
path := fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", domain)
|
||||
return c.FileExists(ctx, path)
|
||||
}
|
||||
469
internal/infra/repository.go
Normal file
469
internal/infra/repository.go
Normal file
@@ -0,0 +1,469 @@
|
||||
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Servers
|
||||
// ============================================================================
|
||||
|
||||
// CreateServer crée un nouveau serveur.
|
||||
func (db *DB) CreateServer(s *Server) error {
|
||||
now := time.Now()
|
||||
result, err := db.Exec(`
|
||||
INSERT INTO servers (name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
s.Name, s.Host, nullString(s.VpnIP), s.SSHPort, s.SSHUser, s.SSHKeyFile,
|
||||
boolToInt(s.HasIncus), boolToInt(s.HasNginx), s.Status, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert server: %w", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
s.ID = id
|
||||
s.CreatedAt = now
|
||||
s.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServer récupère un serveur par ID.
|
||||
func (db *DB) GetServer(id int64) (*Server, error) {
|
||||
s := &Server{}
|
||||
err := db.QueryRow(`
|
||||
SELECT id, name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at
|
||||
FROM servers WHERE id = ?`, id).Scan(
|
||||
&s.ID, &s.Name, &s.Host, &s.VpnIP, &s.SSHPort, &s.SSHUser, &s.SSHKeyFile,
|
||||
&s.HasIncus, &s.HasNginx, &s.Status, &s.CreatedAt, &s.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get server: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// GetServerByName récupère un serveur par nom.
|
||||
func (db *DB) GetServerByName(name string) (*Server, error) {
|
||||
s := &Server{}
|
||||
err := db.QueryRow(`
|
||||
SELECT id, name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at
|
||||
FROM servers WHERE name = ?`, name).Scan(
|
||||
&s.ID, &s.Name, &s.Host, &s.VpnIP, &s.SSHPort, &s.SSHUser, &s.SSHKeyFile,
|
||||
&s.HasIncus, &s.HasNginx, &s.Status, &s.CreatedAt, &s.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get server by name: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ListServers retourne tous les serveurs.
|
||||
func (db *DB) ListServers() ([]Server, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at
|
||||
FROM servers ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list servers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var servers []Server
|
||||
for rows.Next() {
|
||||
var s Server
|
||||
if err := rows.Scan(&s.ID, &s.Name, &s.Host, &s.VpnIP, &s.SSHPort, &s.SSHUser, &s.SSHKeyFile,
|
||||
&s.HasIncus, &s.HasNginx, &s.Status, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan server: %w", err)
|
||||
}
|
||||
servers = append(servers, s)
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// UpdateServer met à jour un serveur.
|
||||
func (db *DB) UpdateServer(s *Server) error {
|
||||
s.UpdatedAt = time.Now()
|
||||
_, err := db.Exec(`
|
||||
UPDATE servers SET name=?, host=?, vpn_ip=?, ssh_port=?, ssh_user=?, ssh_key_file=?,
|
||||
has_incus=?, has_nginx=?, status=?, updated_at=? WHERE id=?`,
|
||||
s.Name, s.Host, nullString(s.VpnIP), s.SSHPort, s.SSHUser, s.SSHKeyFile,
|
||||
boolToInt(s.HasIncus), boolToInt(s.HasNginx), s.Status, s.UpdatedAt, s.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateServerStatus met à jour le statut d'un serveur.
|
||||
func (db *DB) UpdateServerStatus(id int64, status ServerStatus) error {
|
||||
_, err := db.Exec(`UPDATE servers SET status=?, updated_at=? WHERE id=?`,
|
||||
status, time.Now(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update server status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteServer supprime un serveur.
|
||||
func (db *DB) DeleteServer(id int64) error {
|
||||
_, err := db.Exec(`DELETE FROM servers WHERE id=?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Containers
|
||||
// ============================================================================
|
||||
|
||||
// CreateContainer crée un nouveau container.
|
||||
func (db *DB) CreateContainer(c *Container) error {
|
||||
now := time.Now()
|
||||
result, err := db.Exec(`
|
||||
INSERT INTO containers (server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ServerID, c.Name, c.IncusName, nullString(c.IP), nullString(c.VpnIP), c.Image, c.Status, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert container: %w", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
c.ID = id
|
||||
c.CreatedAt = now
|
||||
c.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetContainer récupère un container par ID.
|
||||
func (db *DB) GetContainer(id int64) (*Container, error) {
|
||||
c := &Container{}
|
||||
err := db.QueryRow(`
|
||||
SELECT id, server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at
|
||||
FROM containers WHERE id = ?`, id).Scan(
|
||||
&c.ID, &c.ServerID, &c.Name, &c.IncusName, &c.IP, &c.VpnIP, &c.Image, &c.Status, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get container: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ListContainersByServer retourne les containers d'un serveur.
|
||||
func (db *DB) ListContainersByServer(serverID int64) ([]Container, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at
|
||||
FROM containers WHERE server_id = ? ORDER BY name`, serverID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list containers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var containers []Container
|
||||
for rows.Next() {
|
||||
var c Container
|
||||
if err := rows.Scan(&c.ID, &c.ServerID, &c.Name, &c.IncusName, &c.IP, &c.VpnIP, &c.Image, &c.Status, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan container: %w", err)
|
||||
}
|
||||
containers = append(containers, c)
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
// ListAllContainers retourne tous les containers.
|
||||
func (db *DB) ListAllContainers() ([]Container, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at
|
||||
FROM containers ORDER BY server_id, name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list all containers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var containers []Container
|
||||
for rows.Next() {
|
||||
var c Container
|
||||
if err := rows.Scan(&c.ID, &c.ServerID, &c.Name, &c.IncusName, &c.IP, &c.VpnIP, &c.Image, &c.Status, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan container: %w", err)
|
||||
}
|
||||
containers = append(containers, c)
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
// UpdateContainer met à jour un container.
|
||||
func (db *DB) UpdateContainer(c *Container) error {
|
||||
c.UpdatedAt = time.Now()
|
||||
_, err := db.Exec(`
|
||||
UPDATE containers SET server_id=?, name=?, incus_name=?, ip=?, vpn_ip=?, image=?, status=?, updated_at=?
|
||||
WHERE id=?`,
|
||||
c.ServerID, c.Name, c.IncusName, nullString(c.IP), nullString(c.VpnIP), c.Image, c.Status, c.UpdatedAt, c.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update container: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateContainerStatus met à jour le statut d'un container.
|
||||
func (db *DB) UpdateContainerStatus(id int64, status ContainerStatus) error {
|
||||
_, err := db.Exec(`UPDATE containers SET status=?, updated_at=? WHERE id=?`,
|
||||
status, time.Now(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update container status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteContainer supprime un container.
|
||||
func (db *DB) DeleteContainer(id int64) error {
|
||||
_, err := db.Exec(`DELETE FROM containers WHERE id=?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete container: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NginxConfigs
|
||||
// ============================================================================
|
||||
|
||||
// CreateNginxConfig crée une nouvelle config Nginx.
|
||||
func (db *DB) CreateNginxConfig(n *NginxConfig) error {
|
||||
now := time.Now()
|
||||
result, err := db.Exec(`
|
||||
INSERT INTO nginx_configs (server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
n.ServerID, n.Domain, n.Type, nullString(n.Template), nullString(n.Upstream),
|
||||
boolToInt(n.SSLEnabled), nullString(n.ConfigContent), n.Status, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert nginx config: %w", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
n.ID = id
|
||||
n.CreatedAt = now
|
||||
n.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNginxConfig récupère une config Nginx par ID.
|
||||
func (db *DB) GetNginxConfig(id int64) (*NginxConfig, error) {
|
||||
n := &NginxConfig{}
|
||||
err := db.QueryRow(`
|
||||
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
|
||||
FROM nginx_configs WHERE id = ?`, id).Scan(
|
||||
&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
|
||||
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get nginx config: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GetNginxConfigByDomain récupère une config par domaine.
|
||||
func (db *DB) GetNginxConfigByDomain(serverID int64, domain string) (*NginxConfig, error) {
|
||||
n := &NginxConfig{}
|
||||
err := db.QueryRow(`
|
||||
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
|
||||
FROM nginx_configs WHERE server_id = ? AND domain = ?`, serverID, domain).Scan(
|
||||
&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
|
||||
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get nginx config by domain: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ListNginxConfigsByServer retourne les configs d'un serveur.
|
||||
func (db *DB) ListNginxConfigsByServer(serverID int64) ([]NginxConfig, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
|
||||
FROM nginx_configs WHERE server_id = ? ORDER BY domain`, serverID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list nginx configs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var configs []NginxConfig
|
||||
for rows.Next() {
|
||||
var n NginxConfig
|
||||
if err := rows.Scan(&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
|
||||
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan nginx config: %w", err)
|
||||
}
|
||||
configs = append(configs, n)
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// ListAllNginxConfigs retourne toutes les configs.
|
||||
func (db *DB) ListAllNginxConfigs() ([]NginxConfig, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
|
||||
FROM nginx_configs ORDER BY server_id, domain`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list all nginx configs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var configs []NginxConfig
|
||||
for rows.Next() {
|
||||
var n NginxConfig
|
||||
if err := rows.Scan(&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
|
||||
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan nginx config: %w", err)
|
||||
}
|
||||
configs = append(configs, n)
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// UpdateNginxConfig met à jour une config.
|
||||
func (db *DB) UpdateNginxConfig(n *NginxConfig) error {
|
||||
n.UpdatedAt = time.Now()
|
||||
_, err := db.Exec(`
|
||||
UPDATE nginx_configs SET server_id=?, domain=?, type=?, template=?, upstream=?,
|
||||
ssl_enabled=?, config_content=?, status=?, updated_at=? WHERE id=?`,
|
||||
n.ServerID, n.Domain, n.Type, nullString(n.Template), nullString(n.Upstream),
|
||||
boolToInt(n.SSLEnabled), nullString(n.ConfigContent), n.Status, n.UpdatedAt, n.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update nginx config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteNginxConfig supprime une config.
|
||||
func (db *DB) DeleteNginxConfig(id int64) error {
|
||||
_, err := db.Exec(`DELETE FROM nginx_configs WHERE id=?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete nginx config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AppBindings
|
||||
// ============================================================================
|
||||
|
||||
// CreateAppBinding crée un nouveau binding.
|
||||
func (db *DB) CreateAppBinding(b *AppBinding) error {
|
||||
now := time.Now()
|
||||
result, err := db.Exec(`
|
||||
INSERT INTO app_bindings (app_id, container_id, nginx_config_id, server_id, type, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
b.AppID, nullInt64(b.ContainerID), nullInt64(b.NginxConfigID), nullInt64(b.ServerID), b.Type, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert app binding: %w", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
b.ID = id
|
||||
b.CreatedAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAppBindings retourne les bindings d'une app.
|
||||
func (db *DB) ListAppBindings(appID string) ([]AppBinding, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, app_id, container_id, nginx_config_id, server_id, type, created_at
|
||||
FROM app_bindings WHERE app_id = ?`, appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list app bindings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var bindings []AppBinding
|
||||
for rows.Next() {
|
||||
var b AppBinding
|
||||
if err := rows.Scan(&b.ID, &b.AppID, &b.ContainerID, &b.NginxConfigID, &b.ServerID, &b.Type, &b.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan app binding: %w", err)
|
||||
}
|
||||
bindings = append(bindings, b)
|
||||
}
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
// DeleteAppBinding supprime un binding.
|
||||
func (db *DB) DeleteAppBinding(id int64) error {
|
||||
_, err := db.Exec(`DELETE FROM app_bindings WHERE id=?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete app binding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAppBindingsByApp supprime tous les bindings d'une app.
|
||||
func (db *DB) DeleteAppBindingsByApp(appID string) error {
|
||||
_, err := db.Exec(`DELETE FROM app_bindings WHERE app_id=?`, appID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete app bindings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
func nullString(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nullInt64(i *int64) interface{} {
|
||||
if i == nil {
|
||||
return nil
|
||||
}
|
||||
return *i
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Aggregate queries
|
||||
// ============================================================================
|
||||
|
||||
// GetInfraOverview retourne une vue complète de l'infrastructure.
|
||||
func (db *DB) GetInfraOverview() (*InfraOverview, error) {
|
||||
servers, err := db.ListServers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
overview := &InfraOverview{
|
||||
Servers: make([]ServerWithContainers, len(servers)),
|
||||
}
|
||||
|
||||
for i, s := range servers {
|
||||
overview.Servers[i].Server = s
|
||||
containers, err := db.ListContainersByServer(s.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overview.Servers[i].Containers = containers
|
||||
}
|
||||
|
||||
overview.NginxConfigs, err = db.ListAllNginxConfigs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return overview, nil
|
||||
}
|
||||
335
internal/infra/ssh.go
Normal file
335
internal/infra/ssh.go
Normal file
@@ -0,0 +1,335 @@
|
||||
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// SSHClient représente une connexion SSH à un serveur.
|
||||
type SSHClient struct {
|
||||
server *Server
|
||||
client *ssh.Client
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// SSHPool gère un pool de connexions SSH.
|
||||
type SSHPool struct {
|
||||
clients map[int64]*SSHClient
|
||||
mu sync.RWMutex
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// SSHResult représente le résultat d'une commande SSH.
|
||||
type SSHResult struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExitCode int
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// NewSSHPool crée un nouveau pool SSH.
|
||||
func NewSSHPool(timeout time.Duration) *SSHPool {
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
return &SSHPool{
|
||||
clients: make(map[int64]*SSHClient),
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect établit une connexion SSH à un serveur.
|
||||
func (p *SSHPool) Connect(server *Server) (*SSHClient, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Vérifier si déjà connecté
|
||||
if client, ok := p.clients[server.ID]; ok {
|
||||
if client.isAlive() {
|
||||
return client, nil
|
||||
}
|
||||
// Connexion morte, la supprimer
|
||||
client.Close()
|
||||
delete(p.clients, server.ID)
|
||||
}
|
||||
|
||||
// Lire la clé SSH
|
||||
keyData, err := os.ReadFile(server.SSHKeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ssh key: %w", err)
|
||||
}
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(keyData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ssh key: %w", err)
|
||||
}
|
||||
|
||||
// Config SSH
|
||||
config := &ssh.ClientConfig{
|
||||
User: server.SSHUser,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: gérer les known_hosts
|
||||
Timeout: p.timeout,
|
||||
}
|
||||
|
||||
// Connexion
|
||||
addr := fmt.Sprintf("%s:%d", server.Host, server.SSHPort)
|
||||
sshClient, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssh dial %s: %w", addr, err)
|
||||
}
|
||||
|
||||
client := &SSHClient{
|
||||
server: server,
|
||||
client: sshClient,
|
||||
}
|
||||
p.clients[server.ID] = client
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Get récupère un client existant ou nil.
|
||||
func (p *SSHPool) Get(serverID int64) *SSHClient {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.clients[serverID]
|
||||
}
|
||||
|
||||
// Disconnect ferme la connexion à un serveur.
|
||||
func (p *SSHPool) Disconnect(serverID int64) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if client, ok := p.clients[serverID]; ok {
|
||||
client.Close()
|
||||
delete(p.clients, serverID)
|
||||
}
|
||||
}
|
||||
|
||||
// CloseAll ferme toutes les connexions.
|
||||
func (p *SSHPool) CloseAll() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
for id, client := range p.clients {
|
||||
client.Close()
|
||||
delete(p.clients, id)
|
||||
}
|
||||
}
|
||||
|
||||
// isAlive vérifie si la connexion est active.
|
||||
func (c *SSHClient) isAlive() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.client == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Test rapide avec une session
|
||||
session, err := c.client.NewSession()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
session.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// Close ferme la connexion.
|
||||
func (c *SSHClient) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.client != nil {
|
||||
err := c.client.Close()
|
||||
c.client = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exec exécute une commande sur le serveur distant.
|
||||
func (c *SSHClient) Exec(ctx context.Context, cmd string) (*SSHResult, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.client == nil {
|
||||
return nil, fmt.Errorf("ssh client not connected")
|
||||
}
|
||||
|
||||
session, err := c.client.NewSession()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
session.Stdout = &stdout
|
||||
session.Stderr = &stderr
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Exécuter avec timeout
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- session.Run(cmd)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
session.Signal(ssh.SIGKILL)
|
||||
return nil, ctx.Err()
|
||||
case err := <-done:
|
||||
result := &SSHResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
Duration: time.Since(start),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*ssh.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitStatus()
|
||||
} else {
|
||||
return nil, fmt.Errorf("exec command: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ExecSimple exécute une commande et retourne stdout.
|
||||
func (c *SSHClient) ExecSimple(ctx context.Context, cmd string) (string, error) {
|
||||
result, err := c.Exec(ctx, cmd)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return "", fmt.Errorf("command failed (exit %d): %s", result.ExitCode, result.Stderr)
|
||||
}
|
||||
return strings.TrimSpace(result.Stdout), nil
|
||||
}
|
||||
|
||||
// WriteFile écrit un fichier sur le serveur distant via cat.
|
||||
func (c *SSHClient) WriteFile(ctx context.Context, path string, content []byte, mode string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.client == nil {
|
||||
return fmt.Errorf("ssh client not connected")
|
||||
}
|
||||
|
||||
session, err := c.client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// Utiliser cat pour écrire le fichier
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("cat > %s && chmod %s %s", path, mode, path)
|
||||
if err := session.Start(cmd); err != nil {
|
||||
return fmt.Errorf("start command: %w", err)
|
||||
}
|
||||
|
||||
if _, err := stdin.Write(content); err != nil {
|
||||
return fmt.Errorf("write content: %w", err)
|
||||
}
|
||||
stdin.Close()
|
||||
|
||||
if err := session.Wait(); err != nil {
|
||||
return fmt.Errorf("wait command: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadFile lit un fichier depuis le serveur distant.
|
||||
func (c *SSHClient) ReadFile(ctx context.Context, path string) ([]byte, error) {
|
||||
result, err := c.Exec(ctx, fmt.Sprintf("cat %s", path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return nil, fmt.Errorf("read file failed: %s", result.Stderr)
|
||||
}
|
||||
return []byte(result.Stdout), nil
|
||||
}
|
||||
|
||||
// FileExists vérifie si un fichier existe.
|
||||
func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) {
|
||||
result, err := c.Exec(ctx, fmt.Sprintf("test -e %s && echo yes || echo no", path))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.TrimSpace(result.Stdout) == "yes", nil
|
||||
}
|
||||
|
||||
// SFTP retourne un client SFTP pour des opérations de fichiers avancées.
|
||||
// Note: nécessite github.com/pkg/sftp si on veut un vrai SFTP.
|
||||
// Pour l'instant on utilise des commandes shell.
|
||||
|
||||
// CopyFile copie un fichier local vers le serveur distant.
|
||||
func (c *SSHClient) CopyFile(ctx context.Context, localPath, remotePath string) error {
|
||||
content, err := os.ReadFile(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read local file: %w", err)
|
||||
}
|
||||
return c.WriteFile(ctx, remotePath, content, "644")
|
||||
}
|
||||
|
||||
// CopyFrom copie un fichier du serveur distant vers local.
|
||||
func (c *SSHClient) CopyFrom(ctx context.Context, remotePath, localPath string) error {
|
||||
content, err := c.ReadFile(ctx, remotePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(localPath, content, 0644)
|
||||
}
|
||||
|
||||
// StreamExec exécute une commande et stream la sortie.
|
||||
func (c *SSHClient) StreamExec(ctx context.Context, cmd string, stdout, stderr io.Writer) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.client == nil {
|
||||
return fmt.Errorf("ssh client not connected")
|
||||
}
|
||||
|
||||
session, err := c.client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
session.Stdout = stdout
|
||||
session.Stderr = stderr
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- session.Run(cmd)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
session.Signal(ssh.SIGKILL)
|
||||
return ctx.Err()
|
||||
case err := <-done:
|
||||
return err
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user