2 Commits

Author SHA1 Message Date
0b1977e0c4 SOGOMS v1.0.7 - 2FA obligatoire et Infrastructure Management
Phase 17g - Double Authentification:
- TOTP avec Google Authenticator/Authy
- QR code pour enrôlement
- Codes de backup (10 codes usage unique)
- Page /admin/security pour gestion 2FA
- Page /admin/users avec Reset 2FA (super_admin)
- 2FA obligatoire pour rôles configurés

Phase 21 - Infrastructure Management:
- SQLite pour données infra (/data/infra.db)
- SSH Pool avec reconnexion auto
- Gestion Incus (list, start, stop, restart, sync)
- Gestion Nginx (test, reload, deploy, sync, certbot)
- Interface admin /admin/infra
- Formulaire ajout serveur
- Page détail serveur avec containers et sites

Fichiers créés:
- internal/infra/ (db, models, migrations, repository, ssh, incus, nginx)
- cmd/sogoms/admin/totp.go
- cmd/sogoms/admin/handlers_2fa.go
- cmd/sogoms/admin/handlers_infra.go
- Templates: 2fa_*, security, users, infra, server_*

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:21:11 +01:00
1274400b08 SOGOMS v1.0.5 - Auto-génération login_data et version UI
- Génération automatique de login_data dans auth.yaml après scan DB
- Tables avec filter:owner incluses dans login_data pour login enrichi
- Affichage version SOGOMS dans l'interface admin (login + header)
- Documentation mise à jour (DOCTECH.md, README.md, TODO.md)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 15:41:49 +01:00
39 changed files with 5150 additions and 150 deletions

View File

@@ -2,8 +2,8 @@
**Service Oriented GO MicroServices** - Plateforme SaaS modulaire multi-tenant.
Version: 1.0.1
Date: 16 décembre 2025
Version: 1.0.5
Date: 22 décembre 2025
---
@@ -220,14 +220,52 @@ 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
- `POST /admin/login` : authentification
- `GET /admin/` : dashboard principal
- `POST /admin/logout` : déconnexion
- `GET /admin/apps/{app}` : détail application
- `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 :**
Le bouton "Scanner la base" sur la page détail d'une app :
1. Introspection de la DB via `INFORMATION_SCHEMA`
2. Génération de `schema.yaml` (tables, colonnes, types, contraintes)
3. Génération automatique de `login_data` dans `queries/auth.yaml`
4. Rechargement du registry et de sogoway (SIGHUP)
Les tables avec colonne `user_id` reçoivent automatiquement :
- `filter: owner` pour filtrage par utilisateur
- CRUD activé (list, show, create, update, delete)
- Requête SELECT dans `login_data` pour le login enrichi
**Configuration :**
```yaml
@@ -241,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$..."

View File

@@ -18,7 +18,7 @@ schema.yaml → SOGOMS → API REST + Auth + CRUD + Push
- **Sécurisé** : JWT, isolation par user_id, bcrypt
- **Auto-supervisé** : health checks, restart automatique
- **Temps réel** : push MQTT vers les applications (roadmap)
- **Schema-driven** : génération d'API depuis la structure DB (roadmap)
- **Schema-driven** : génération d'API et queries depuis la structure DB
## Services actuels
@@ -30,7 +30,7 @@ schema.yaml → SOGOMS → API REST + Auth + CRUD + Push
| `sogoms-logs` | Logging centralisé | Stable |
| `sogoms-smtp` | Envoi emails, templates | Stable |
| `sogoms-cron` | Tâches planifiées | Stable |
| `sogoms-admin` | Interface web administration | Stable |
| `sogoms-admin` | Interface web administration, scan DB | Stable |
## Roadmap

314
TODO.md
View File

@@ -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)
@@ -408,6 +456,14 @@ Note : le bouton "Scanner la base" (19b) fait office d'Update Schema.
- [ ] Page détail app : liste routes générées
- [ ] Page détail app : dictionnaire des données (types, contraintes)
- [ ] Indicateur : schema synchronisé / désynchronisé avec DB
- [ ] Modale détail table : colonnes (nom, type, nullable, default, contraintes)
- [ ] Modale détail route : query SQL, filtres, champs autorisés
### 19f. Génération auto login_data
- [x] Après scan schema, regénérer `login_data` dans `queries/auth.yaml`
- [x] Pour chaque table avec `filter: owner` : SELECT toutes colonnes WHERE user_id = ?
- [x] Préserver le reste du fichier auth.yaml (user_by_email, etc.)
### 19e. Gestion des secrets
@@ -416,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).

View File

@@ -1 +1 @@
1.0.3
1.0.7

BIN
admin

Binary file not shown.

View File

@@ -11,6 +11,7 @@ import (
"sogoms.com/internal/admin"
"sogoms.com/internal/auth"
"sogoms.com/internal/config"
"sogoms.com/internal/infra"
)
// AdminServer contient les dépendances des handlers.
@@ -18,25 +19,18 @@ 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
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
}
@@ -98,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)
@@ -112,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)
}
@@ -237,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.
@@ -376,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,
})
}
}
@@ -539,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),
@@ -585,6 +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)
} 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)
@@ -614,6 +692,9 @@ func (s *AdminServer) HandleAppScanDB(w http.ResponseWriter, r *http.Request) {
func (s *AdminServer) render(w http.ResponseWriter, name string, data map[string]any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Ajouter la version à toutes les pages
data["SogVersion"] = s.version
tmpl := s.getTemplates()
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
log.Printf("[admin] template error: %v", err)

View File

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

View File

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

View File

@@ -13,10 +13,13 @@ 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"
)
//go:embed templates/*.html templates/partials/*.html
@@ -29,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() {
@@ -80,32 +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,
infraDB: infraDB,
sshPool: sshPool,
}
// Router
@@ -123,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))
@@ -139,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)
@@ -166,29 +207,17 @@ func main() {
httpServer.Close()
}
// loadTemplates charge les templates depuis le filesystem ou embedded.
func loadTemplates(dir string) (*template.Template, error) {
// loadTemplates charge les templates depuis embedded.
func loadTemplates() (*template.Template, error) {
funcMap := template.FuncMap{
"safe": func(s string) template.HTML {
return template.HTML(s)
},
"safeURL": func(s string) template.URL {
return template.URL(s)
},
}
if dir != "" {
// Charger depuis le filesystem
tmpl, err := template.New("").Funcs(funcMap).ParseGlob(dir + "/*.html")
if err != nil {
return nil, err
}
// Charger les partials
tmpl, err = tmpl.ParseGlob(dir + "/partials/*.html")
if err != nil {
return nil, err
}
return tmpl, nil
}
// Charger depuis embedded
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html", "templates/partials/*.html")
if err != nil {
return nil, err

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,6 +63,9 @@
<button type="submit">Se connecter</button>
</form>
<footer style="text-align: center; margin-top: 1rem; font-size: 0.8rem; color: var(--pico-muted-color);">
v{{.SogVersion}}
</footer>
</article>
</body>
</html>

View File

@@ -25,14 +25,17 @@
<header class="container">
<nav>
<ul>
<li><a href="/admin/" class="logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1095c1" d="M0,3v8H11V0H3A3,3,0,0,0,0,3Z"/><path fill="#1095c1" d="M21,0H13V11H24V3A3,3,0,0,0,21,0Z"/><path fill="#1095c1" d="M0,21a3,3,0,0,0,3,3h8V13H0Z"/><path fill="#1095c1" d="M13,24h8a3,3,0,0,0,3-3V13H13Z"/></svg>SOGO<span>MS</span></a></li>
<li><a href="/admin/" class="logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1095c1" d="M0,3v8H11V0H3A3,3,0,0,0,0,3Z"/><path fill="#1095c1" d="M21,0H13V11H24V3A3,3,0,0,0,21,0Z"/><path fill="#1095c1" d="M0,21a3,3,0,0,0,3,3h8V13H0Z"/><path fill="#1095c1" d="M13,24h8a3,3,0,0,0,3-3V13H13Z"/></svg>SOGO<span>MS</span> <small style="font-weight:normal;color:var(--pico-muted-color)">v{{.SogVersion}}</small></a></li>
</ul>
<ul>
{{if .User}}
<li><a href="/admin/"{{if eq .Title "Dashboard"}} aria-current="page"{{end}}>Dashboard</a></li>
{{if .IsSuperAdmin}}
<li><a href="/admin/apps"{{if eq .Title "Applications"}} aria-current="page"{{end}}>Apps</a></li>
<li><a href="/admin/infra"{{if eq .Title "Infrastructure"}} aria-current="page"{{end}}>Infra</a></li>
<li><a href="/admin/users"{{if eq .Title "Utilisateurs"}} aria-current="page"{{end}}>Utilisateurs</a></li>
{{end}}
<li><a href="/admin/security"{{if eq .Title "Sécurité"}} aria-current="page"{{end}}>Sécurité</a></li>
<li>
<form action="/admin/logout" method="post" style="margin:0">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -292,6 +292,7 @@ func handleInsert(req *protocol.Request, db *sql.DB, appID string) *protocol.Res
}
// handleUpdate exécute un UPDATE et retourne le nombre de lignes affectées.
// Supporte le paramètre "raw" ([]string) pour les colonnes avec expressions SQL brutes.
func handleUpdate(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
table, ok := req.Params["table"].(string)
if !ok {
@@ -308,14 +309,29 @@ 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 {
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
whereClauses := make([]string, 0, len(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"}

View File

@@ -69,9 +69,6 @@ services:
- "/config"
- "-secrets"
- "/secrets"
- "-templates"
- "/config/admin/templates"
- "-dev"
- "-port"
- "9000"
- "-db-socket"

View File

@@ -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."

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

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

View File

@@ -23,6 +23,8 @@ type Table struct {
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
View File

@@ -0,0 +1,52 @@
// Package infra gère l'infrastructure (serveurs, containers, nginx).
package infra
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
// DB représente la connexion à la base SQLite.
type DB struct {
*sql.DB
}
// Open ouvre ou crée la base de données SQLite.
func Open(dbPath string) (*DB, error) {
// Créer le répertoire si nécessaire
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("create db directory: %w", err)
}
// Ouvrir la connexion
sqlDB, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// Tester la connexion
if err := sqlDB.Ping(); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
db := &DB{sqlDB}
// Exécuter les migrations
if err := db.Migrate(); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("migrate database: %w", err)
}
return db, nil
}
// Close ferme la connexion.
func (db *DB) Close() error {
return db.DB.Close()
}

227
internal/infra/incus.go Normal file
View File

@@ -0,0 +1,227 @@
// Package infra gère l'infrastructure (serveurs, containers, nginx).
package infra
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// IncusContainer représente un container Incus retourné par incus list.
type IncusContainer struct {
Name string `json:"name"`
State string `json:"state"`
IPv4 []string `json:"ipv4"`
IPv6 []string `json:"ipv6"`
Type string `json:"type"`
Snapshots int `json:"snapshots"`
Location string `json:"location"`
Image string `json:"-"` // Rempli séparément
CreatedAt string `json:"created_at"`
}
// incusListJSON représente le format JSON de incus list.
type incusListJSON struct {
Name string `json:"name"`
Status string `json:"status"`
Type string `json:"type"`
Snapshots int `json:"snapshots"`
Location string `json:"location"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
}
// ListIncusContainers liste les containers Incus sur le serveur.
func (c *SSHClient) ListIncusContainers(ctx context.Context) ([]IncusContainer, error) {
// Récupérer la liste en format JSON
result, err := c.Exec(ctx, "incus list --format json")
if err != nil {
return nil, fmt.Errorf("incus list: %w", err)
}
if result.ExitCode != 0 {
return nil, fmt.Errorf("incus list failed: %s", result.Stderr)
}
// Parser le JSON
var rawContainers []struct {
Name string `json:"name"`
Status string `json:"status"`
StatusCode int `json:"status_code"`
Type string `json:"type"`
Config struct {
Image string `json:"image.description"`
} `json:"config"`
State struct {
Network map[string]struct {
Addresses []struct {
Address string `json:"address"`
Family string `json:"family"`
Scope string `json:"scope"`
} `json:"addresses"`
} `json:"network"`
} `json:"state"`
Snapshots []interface{} `json:"snapshots"`
Location string `json:"location"`
CreatedAt string `json:"created_at"`
}
if err := json.Unmarshal([]byte(result.Stdout), &rawContainers); err != nil {
return nil, fmt.Errorf("parse incus json: %w", err)
}
// Convertir en notre format
containers := make([]IncusContainer, 0, len(rawContainers))
for _, rc := range rawContainers {
container := IncusContainer{
Name: rc.Name,
State: strings.ToLower(rc.Status),
Type: rc.Type,
Snapshots: len(rc.Snapshots),
Location: rc.Location,
Image: rc.Config.Image,
CreatedAt: rc.CreatedAt,
}
// Extraire les IPs
for _, net := range rc.State.Network {
for _, addr := range net.Addresses {
if addr.Scope == "global" {
if addr.Family == "inet" {
container.IPv4 = append(container.IPv4, addr.Address)
} else if addr.Family == "inet6" {
container.IPv6 = append(container.IPv6, addr.Address)
}
}
}
}
containers = append(containers, container)
}
return containers, nil
}
// GetIncusContainer récupère les infos d'un container spécifique.
func (c *SSHClient) GetIncusContainer(ctx context.Context, name string) (*IncusContainer, error) {
containers, err := c.ListIncusContainers(ctx)
if err != nil {
return nil, err
}
for _, container := range containers {
if container.Name == name {
return &container, nil
}
}
return nil, fmt.Errorf("container %s not found", name)
}
// StartIncusContainer démarre un container.
func (c *SSHClient) StartIncusContainer(ctx context.Context, name string) error {
result, err := c.Exec(ctx, fmt.Sprintf("incus start %s", name))
if err != nil {
return fmt.Errorf("incus start: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("incus start %s failed: %s", name, result.Stderr)
}
return nil
}
// StopIncusContainer arrête un container.
func (c *SSHClient) StopIncusContainer(ctx context.Context, name string) error {
result, err := c.Exec(ctx, fmt.Sprintf("incus stop %s", name))
if err != nil {
return fmt.Errorf("incus stop: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("incus stop %s failed: %s", name, result.Stderr)
}
return nil
}
// RestartIncusContainer redémarre un container.
func (c *SSHClient) RestartIncusContainer(ctx context.Context, name string) error {
result, err := c.Exec(ctx, fmt.Sprintf("incus restart %s", name))
if err != nil {
return fmt.Errorf("incus restart: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("incus restart %s failed: %s", name, result.Stderr)
}
return nil
}
// ExecInContainer exécute une commande dans un container.
func (c *SSHClient) ExecInContainer(ctx context.Context, containerName, cmd string) (*SSHResult, error) {
fullCmd := fmt.Sprintf("incus exec %s -- %s", containerName, cmd)
return c.Exec(ctx, fullCmd)
}
// ExecInContainerSimple exécute une commande et retourne stdout.
func (c *SSHClient) ExecInContainerSimple(ctx context.Context, containerName, cmd string) (string, error) {
result, err := c.ExecInContainer(ctx, containerName, cmd)
if err != nil {
return "", err
}
if result.ExitCode != 0 {
return "", fmt.Errorf("command failed in %s (exit %d): %s", containerName, result.ExitCode, result.Stderr)
}
return strings.TrimSpace(result.Stdout), nil
}
// PushFileToContainer envoie un fichier vers un container.
func (c *SSHClient) PushFileToContainer(ctx context.Context, containerName, localPath, remotePath string) error {
// D'abord copier vers le serveur hôte
tempPath := fmt.Sprintf("/tmp/incus-push-%d", ctx.Value("request_id"))
if err := c.CopyFile(ctx, localPath, tempPath); err != nil {
return fmt.Errorf("copy to host: %w", err)
}
// Puis push vers le container
result, err := c.Exec(ctx, fmt.Sprintf("incus file push %s %s%s && rm %s",
tempPath, containerName, remotePath, tempPath))
if err != nil {
return fmt.Errorf("incus file push: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("incus file push failed: %s", result.Stderr)
}
return nil
}
// PullFileFromContainer récupère un fichier depuis un container.
func (c *SSHClient) PullFileFromContainer(ctx context.Context, containerName, remotePath, localPath string) error {
tempPath := fmt.Sprintf("/tmp/incus-pull-%d", ctx.Value("request_id"))
// Pull depuis le container vers l'hôte
result, err := c.Exec(ctx, fmt.Sprintf("incus file pull %s%s %s",
containerName, remotePath, tempPath))
if err != nil {
return fmt.Errorf("incus file pull: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("incus file pull failed: %s", result.Stderr)
}
// Puis copier vers local
if err := c.CopyFrom(ctx, tempPath, localPath); err != nil {
return fmt.Errorf("copy from host: %w", err)
}
// Nettoyer
c.Exec(ctx, fmt.Sprintf("rm %s", tempPath))
return nil
}
// GetContainerLogs récupère les logs d'un container.
func (c *SSHClient) GetContainerLogs(ctx context.Context, containerName string, lines int) (string, error) {
if lines <= 0 {
lines = 100
}
return c.ExecInContainerSimple(ctx, containerName, fmt.Sprintf("journalctl -n %d --no-pager", lines))
}

View File

@@ -0,0 +1,92 @@
// Package infra gère l'infrastructure (serveurs, containers, nginx).
package infra
import "fmt"
// migrations contient les migrations SQL à exécuter dans l'ordre.
var migrations = []string{
// Table servers
`CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
host TEXT NOT NULL,
vpn_ip TEXT,
ssh_port INTEGER NOT NULL DEFAULT 22,
ssh_user TEXT NOT NULL DEFAULT 'root',
ssh_key_file TEXT NOT NULL,
has_incus INTEGER NOT NULL DEFAULT 0,
has_nginx INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'unknown',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
// Table containers
`CREATE TABLE IF NOT EXISTS containers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
name TEXT NOT NULL,
incus_name TEXT NOT NULL,
ip TEXT,
vpn_ip TEXT,
image TEXT,
status TEXT NOT NULL DEFAULT 'unknown',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
UNIQUE(server_id, incus_name)
)`,
// Table nginx_configs
`CREATE TABLE IF NOT EXISTS nginx_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
domain TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'proxy',
template TEXT,
upstream TEXT,
ssl_enabled INTEGER NOT NULL DEFAULT 1,
config_content TEXT,
status TEXT NOT NULL DEFAULT 'inactive',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
UNIQUE(server_id, domain)
)`,
// Table app_bindings
`CREATE TABLE IF NOT EXISTS app_bindings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_id TEXT NOT NULL,
container_id INTEGER,
nginx_config_id INTEGER,
server_id INTEGER,
type TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (container_id) REFERENCES containers(id) ON DELETE SET NULL,
FOREIGN KEY (nginx_config_id) REFERENCES nginx_configs(id) ON DELETE SET NULL,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL
)`,
// Index pour accélérer les requêtes
`CREATE INDEX IF NOT EXISTS idx_containers_server ON containers(server_id)`,
`CREATE INDEX IF NOT EXISTS idx_nginx_configs_server ON nginx_configs(server_id)`,
`CREATE INDEX IF NOT EXISTS idx_nginx_configs_domain ON nginx_configs(domain)`,
`CREATE INDEX IF NOT EXISTS idx_app_bindings_app ON app_bindings(app_id)`,
// Table migrations pour tracking des versions
`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
}
// Migrate exécute toutes les migrations.
func (db *DB) Migrate() error {
for i, migration := range migrations {
if _, err := db.Exec(migration); err != nil {
return fmt.Errorf("migration %d failed: %w", i+1, err)
}
}
return nil
}

129
internal/infra/models.go Normal file
View File

@@ -0,0 +1,129 @@
// Package infra gère l'infrastructure (serveurs, containers, nginx).
package infra
import "time"
// ServerStatus représente l'état d'un serveur.
type ServerStatus string
const (
ServerStatusOnline ServerStatus = "online"
ServerStatusOffline ServerStatus = "offline"
ServerStatusUnknown ServerStatus = "unknown"
)
// ContainerStatus représente l'état d'un container.
type ContainerStatus string
const (
ContainerStatusRunning ContainerStatus = "running"
ContainerStatusStopped ContainerStatus = "stopped"
ContainerStatusUnknown ContainerStatus = "unknown"
)
// NginxConfigStatus représente l'état d'une config Nginx.
type NginxConfigStatus string
const (
NginxConfigStatusActive NginxConfigStatus = "active"
NginxConfigStatusInactive NginxConfigStatus = "inactive"
NginxConfigStatusError NginxConfigStatus = "error"
)
// NginxConfigType représente le type de config Nginx.
type NginxConfigType string
const (
NginxTypeProxy NginxConfigType = "proxy"
NginxTypeStatic NginxConfigType = "static"
NginxTypeSocket NginxConfigType = "socket"
)
// BindingType représente le type de binding app.
type BindingType string
const (
BindingTypeContainer BindingType = "container"
BindingTypeNginx BindingType = "nginx"
BindingTypeServer BindingType = "server"
)
// Server représente un serveur physique ou VM.
type Server struct {
ID int64 `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
VpnIP string `json:"vpn_ip,omitempty"`
SSHPort int `json:"ssh_port"`
SSHUser string `json:"ssh_user"`
SSHKeyFile string `json:"ssh_key_file"`
HasIncus bool `json:"has_incus"`
HasNginx bool `json:"has_nginx"`
Status ServerStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Container représente un container Incus.
type Container struct {
ID int64 `json:"id"`
ServerID int64 `json:"server_id"`
Name string `json:"name"`
IncusName string `json:"incus_name"`
IP string `json:"ip"`
VpnIP string `json:"vpn_ip,omitempty"`
Image string `json:"image"`
Status ContainerStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Relation (populé par query)
Server *Server `json:"server,omitempty"`
}
// NginxConfig représente une configuration Nginx.
type NginxConfig struct {
ID int64 `json:"id"`
ServerID int64 `json:"server_id"`
Domain string `json:"domain"`
Type NginxConfigType `json:"type"`
Template string `json:"template,omitempty"`
Upstream string `json:"upstream,omitempty"`
SSLEnabled bool `json:"ssl_enabled"`
ConfigContent string `json:"config_content,omitempty"`
Status NginxConfigStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Relation (populé par query)
Server *Server `json:"server,omitempty"`
}
// AppBinding représente le lien entre une app SOGOMS et l'infrastructure.
type AppBinding struct {
ID int64 `json:"id"`
AppID string `json:"app_id"`
ContainerID *int64 `json:"container_id,omitempty"`
NginxConfigID *int64 `json:"nginx_config_id,omitempty"`
ServerID *int64 `json:"server_id,omitempty"`
Type BindingType `json:"type"`
CreatedAt time.Time `json:"created_at"`
// Relations (populées par query)
Container *Container `json:"container,omitempty"`
NginxConfig *NginxConfig `json:"nginx_config,omitempty"`
Server *Server `json:"server,omitempty"`
}
// ServerWithContainers représente un serveur avec ses containers.
type ServerWithContainers struct {
Server
Containers []Container `json:"containers"`
}
// InfraOverview représente une vue globale de l'infrastructure.
type InfraOverview struct {
Servers []ServerWithContainers `json:"servers"`
NginxConfigs []NginxConfig `json:"nginx_configs"`
AppBindings []AppBinding `json:"app_bindings"`
}

306
internal/infra/nginx.go Normal file
View File

@@ -0,0 +1,306 @@
// Package infra gère l'infrastructure (serveurs, containers, nginx).
package infra
import (
"context"
"fmt"
"path/filepath"
"strings"
)
const (
// NginxSitesAvailable est le répertoire des sites disponibles.
NginxSitesAvailable = "/etc/nginx/sites-available"
// NginxSitesEnabled est le répertoire des sites activés.
NginxSitesEnabled = "/etc/nginx/sites-enabled"
)
// NginxSiteInfo représente les infos d'un site Nginx.
type NginxSiteInfo struct {
Name string
Enabled bool
Config string
HasSSL bool
Domains []string
Upstream string
}
// TestNginxConfig teste la configuration Nginx.
func (c *SSHClient) TestNginxConfig(ctx context.Context) error {
result, err := c.Exec(ctx, "nginx -t")
if err != nil {
return fmt.Errorf("nginx test: %w", err)
}
// nginx -t écrit sur stderr même en cas de succès
if result.ExitCode != 0 {
return fmt.Errorf("nginx config invalid: %s", result.Stderr)
}
return nil
}
// ReloadNginx recharge la configuration Nginx.
func (c *SSHClient) ReloadNginx(ctx context.Context) error {
// D'abord tester la config
if err := c.TestNginxConfig(ctx); err != nil {
return err
}
result, err := c.Exec(ctx, "systemctl reload nginx")
if err != nil {
return fmt.Errorf("nginx reload: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("nginx reload failed: %s", result.Stderr)
}
return nil
}
// RestartNginx redémarre Nginx.
func (c *SSHClient) RestartNginx(ctx context.Context) error {
result, err := c.Exec(ctx, "systemctl restart nginx")
if err != nil {
return fmt.Errorf("nginx restart: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("nginx restart failed: %s", result.Stderr)
}
return nil
}
// NginxStatus retourne le statut de Nginx.
func (c *SSHClient) NginxStatus(ctx context.Context) (string, error) {
result, err := c.Exec(ctx, "systemctl is-active nginx")
if err != nil {
return "unknown", nil
}
return strings.TrimSpace(result.Stdout), nil
}
// ListNginxSites liste les sites Nginx configurés.
func (c *SSHClient) ListNginxSites(ctx context.Context) ([]NginxSiteInfo, error) {
// Lister sites-available
result, err := c.Exec(ctx, fmt.Sprintf("ls -1 %s", NginxSitesAvailable))
if err != nil {
return nil, fmt.Errorf("list sites-available: %w", err)
}
if result.ExitCode != 0 {
return nil, fmt.Errorf("list sites-available failed: %s", result.Stderr)
}
// Lister sites-enabled
enabledResult, err := c.Exec(ctx, fmt.Sprintf("ls -1 %s", NginxSitesEnabled))
if err != nil {
return nil, fmt.Errorf("list sites-enabled: %w", err)
}
enabledSet := make(map[string]bool)
for _, name := range strings.Split(enabledResult.Stdout, "\n") {
name = strings.TrimSpace(name)
if name != "" {
enabledSet[name] = true
}
}
var sites []NginxSiteInfo
for _, name := range strings.Split(result.Stdout, "\n") {
name = strings.TrimSpace(name)
if name == "" || name == "default" {
continue
}
site := NginxSiteInfo{
Name: name,
Enabled: enabledSet[name],
}
sites = append(sites, site)
}
return sites, nil
}
// GetNginxSiteConfig récupère la config d'un site.
func (c *SSHClient) GetNginxSiteConfig(ctx context.Context, name string) (string, error) {
path := filepath.Join(NginxSitesAvailable, name)
content, err := c.ReadFile(ctx, path)
if err != nil {
return "", fmt.Errorf("read site config: %w", err)
}
return string(content), nil
}
// WriteNginxSiteConfig écrit la config d'un site.
func (c *SSHClient) WriteNginxSiteConfig(ctx context.Context, name string, config string) error {
path := filepath.Join(NginxSitesAvailable, name)
return c.WriteFile(ctx, path, []byte(config), "644")
}
// EnableNginxSite active un site (crée le lien symbolique).
func (c *SSHClient) EnableNginxSite(ctx context.Context, name string) error {
src := filepath.Join(NginxSitesAvailable, name)
dst := filepath.Join(NginxSitesEnabled, name)
// Vérifier que le site existe
exists, err := c.FileExists(ctx, src)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("site %s does not exist", name)
}
// Créer le lien
result, err := c.Exec(ctx, fmt.Sprintf("ln -sf %s %s", src, dst))
if err != nil {
return fmt.Errorf("enable site: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("enable site failed: %s", result.Stderr)
}
return nil
}
// DisableNginxSite désactive un site (supprime le lien symbolique).
func (c *SSHClient) DisableNginxSite(ctx context.Context, name string) error {
path := filepath.Join(NginxSitesEnabled, name)
result, err := c.Exec(ctx, fmt.Sprintf("rm -f %s", path))
if err != nil {
return fmt.Errorf("disable site: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("disable site failed: %s", result.Stderr)
}
return nil
}
// DeleteNginxSite supprime un site (désactive puis supprime).
func (c *SSHClient) DeleteNginxSite(ctx context.Context, name string) error {
// D'abord désactiver
if err := c.DisableNginxSite(ctx, name); err != nil {
return err
}
// Puis supprimer
path := filepath.Join(NginxSitesAvailable, name)
result, err := c.Exec(ctx, fmt.Sprintf("rm -f %s", path))
if err != nil {
return fmt.Errorf("delete site: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("delete site failed: %s", result.Stderr)
}
return nil
}
// DeployNginxSite déploie un site complet (écrire, activer, recharger).
func (c *SSHClient) DeployNginxSite(ctx context.Context, name string, config string) error {
// Écrire la config
if err := c.WriteNginxSiteConfig(ctx, name, config); err != nil {
return fmt.Errorf("write config: %w", err)
}
// Activer le site
if err := c.EnableNginxSite(ctx, name); err != nil {
return fmt.Errorf("enable site: %w", err)
}
// Recharger Nginx
if err := c.ReloadNginx(ctx); err != nil {
// En cas d'erreur, désactiver le site
c.DisableNginxSite(ctx, name)
return fmt.Errorf("reload nginx: %w", err)
}
return nil
}
// GenerateNginxProxyConfig génère une config proxy standard.
func GenerateNginxProxyConfig(domain, upstream string, ssl bool) string {
var config strings.Builder
if ssl {
// Redirect HTTP to HTTPS
config.WriteString(fmt.Sprintf(`server {
listen 80;
listen [::]:80;
server_name %s;
return 301 https://$server_name$request_uri;
}
`, domain))
}
// Main server block
if ssl {
config.WriteString(fmt.Sprintf(`server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name %s;
ssl_certificate /etc/letsencrypt/live/%s/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/%s/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
location / {
proxy_pass %s;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
}
`, domain, domain, domain, upstream))
} else {
config.WriteString(fmt.Sprintf(`server {
listen 80;
listen [::]:80;
server_name %s;
location / {
proxy_pass %s;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
}
`, domain, upstream))
}
return config.String()
}
// RequestSSLCertificate demande un certificat Let's Encrypt.
func (c *SSHClient) RequestSSLCertificate(ctx context.Context, domain, email string) error {
cmd := fmt.Sprintf("certbot certonly --nginx -d %s --non-interactive --agree-tos -m %s", domain, email)
result, err := c.Exec(ctx, cmd)
if err != nil {
return fmt.Errorf("certbot: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("certbot failed: %s", result.Stderr)
}
return nil
}
// CheckSSLCertificate vérifie si un certificat SSL existe.
func (c *SSHClient) CheckSSLCertificate(ctx context.Context, domain string) (bool, error) {
path := fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", domain)
return c.FileExists(ctx, path)
}

View File

@@ -0,0 +1,469 @@
// Package infra gère l'infrastructure (serveurs, containers, nginx).
package infra
import (
"database/sql"
"fmt"
"time"
)
// ============================================================================
// Servers
// ============================================================================
// CreateServer crée un nouveau serveur.
func (db *DB) CreateServer(s *Server) error {
now := time.Now()
result, err := db.Exec(`
INSERT INTO servers (name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.Name, s.Host, nullString(s.VpnIP), s.SSHPort, s.SSHUser, s.SSHKeyFile,
boolToInt(s.HasIncus), boolToInt(s.HasNginx), s.Status, now, now)
if err != nil {
return fmt.Errorf("insert server: %w", err)
}
id, _ := result.LastInsertId()
s.ID = id
s.CreatedAt = now
s.UpdatedAt = now
return nil
}
// GetServer récupère un serveur par ID.
func (db *DB) GetServer(id int64) (*Server, error) {
s := &Server{}
err := db.QueryRow(`
SELECT id, name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at
FROM servers WHERE id = ?`, id).Scan(
&s.ID, &s.Name, &s.Host, &s.VpnIP, &s.SSHPort, &s.SSHUser, &s.SSHKeyFile,
&s.HasIncus, &s.HasNginx, &s.Status, &s.CreatedAt, &s.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get server: %w", err)
}
return s, nil
}
// GetServerByName récupère un serveur par nom.
func (db *DB) GetServerByName(name string) (*Server, error) {
s := &Server{}
err := db.QueryRow(`
SELECT id, name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at
FROM servers WHERE name = ?`, name).Scan(
&s.ID, &s.Name, &s.Host, &s.VpnIP, &s.SSHPort, &s.SSHUser, &s.SSHKeyFile,
&s.HasIncus, &s.HasNginx, &s.Status, &s.CreatedAt, &s.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get server by name: %w", err)
}
return s, nil
}
// ListServers retourne tous les serveurs.
func (db *DB) ListServers() ([]Server, error) {
rows, err := db.Query(`
SELECT id, name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at
FROM servers ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("list servers: %w", err)
}
defer rows.Close()
var servers []Server
for rows.Next() {
var s Server
if err := rows.Scan(&s.ID, &s.Name, &s.Host, &s.VpnIP, &s.SSHPort, &s.SSHUser, &s.SSHKeyFile,
&s.HasIncus, &s.HasNginx, &s.Status, &s.CreatedAt, &s.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan server: %w", err)
}
servers = append(servers, s)
}
return servers, nil
}
// UpdateServer met à jour un serveur.
func (db *DB) UpdateServer(s *Server) error {
s.UpdatedAt = time.Now()
_, err := db.Exec(`
UPDATE servers SET name=?, host=?, vpn_ip=?, ssh_port=?, ssh_user=?, ssh_key_file=?,
has_incus=?, has_nginx=?, status=?, updated_at=? WHERE id=?`,
s.Name, s.Host, nullString(s.VpnIP), s.SSHPort, s.SSHUser, s.SSHKeyFile,
boolToInt(s.HasIncus), boolToInt(s.HasNginx), s.Status, s.UpdatedAt, s.ID)
if err != nil {
return fmt.Errorf("update server: %w", err)
}
return nil
}
// UpdateServerStatus met à jour le statut d'un serveur.
func (db *DB) UpdateServerStatus(id int64, status ServerStatus) error {
_, err := db.Exec(`UPDATE servers SET status=?, updated_at=? WHERE id=?`,
status, time.Now(), id)
if err != nil {
return fmt.Errorf("update server status: %w", err)
}
return nil
}
// DeleteServer supprime un serveur.
func (db *DB) DeleteServer(id int64) error {
_, err := db.Exec(`DELETE FROM servers WHERE id=?`, id)
if err != nil {
return fmt.Errorf("delete server: %w", err)
}
return nil
}
// ============================================================================
// Containers
// ============================================================================
// CreateContainer crée un nouveau container.
func (db *DB) CreateContainer(c *Container) error {
now := time.Now()
result, err := db.Exec(`
INSERT INTO containers (server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ServerID, c.Name, c.IncusName, nullString(c.IP), nullString(c.VpnIP), c.Image, c.Status, now, now)
if err != nil {
return fmt.Errorf("insert container: %w", err)
}
id, _ := result.LastInsertId()
c.ID = id
c.CreatedAt = now
c.UpdatedAt = now
return nil
}
// GetContainer récupère un container par ID.
func (db *DB) GetContainer(id int64) (*Container, error) {
c := &Container{}
err := db.QueryRow(`
SELECT id, server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at
FROM containers WHERE id = ?`, id).Scan(
&c.ID, &c.ServerID, &c.Name, &c.IncusName, &c.IP, &c.VpnIP, &c.Image, &c.Status, &c.CreatedAt, &c.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get container: %w", err)
}
return c, nil
}
// ListContainersByServer retourne les containers d'un serveur.
func (db *DB) ListContainersByServer(serverID int64) ([]Container, error) {
rows, err := db.Query(`
SELECT id, server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at
FROM containers WHERE server_id = ? ORDER BY name`, serverID)
if err != nil {
return nil, fmt.Errorf("list containers: %w", err)
}
defer rows.Close()
var containers []Container
for rows.Next() {
var c Container
if err := rows.Scan(&c.ID, &c.ServerID, &c.Name, &c.IncusName, &c.IP, &c.VpnIP, &c.Image, &c.Status, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan container: %w", err)
}
containers = append(containers, c)
}
return containers, nil
}
// ListAllContainers retourne tous les containers.
func (db *DB) ListAllContainers() ([]Container, error) {
rows, err := db.Query(`
SELECT id, server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at
FROM containers ORDER BY server_id, name`)
if err != nil {
return nil, fmt.Errorf("list all containers: %w", err)
}
defer rows.Close()
var containers []Container
for rows.Next() {
var c Container
if err := rows.Scan(&c.ID, &c.ServerID, &c.Name, &c.IncusName, &c.IP, &c.VpnIP, &c.Image, &c.Status, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan container: %w", err)
}
containers = append(containers, c)
}
return containers, nil
}
// UpdateContainer met à jour un container.
func (db *DB) UpdateContainer(c *Container) error {
c.UpdatedAt = time.Now()
_, err := db.Exec(`
UPDATE containers SET server_id=?, name=?, incus_name=?, ip=?, vpn_ip=?, image=?, status=?, updated_at=?
WHERE id=?`,
c.ServerID, c.Name, c.IncusName, nullString(c.IP), nullString(c.VpnIP), c.Image, c.Status, c.UpdatedAt, c.ID)
if err != nil {
return fmt.Errorf("update container: %w", err)
}
return nil
}
// UpdateContainerStatus met à jour le statut d'un container.
func (db *DB) UpdateContainerStatus(id int64, status ContainerStatus) error {
_, err := db.Exec(`UPDATE containers SET status=?, updated_at=? WHERE id=?`,
status, time.Now(), id)
if err != nil {
return fmt.Errorf("update container status: %w", err)
}
return nil
}
// DeleteContainer supprime un container.
func (db *DB) DeleteContainer(id int64) error {
_, err := db.Exec(`DELETE FROM containers WHERE id=?`, id)
if err != nil {
return fmt.Errorf("delete container: %w", err)
}
return nil
}
// ============================================================================
// NginxConfigs
// ============================================================================
// CreateNginxConfig crée une nouvelle config Nginx.
func (db *DB) CreateNginxConfig(n *NginxConfig) error {
now := time.Now()
result, err := db.Exec(`
INSERT INTO nginx_configs (server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n.ServerID, n.Domain, n.Type, nullString(n.Template), nullString(n.Upstream),
boolToInt(n.SSLEnabled), nullString(n.ConfigContent), n.Status, now, now)
if err != nil {
return fmt.Errorf("insert nginx config: %w", err)
}
id, _ := result.LastInsertId()
n.ID = id
n.CreatedAt = now
n.UpdatedAt = now
return nil
}
// GetNginxConfig récupère une config Nginx par ID.
func (db *DB) GetNginxConfig(id int64) (*NginxConfig, error) {
n := &NginxConfig{}
err := db.QueryRow(`
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
FROM nginx_configs WHERE id = ?`, id).Scan(
&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get nginx config: %w", err)
}
return n, nil
}
// GetNginxConfigByDomain récupère une config par domaine.
func (db *DB) GetNginxConfigByDomain(serverID int64, domain string) (*NginxConfig, error) {
n := &NginxConfig{}
err := db.QueryRow(`
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
FROM nginx_configs WHERE server_id = ? AND domain = ?`, serverID, domain).Scan(
&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get nginx config by domain: %w", err)
}
return n, nil
}
// ListNginxConfigsByServer retourne les configs d'un serveur.
func (db *DB) ListNginxConfigsByServer(serverID int64) ([]NginxConfig, error) {
rows, err := db.Query(`
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
FROM nginx_configs WHERE server_id = ? ORDER BY domain`, serverID)
if err != nil {
return nil, fmt.Errorf("list nginx configs: %w", err)
}
defer rows.Close()
var configs []NginxConfig
for rows.Next() {
var n NginxConfig
if err := rows.Scan(&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan nginx config: %w", err)
}
configs = append(configs, n)
}
return configs, nil
}
// ListAllNginxConfigs retourne toutes les configs.
func (db *DB) ListAllNginxConfigs() ([]NginxConfig, error) {
rows, err := db.Query(`
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
FROM nginx_configs ORDER BY server_id, domain`)
if err != nil {
return nil, fmt.Errorf("list all nginx configs: %w", err)
}
defer rows.Close()
var configs []NginxConfig
for rows.Next() {
var n NginxConfig
if err := rows.Scan(&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan nginx config: %w", err)
}
configs = append(configs, n)
}
return configs, nil
}
// UpdateNginxConfig met à jour une config.
func (db *DB) UpdateNginxConfig(n *NginxConfig) error {
n.UpdatedAt = time.Now()
_, err := db.Exec(`
UPDATE nginx_configs SET server_id=?, domain=?, type=?, template=?, upstream=?,
ssl_enabled=?, config_content=?, status=?, updated_at=? WHERE id=?`,
n.ServerID, n.Domain, n.Type, nullString(n.Template), nullString(n.Upstream),
boolToInt(n.SSLEnabled), nullString(n.ConfigContent), n.Status, n.UpdatedAt, n.ID)
if err != nil {
return fmt.Errorf("update nginx config: %w", err)
}
return nil
}
// DeleteNginxConfig supprime une config.
func (db *DB) DeleteNginxConfig(id int64) error {
_, err := db.Exec(`DELETE FROM nginx_configs WHERE id=?`, id)
if err != nil {
return fmt.Errorf("delete nginx config: %w", err)
}
return nil
}
// ============================================================================
// AppBindings
// ============================================================================
// CreateAppBinding crée un nouveau binding.
func (db *DB) CreateAppBinding(b *AppBinding) error {
now := time.Now()
result, err := db.Exec(`
INSERT INTO app_bindings (app_id, container_id, nginx_config_id, server_id, type, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
b.AppID, nullInt64(b.ContainerID), nullInt64(b.NginxConfigID), nullInt64(b.ServerID), b.Type, now)
if err != nil {
return fmt.Errorf("insert app binding: %w", err)
}
id, _ := result.LastInsertId()
b.ID = id
b.CreatedAt = now
return nil
}
// ListAppBindings retourne les bindings d'une app.
func (db *DB) ListAppBindings(appID string) ([]AppBinding, error) {
rows, err := db.Query(`
SELECT id, app_id, container_id, nginx_config_id, server_id, type, created_at
FROM app_bindings WHERE app_id = ?`, appID)
if err != nil {
return nil, fmt.Errorf("list app bindings: %w", err)
}
defer rows.Close()
var bindings []AppBinding
for rows.Next() {
var b AppBinding
if err := rows.Scan(&b.ID, &b.AppID, &b.ContainerID, &b.NginxConfigID, &b.ServerID, &b.Type, &b.CreatedAt); err != nil {
return nil, fmt.Errorf("scan app binding: %w", err)
}
bindings = append(bindings, b)
}
return bindings, nil
}
// DeleteAppBinding supprime un binding.
func (db *DB) DeleteAppBinding(id int64) error {
_, err := db.Exec(`DELETE FROM app_bindings WHERE id=?`, id)
if err != nil {
return fmt.Errorf("delete app binding: %w", err)
}
return nil
}
// DeleteAppBindingsByApp supprime tous les bindings d'une app.
func (db *DB) DeleteAppBindingsByApp(appID string) error {
_, err := db.Exec(`DELETE FROM app_bindings WHERE app_id=?`, appID)
if err != nil {
return fmt.Errorf("delete app bindings: %w", err)
}
return nil
}
// ============================================================================
// Helpers
// ============================================================================
func nullString(s string) interface{} {
if s == "" {
return nil
}
return s
}
func nullInt64(i *int64) interface{} {
if i == nil {
return nil
}
return *i
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// ============================================================================
// Aggregate queries
// ============================================================================
// GetInfraOverview retourne une vue complète de l'infrastructure.
func (db *DB) GetInfraOverview() (*InfraOverview, error) {
servers, err := db.ListServers()
if err != nil {
return nil, err
}
overview := &InfraOverview{
Servers: make([]ServerWithContainers, len(servers)),
}
for i, s := range servers {
overview.Servers[i].Server = s
containers, err := db.ListContainersByServer(s.ID)
if err != nil {
return nil, err
}
overview.Servers[i].Containers = containers
}
overview.NginxConfigs, err = db.ListAllNginxConfigs()
if err != nil {
return nil, err
}
return overview, nil
}

335
internal/infra/ssh.go Normal file
View File

@@ -0,0 +1,335 @@
// Package infra gère l'infrastructure (serveurs, containers, nginx).
package infra
import (
"bytes"
"context"
"fmt"
"io"
"os"
"strings"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
// SSHClient représente une connexion SSH à un serveur.
type SSHClient struct {
server *Server
client *ssh.Client
mu sync.Mutex
}
// SSHPool gère un pool de connexions SSH.
type SSHPool struct {
clients map[int64]*SSHClient
mu sync.RWMutex
timeout time.Duration
}
// SSHResult représente le résultat d'une commande SSH.
type SSHResult struct {
Stdout string
Stderr string
ExitCode int
Duration time.Duration
}
// NewSSHPool crée un nouveau pool SSH.
func NewSSHPool(timeout time.Duration) *SSHPool {
if timeout == 0 {
timeout = 30 * time.Second
}
return &SSHPool{
clients: make(map[int64]*SSHClient),
timeout: timeout,
}
}
// Connect établit une connexion SSH à un serveur.
func (p *SSHPool) Connect(server *Server) (*SSHClient, error) {
p.mu.Lock()
defer p.mu.Unlock()
// Vérifier si déjà connecté
if client, ok := p.clients[server.ID]; ok {
if client.isAlive() {
return client, nil
}
// Connexion morte, la supprimer
client.Close()
delete(p.clients, server.ID)
}
// Lire la clé SSH
keyData, err := os.ReadFile(server.SSHKeyFile)
if err != nil {
return nil, fmt.Errorf("read ssh key: %w", err)
}
signer, err := ssh.ParsePrivateKey(keyData)
if err != nil {
return nil, fmt.Errorf("parse ssh key: %w", err)
}
// Config SSH
config := &ssh.ClientConfig{
User: server.SSHUser,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: gérer les known_hosts
Timeout: p.timeout,
}
// Connexion
addr := fmt.Sprintf("%s:%d", server.Host, server.SSHPort)
sshClient, err := ssh.Dial("tcp", addr, config)
if err != nil {
return nil, fmt.Errorf("ssh dial %s: %w", addr, err)
}
client := &SSHClient{
server: server,
client: sshClient,
}
p.clients[server.ID] = client
return client, nil
}
// Get récupère un client existant ou nil.
func (p *SSHPool) Get(serverID int64) *SSHClient {
p.mu.RLock()
defer p.mu.RUnlock()
return p.clients[serverID]
}
// Disconnect ferme la connexion à un serveur.
func (p *SSHPool) Disconnect(serverID int64) {
p.mu.Lock()
defer p.mu.Unlock()
if client, ok := p.clients[serverID]; ok {
client.Close()
delete(p.clients, serverID)
}
}
// CloseAll ferme toutes les connexions.
func (p *SSHPool) CloseAll() {
p.mu.Lock()
defer p.mu.Unlock()
for id, client := range p.clients {
client.Close()
delete(p.clients, id)
}
}
// isAlive vérifie si la connexion est active.
func (c *SSHClient) isAlive() bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.client == nil {
return false
}
// Test rapide avec une session
session, err := c.client.NewSession()
if err != nil {
return false
}
session.Close()
return true
}
// Close ferme la connexion.
func (c *SSHClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.client != nil {
err := c.client.Close()
c.client = nil
return err
}
return nil
}
// Exec exécute une commande sur le serveur distant.
func (c *SSHClient) Exec(ctx context.Context, cmd string) (*SSHResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.client == nil {
return nil, fmt.Errorf("ssh client not connected")
}
session, err := c.client.NewSession()
if err != nil {
return nil, fmt.Errorf("create session: %w", err)
}
defer session.Close()
var stdout, stderr bytes.Buffer
session.Stdout = &stdout
session.Stderr = &stderr
start := time.Now()
// Exécuter avec timeout
done := make(chan error, 1)
go func() {
done <- session.Run(cmd)
}()
select {
case <-ctx.Done():
session.Signal(ssh.SIGKILL)
return nil, ctx.Err()
case err := <-done:
result := &SSHResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
Duration: time.Since(start),
}
if err != nil {
if exitErr, ok := err.(*ssh.ExitError); ok {
result.ExitCode = exitErr.ExitStatus()
} else {
return nil, fmt.Errorf("exec command: %w", err)
}
}
return result, nil
}
}
// ExecSimple exécute une commande et retourne stdout.
func (c *SSHClient) ExecSimple(ctx context.Context, cmd string) (string, error) {
result, err := c.Exec(ctx, cmd)
if err != nil {
return "", err
}
if result.ExitCode != 0 {
return "", fmt.Errorf("command failed (exit %d): %s", result.ExitCode, result.Stderr)
}
return strings.TrimSpace(result.Stdout), nil
}
// WriteFile écrit un fichier sur le serveur distant via cat.
func (c *SSHClient) WriteFile(ctx context.Context, path string, content []byte, mode string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.client == nil {
return fmt.Errorf("ssh client not connected")
}
session, err := c.client.NewSession()
if err != nil {
return fmt.Errorf("create session: %w", err)
}
defer session.Close()
// Utiliser cat pour écrire le fichier
stdin, err := session.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
cmd := fmt.Sprintf("cat > %s && chmod %s %s", path, mode, path)
if err := session.Start(cmd); err != nil {
return fmt.Errorf("start command: %w", err)
}
if _, err := stdin.Write(content); err != nil {
return fmt.Errorf("write content: %w", err)
}
stdin.Close()
if err := session.Wait(); err != nil {
return fmt.Errorf("wait command: %w", err)
}
return nil
}
// ReadFile lit un fichier depuis le serveur distant.
func (c *SSHClient) ReadFile(ctx context.Context, path string) ([]byte, error) {
result, err := c.Exec(ctx, fmt.Sprintf("cat %s", path))
if err != nil {
return nil, err
}
if result.ExitCode != 0 {
return nil, fmt.Errorf("read file failed: %s", result.Stderr)
}
return []byte(result.Stdout), nil
}
// FileExists vérifie si un fichier existe.
func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) {
result, err := c.Exec(ctx, fmt.Sprintf("test -e %s && echo yes || echo no", path))
if err != nil {
return false, err
}
return strings.TrimSpace(result.Stdout) == "yes", nil
}
// SFTP retourne un client SFTP pour des opérations de fichiers avancées.
// Note: nécessite github.com/pkg/sftp si on veut un vrai SFTP.
// Pour l'instant on utilise des commandes shell.
// CopyFile copie un fichier local vers le serveur distant.
func (c *SSHClient) CopyFile(ctx context.Context, localPath, remotePath string) error {
content, err := os.ReadFile(localPath)
if err != nil {
return fmt.Errorf("read local file: %w", err)
}
return c.WriteFile(ctx, remotePath, content, "644")
}
// CopyFrom copie un fichier du serveur distant vers local.
func (c *SSHClient) CopyFrom(ctx context.Context, remotePath, localPath string) error {
content, err := c.ReadFile(ctx, remotePath)
if err != nil {
return err
}
return os.WriteFile(localPath, content, 0644)
}
// StreamExec exécute une commande et stream la sortie.
func (c *SSHClient) StreamExec(ctx context.Context, cmd string, stdout, stderr io.Writer) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.client == nil {
return fmt.Errorf("ssh client not connected")
}
session, err := c.client.NewSession()
if err != nil {
return fmt.Errorf("create session: %w", err)
}
defer session.Close()
session.Stdout = stdout
session.Stderr = stderr
done := make(chan error, 1)
go func() {
done <- session.Run(cmd)
}()
select {
case <-ctx.Done():
session.Signal(ssh.SIGKILL)
return ctx.Err()
case err := <-done:
return err
}
}