3 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
65da4efdad SOGOMS v1.0.3 - Admin UI, Cron, Config reload
Phase 13 : sogoms-cron
- Jobs planifiés avec schedule cron standard
- Types: query_email, http, service
- Actions: list, trigger, status

Phase 16 : Réorganisation config/apps/{app}/
- Tous les fichiers d'une app dans un seul dossier
- Migration prokov vers nouvelle structure

Phase 17 : sogoms-admin
- Interface web d'administration (Go templates + htmx)
- Auth sessions cookies signées HMAC-SHA256
- Rôles super_admin / app_admin avec permissions

Phase 19 : Création d'app via Admin UI
- Formulaire création app avec config DB/auth
- Bouton "Scanner la base" : introspection + schema.yaml
- Rechargement automatique sogoway via SIGHUP

Infrastructure :
- sogoctl : socket de contrôle /run/sogoctl.sock
- sogoway : reload config sur SIGHUP sans restart

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:30:56 +01:00
95 changed files with 10311 additions and 86 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
---
@@ -56,6 +56,8 @@ Client → Nginx(:443) → Sogoway(:8080) → Sogoms-* → MariaDB
| `sogoms-db` | Accès MariaDB, pool par application | `/run/sogoms-db.1.sock` |
| `sogoms-logs` | Logging centralisé, rotation auto | `/run/sogoms-logs.1.sock` |
| `sogoms-smtp` | Envoi d'emails, templates YAML | `/run/sogoms-smtp.1.sock` |
| `sogoms-cron` | Tâches planifiées, jobs périodiques | `/run/sogoms-cron.1.sock` |
| `sogoms-admin` | Interface web d'administration | TCP :9000 |
### sogoctl (Superviseur)
@@ -63,6 +65,9 @@ Client → Nginx(:443) → Sogoway(:8080) → Sogoms-* → MariaDB
- Health checks périodiques (socket ou HTTP)
- Redémarrage automatique en cas de crash
- Arrêt gracieux sur SIGTERM/SIGINT
- **Socket de contrôle** `/run/sogoctl.sock` pour commandes runtime :
- `reload <service>` : envoie SIGHUP au service (rechargement config)
- `status` : affiche l'état des services
```yaml
# config/sogoctl.yaml
@@ -96,6 +101,7 @@ services:
- Authentification JWT (HS256)
- CRUD générique paramétré par YAML
- Logging des événements (login, register)
- **Rechargement à chaud** : SIGHUP recharge registry + JWT sans restart
### sogoms-db (Base de données)
@@ -151,6 +157,167 @@ body_html: |
<p>Bienvenue sur notre plateforme.</p>
```
### sogoms-cron (Tâches planifiées)
Exécute des jobs périodiques définis en YAML avec support cron standard.
Actions disponibles :
- `list` : liste les jobs configurés avec prochain run
- `trigger` : déclenche un job manuellement
- `status` : historique des dernières exécutions
- `health` : statut OK
Types de jobs :
- `query_email` : requête DB + envoi email groupé par utilisateur
- `http` : appel HTTP (GET/POST) vers endpoint interne ou externe
- `service` : appel service interne (sogoms-db, sogoms-smtp, etc.)
Configuration dans `config/apps/{app}/cron.yaml` :
```yaml
timezone: Europe/Paris
retry:
max_attempts: 3
delay: 5m
history_days: 7
jobs:
tasks_today:
schedule: "0 8 * * 1-5" # 8h00 lun-ven
type: query_email
query: |
SELECT u.id AS user_id, u.email, u.name AS user_name,
t.title, p.name AS project_name, s.name AS status_name
FROM users u
INNER JOIN tasks t ON t.user_id = u.id
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN statuses s ON t.status_id = s.id
WHERE t.date_end <= CURDATE()
group_by: user_id
template: tasks_today
enabled: true
```
Format cron : `minute heure jour mois jour_semaine`
- `0 8 * * 1-5` : 8h00 du lundi au vendredi
- `*/15 * * * *` : toutes les 15 minutes
- `0 9 1 * *` : 9h00 le premier de chaque mois
### sogoms-admin (Interface web)
Interface d'administration web pour gérer les applications SOGOMS.
**Rôles :**
- `super_admin` : accès global à toutes les apps et services
- `app_admin` : accès limité aux apps assignées avec permissions fines
**Stack :**
- Backend : Go net/http
- Frontend : Go templates + htmx + Pico.css (embarqués via go:embed)
- Auth : sessions cookies signées (HMAC-SHA256)
**Sécurité :**
- Passwords : bcrypt cost=12
- 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
# /secrets/admin_users.yaml
session:
secret_file: /secrets/admin_session_secret
max_age: 3600
cookie_name: sogoms_admin_sid
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$..."
role: app_admin
apps: [prokov]
permissions:
- schema:read
- queries:read
- cron:read
- logs:read
```
**Permissions disponibles :**
- `schema:read/write/upload` : gestion schema
- `queries:read/write` : requêtes SQL
- `emails:read/write` : templates email
- `cron:read/write/trigger` : jobs cron
- `logs:read` : consultation logs
- `db:introspect` : introspection DB
- `*` : toutes (super_admin)
**Accès externe :** `admin.sogoms.com` via Nginx → :9000
---
## Communication IPC
@@ -461,7 +628,15 @@ sogoms/
│ └── sogoms/
│ ├── db/main.go # Microservice DB
│ ├── logs/main.go # Microservice Logs
── smtp/main.go # Microservice SMTP
── smtp/main.go # Microservice SMTP
│ ├── cron/main.go # Microservice Cron
│ └── admin/ # Interface web admin
│ ├── main.go
│ ├── handlers.go
│ ├── middleware.go
│ ├── session.go
│ ├── services.go
│ └── templates/
├── internal/
│ ├── protocol/
│ │ ├── message.go # Structs Request/Response
@@ -469,30 +644,31 @@ sogoms/
│ │ └── client.go # Client + Pool connexions
│ ├── config/
│ │ └── config.go # Registry, Queries, CUD
│ ├── cron/
│ │ └── scheduler.go # Parser cron, calcul next run
│ ├── auth/
│ │ ├── jwt.go # Génération/validation JWT
│ │ └── password.go # Hash bcrypt
│ ├── admin/
│ │ ├── config.go # Chargement admin_users.yaml
│ │ ├── permissions.go # Vérification droits
│ │ └── audit.go # Logging actions
│ └── version/
│ └── version.go # Version, BuildTime
├── config/
│ ├── sogoctl.yaml
── routes/
│ │ └── prokov.yaml # Config app (DB, auth, SMTP)
│ ├── queries/
│ │ └── prokov/
│ │ ├── auth.yaml
│ │ ├── projects.yaml
│ │ ├── tasks.yaml
│ │ ├── tags.yaml
│ │ └── statuses.yaml
│ └── emails/
── apps/
│ └── prokov/
│ ├── welcome.yaml
│ ├── password_reset.yaml
│ ├── task_assigned.yaml
── tasks_today.yaml
├── clients/
└── prokov.sql # Schéma DB
│ ├── app.yaml # Config app (DB, auth, SMTP)
│ ├── schema.yaml # Schema DB généré
│ ├── cron.yaml # Jobs planifiés
── queries/ # Requêtes SQL
│ │ ├── auth.yaml
├── projects.yaml
│ │ └── ...
│ └── emails/ # Templates email
│ ├── welcome.yaml
│ └── tasks_today.yaml
├── bin/ # Binaires compilés
├── deploy.sh # Script déploiement
├── VERSION # Numéro de version

338
GO-HTMX.md Normal file
View File

@@ -0,0 +1,338 @@
# Architecture Go + htmx (sogoms-admin)
Guide pour comprendre et modifier l'interface d'administration SOGOMS.
## Vue d'ensemble
```
Browser (htmx) ←→ Go Server ←→ Services (sockets)
│ │
│ HTTP GET/POST │
▼ ▼
Templates Handlers
(HTML) (Go)
```
**Stack :**
- **Backend** : Go net/http (pas de framework)
- **Frontend** : Go templates + htmx + Pico.css
- **Principe** : Le serveur renvoie des fragments HTML, htmx les injecte dans le DOM
---
## Structure des fichiers
```
cmd/sogoms/admin/
├── main.go # Point d'entrée, routes, config
├── handlers.go # Handlers HTTP (logique métier)
├── middleware.go # Auth, CSRF, logging, rate limiting
├── session.go # Gestion des sessions
├── services.go # Appels vers microservices (sockets)
└── templates/
├── layout.html # Layout commun (head, nav, footer)
├── login.html # Page de connexion
├── dashboard.html # Dashboard principal
└── partials/ # Fragments htmx
├── apps_list.html
└── services_status.html
```
---
## Flux de données
### 1. Requête initiale (page complète)
```
GET /admin/
main.go:103 ─────────────────────────────────────────────┐
│ mux.HandleFunc("GET /admin/", AuthMiddleware(...)) │
└───────────────────────────────────────────────────────┘
handlers.go:114-138 ─────────────────────────────────────┐
│ func HandleDashboard(w, r) { │
│ data := map[string]any{ │
│ "Title": "Dashboard", │
│ "Apps": accessibleApps, │
│ ... │
│ } │
│ s.render(w, "dashboard.html", data) │
│ } │
└───────────────────────────────────────────────────────┘
templates/dashboard.html ────────────────────────────────┐
│ {{template "layout" .}} │
│ <h1>Dashboard</h1> │
│ <div hx-get="/admin/api/services/health" │
│ hx-trigger="load"> │
│ Chargement... │
│ </div> │
└───────────────────────────────────────────────────────┘
```
### 2. Requête htmx (fragment)
```
hx-get="/admin/api/services/health"
main.go:109 ─────────────────────────────────────────────┐
│ mux.HandleFunc("GET /admin/api/services/health", ...) │
└───────────────────────────────────────────────────────┘
handlers.go:172-193 ─────────────────────────────────────┐
│ func HandleAPIServicesHealth(w, r) { │
│ statuses := s.services.HealthCheck() │
│ data := map[string]any{"Services": statuses} │
│ s.render(w, "partials/services_status.html", data)│
│ } │
└───────────────────────────────────────────────────────┘
templates/partials/services_status.html ─────────────────┐
│ <ul> │
│ {{range .Services}} │
│ <li>{{.Name}} - {{if .Available}}OK{{end}}</li> │
│ {{end}} │
│ </ul> │
└───────────────────────────────────────────────────────┘
htmx remplace le contenu du <div> avec ce fragment
```
---
## Attributs htmx essentiels
| Attribut | Description | Exemple |
|----------|-------------|---------|
| `hx-get` | GET AJAX vers URL | `hx-get="/admin/api/apps"` |
| `hx-post` | POST AJAX vers URL | `hx-post="/admin/api/apps/create"` |
| `hx-trigger` | Quand déclencher | `load`, `click`, `every 30s`, `submit` |
| `hx-target` | Où injecter la réponse | `hx-target="#result"` (défaut: élément courant) |
| `hx-swap` | Comment injecter | `innerHTML` (défaut), `outerHTML`, `beforeend` |
| `hx-indicator` | Spinner pendant chargement | `hx-indicator=".loading"` |
| `hx-confirm` | Confirmation avant action | `hx-confirm="Supprimer ?"` |
### Exemples
```html
<!-- Charger au chargement de la page -->
<div hx-get="/admin/api/apps" hx-trigger="load">
Chargement...
</div>
<!-- Rafraîchir toutes les 30 secondes -->
<div hx-get="/admin/api/services/health"
hx-trigger="load, every 30s">
</div>
<!-- Soumettre un formulaire en AJAX -->
<form hx-post="/admin/api/apps/create"
hx-target="#apps-list"
hx-swap="beforeend">
<input name="name" required>
<button type="submit">Créer</button>
</form>
<!-- Supprimer avec confirmation -->
<button hx-delete="/admin/api/apps/123"
hx-confirm="Supprimer cette app ?"
hx-target="closest li"
hx-swap="outerHTML">
Supprimer
</button>
```
---
## Templates Go
### Syntaxe de base
```html
<!-- Variable -->
{{.Title}}
<!-- Condition -->
{{if .IsSuperAdmin}}
Super Admin
{{else}}
App Admin
{{end}}
<!-- Boucle -->
{{range .Apps}}
<li>{{.Name}}</li>
{{end}}
<!-- Inclusion de template -->
{{template "layout" .}}
<!-- Bloc définissable -->
{{define "content"}}
Contenu ici
{{end}}
```
### Structure layout.html
```html
{{define "layout"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}} - SOGOMS Admin</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@1/css/pico.min.css">
</head>
<body>
<nav>...</nav>
<main class="container">
{{template "content" .}}
</main>
</body>
</html>
{{end}}
```
### Structure page (dashboard.html)
```html
{{define "dashboard.html"}}
{{template "layout" .}}
{{end}}
{{define "content"}}
<h1>Dashboard</h1>
<!-- contenu de la page -->
{{end}}
```
---
## Ajouter une nouvelle fonctionnalité
### Exemple : Liste des jobs cron
**1. Créer le partial** `templates/partials/cron_jobs.html`
```html
{{define "partials/cron_jobs.html"}}
<table>
<thead>
<tr>
<th>Job</th>
<th>Schedule</th>
<th>Prochain run</th>
</tr>
</thead>
<tbody>
{{range .Jobs}}
<tr>
<td>{{.Name}}</td>
<td><code>{{.Schedule}}</code></td>
<td>{{.NextRun}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
```
**2. Ajouter le handler** dans `handlers.go`
```go
func (s *AdminServer) HandleAPICronJobs(w http.ResponseWriter, r *http.Request) {
user := GetUserFromContext(r.Context())
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
jobs, err := s.services.GetCronJobs()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]any{
"Jobs": jobs,
}
s.render(w, "partials/cron_jobs.html", data)
}
```
**3. Ajouter la route** dans `main.go`
```go
mux.HandleFunc("GET /admin/api/cron/jobs",
AuthMiddleware(sessions, adminCfg, server.HandleAPICronJobs))
```
**4. Utiliser dans le dashboard** `templates/dashboard.html`
```html
<article>
<header><strong>Jobs Cron</strong></header>
<div hx-get="/admin/api/cron/jobs" hx-trigger="load">
Chargement...
</div>
</article>
```
---
## Mode développement
Le flag `-dev` recharge les templates à chaque requête :
```yaml
# config/sogoctl.yaml
sogoms-admin:
args:
- "-templates"
- "/config/admin/templates"
- "-dev" # ← rechargement auto
```
**Workflow dev :**
```bash
# 1. Modifier un template localement
vim cmd/sogoms/admin/templates/dashboard.html
# 2. Déployer les templates (sans recompilation)
./deploy-admin.sh
# 3. Rafraîchir le navigateur → changements visibles
```
**Pour la prod**, retirer `-dev` (templates chargés une fois au démarrage).
---
## Fichiers de configuration
| Fichier | Rôle |
|---------|------|
| `/secrets/admin_users.yaml` | Users, passwords, rôles |
| `/secrets/admin_session_secret` | Clé HMAC pour cookies |
| `/config/sogoctl.yaml` | Args de sogoms-admin |
| `/config/admin/templates/` | Templates HTML (si -templates) |
---
## Ressources
- **htmx** : https://htmx.org/docs/
- **Pico.css** : https://picocss.com/docs/
- **Go templates** : https://pkg.go.dev/html/template

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
@@ -29,6 +29,8 @@ schema.yaml → SOGOMS → API REST + Auth + CRUD + Push
| `sogoms-db` | Accès MariaDB | Stable |
| `sogoms-logs` | Logging centralisé | Stable |
| `sogoms-smtp` | Envoi emails, templates | Stable |
| `sogoms-cron` | Tâches planifiées | Stable |
| `sogoms-admin` | Interface web administration, scan DB | Stable |
## Roadmap
@@ -36,7 +38,6 @@ schema.yaml → SOGOMS → API REST + Auth + CRUD + Push
|-------|---------|-------------|
| 11 | sogoms-crypt | Chiffrement données sensibles |
| 12 | sogoms-imap/mailproc | Lecture et traitement emails |
| 13 | sogoms-cron | Tâches planifiées |
| 14 | sogoms-push | Push temps réel (MQTT) |
| 15 | sogoms-schema | API auto-générée depuis schema |

559
TODO.md
View File

@@ -161,18 +161,24 @@ curl https://prokov.unikoffice.com/api/auth/me \
## Phase 13 : Microservice Cron
- [ ] `cmd/sogoms/cron/main.go` : point d'entrée
- [ ] Écoute sur Unix socket `/run/sogoms-cron.1.sock`
- [ ] Config YAML par application (`config/cron/{app}.yaml`)
- [ ] Parser cron schedule (format standard `* * * * *`)
- [ ] Action `list` : liste les jobs configurés
- [ ] Action `trigger` : déclenche un job manuellement
- [ ] Action `status` : statut des dernières exécutions
- [ ] Type `service` : appel service interne (sogoms-smtp, sogoms-db, etc.)
- [ ] Type `http` : appel HTTP (GET/POST) vers endpoint interne ou externe
- [ ] Type `query_email` : requête DB + envoi email avec résultat
- [ ] Logging des exécutions dans sogoms-logs
- [ ] Application Prokov : email quotidien `tasks_today`
- [x] `cmd/sogoms/cron/main.go` : point d'entrée
- [x] `internal/cron/scheduler.go` : parser cron + calcul next run
- [x] Écoute sur Unix socket `/run/sogoms-cron.1.sock`
- [x] Config YAML par application (`config/apps/{app}/cron.yaml`)
- [x] Parser cron schedule (format standard `* * * * *`)
- [x] Support timezone (Europe/Paris)
- [x] Retry configurable (max_attempts, delay)
- [x] Historique des exécutions (configurable, défaut 7 jours)
- [x] Action `list` : liste les jobs configurés avec prochain run
- [x] Action `trigger` : déclenche un job manuellement
- [x] Action `status` : historique des dernières exécutions
- [x] Type `service` : appel service interne (sogoms-smtp, sogoms-db, etc.)
- [x] Type `http` : appel HTTP (GET/POST) vers endpoint interne ou externe
- [x] Type `query_email` : requête DB + envoi email groupé par user
- [x] Logging des exécutions dans sogoms-logs
- [x] Application Prokov : email quotidien `tasks_today` (8h00 lun-ven)
- [x] Intégration sogoctl.yaml
- [x] Intégration deploy.sh
## Phase 14 : Push Temps Réel (MQTT)
@@ -210,32 +216,35 @@ Cette phase transforme SOGOMS en générateur d'API automatique.
### 15a. Définition du Schema
- [ ] Format `config/schema/{app}.yaml` : tables, fields, relations
- [ ] Types supportés : int, string, text, bool, date, datetime, json
- [ ] Contraintes : primary, auto, unique, required, default
- [ ] Relations : foreign key avec `foreign: table.field`
- [ ] Sécurité : `filter: owner` pour filtrage auto par user_id
- [x] Format `config/schemas/{app}.yaml` : tables, fields, relations
- [x] Types supportés : int, string, text, float, date, datetime, json
- [x] Contraintes : primary, auto, unique, required, default
- [x] Relations : foreign key avec `foreign: table.field`
- [x] Sécurité : `filter: owner` pour filtrage auto par user_id
- [ ] Auth : `auth: login`, `auth: password` pour détection auto
- [ ] CRUD : liste des opérations autorisées par table
- [x] CRUD : liste des opérations autorisées par table
- [ ] Filtres custom : définition de filtres nommés
- [ ] Ordre par défaut : `order: "position ASC"`
### 15b. sogoms-schema (Générateur)
### 15b. Introspection DB (via API admin)
- [ ] `cmd/sogoms/schema/main.go` : outil CLI
- [ ] Commande `generate {app}` : génère queries YAML depuis schema
- [x] Action `introspect` dans sogoms-db : scan INFORMATION_SCHEMA
- [x] Endpoint `GET /api/_admin/schema/introspect` : retourne JSON
- [x] Endpoint `POST /api/_admin/schema/generate` : génère schema.yaml
- [x] Détection auto : types, clés primaires/étrangères, contraintes
- [x] Détection pattern `filter: owner` sur colonnes `user_id`
- [ ] Commande `validate {app}` : valide le schema
- [ ] Commande `diff {app}` : compare schema vs DB réelle
- [ ] Commande `migrate {app}` : génère SQL de migration
- [ ] Commande `init {app}` : crée schema depuis DB existante (reverse)
### 15c. Runtime Dynamique (sogoway)
- [ ] Chargement schema au démarrage
- [ ] Routes CRUD auto-générées depuis schema
- [ ] Validation des inputs selon types/contraintes
- [ ] Filtrage user_id automatique (filter: owner)
- [x] Chargement schema au démarrage
- [x] Routes CRUD auto-générées depuis schema
- [x] Validation des inputs selon types/contraintes
- [x] Filtrage user_id automatique (filter: owner)
- [ ] Gestion relations (include, nested)
- [ ] Pas de fichiers queries YAML requis (optionnels pour override)
- [x] Pas de fichiers queries YAML requis (optionnels pour override)
### 15d. Dictionnaire de Données
@@ -244,6 +253,163 @@ Cette phase transforme SOGOMS en générateur d'API automatique.
- [ ] Documentation auto-générée
- [ ] Utilisable par Flutter pour génération de formulaires
## Phase 16 : Réorganisation Config par Application
Objectif : regrouper tous les fichiers d'une application dans un seul dossier.
### 16a. Nouvelle structure
```
config/
├── apps/
│ └── {app}/
│ ├── app.yaml ← config principale (ex routes/{app}.yaml)
│ ├── schema.yaml ← schema DB généré
│ ├── queries/ ← requêtes SQL
│ │ ├── auth.yaml
│ │ ├── projects.yaml
│ │ └── ...
│ ├── scenarios/ ← orchestrations complexes
│ │ └── auth/
│ │ └── login.yaml
│ └── emails/ ← templates email
│ └── welcome.yaml
└── sogoctl.yaml
```
### 16b. Migration
- [x] Créer `config/apps/prokov/` avec nouvelle structure
- [x] Migrer `config/routes/prokov.yaml``config/apps/prokov/app.yaml`
- [x] Migrer `config/schemas/prokov.yaml``config/apps/prokov/schema.yaml`
- [x] Migrer `config/queries/prokov/``config/apps/prokov/queries/`
- [x] Migrer `config/scenarios/prokov/``config/apps/prokov/scenarios/`
- [x] Migrer `config/emails/prokov/``config/apps/prokov/emails/`
### 16c. Adaptation du code
- [x] `internal/config/config.go` : nouveau chemin de chargement
- [x] `internal/config/config.go` : charger schema.yaml (optionnel)
- [x] `cmd/sogoway/main.go` : adapter handleSchemaGenerate()
- [x] `deploy.sh` : adapter les chemins de déploiement
- [x] Supprimer anciens dossiers après validation
### 16d. Avantages
- Clarté : tout ce qui concerne une app dans un seul dossier
- Portabilité : copier/sauvegarder une app = copier un dossier
- Scalabilité : ajouter une app = créer un dossier dans `apps/`
- Cohérence : plus de répétition du nom d'app partout
## Phase 17 : Interface Web Administration (sogoms-admin)
### 17a. Backend Go
- [x] `internal/admin/config.go` : chargement admin_users.yaml
- [x] `internal/admin/permissions.go` : vérification des droits
- [x] `internal/admin/audit.go` : logging des actions vers sogoms-logs
- [x] `cmd/sogoms/admin/session.go` : sessions en mémoire, cookies signés HMAC-SHA256
- [x] `cmd/sogoms/admin/middleware.go` : auth, CSRF, rate limiting
- [x] `cmd/sogoms/admin/services.go` : appels vers services (db, logs, cron)
- [x] `cmd/sogoms/admin/handlers.go` : handlers HTTP (login, dashboard, logout)
- [x] `cmd/sogoms/admin/main.go` : serveur HTTP :9000
### 17b. Frontend Templates
- [x] `templates/layout.html` : layout commun avec htmx + Pico.css
- [x] `templates/login.html` : page de connexion avec CSRF
- [x] `templates/dashboard.html` : dashboard principal
- [x] `templates/partials/apps_list.html` : liste apps (htmx)
- [x] `templates/partials/services_status.html` : statut services (htmx)
### 17c. Sécurité
- [x] Passwords : bcrypt cost=12
- [x] Sessions : Cookie HttpOnly + Secure + SameSite=Strict
- [x] CSRF : Token par session, vérifié sur POST
- [x] Rate limiting : 5 tentatives/min par IP sur login
- [x] Audit : Toutes actions loggées vers sogoms-logs
### 17d. Rôles et Permissions
- [x] Super-admin : accès global à toutes les apps et services
- [x] App-admin : accès limité aux apps assignées avec permissions fines
- [x] Permissions granulaires : schema:*, queries:*, emails:*, cron:*, logs:*, db:*
### 17e. Intégration
- [x] `config/sogoctl.yaml` : ajout service sogoms-admin
- [x] `deploy.sh` : build et déploiement sogoms-admin
- [x] Configuration Nginx : admin.sogoms.com → :9000
### 17f. Configuration requise
```yaml
# /secrets/admin_users.yaml
session:
secret_file: /secrets/admin_session_secret
max_age: 3600
cookie_name: sogoms_admin_sid
rate_limit:
login_max: 5
login_window: 60
users:
- username: pierre
password_hash: "$2a$12$..."
role: super_admin
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)
@@ -251,3 +417,342 @@ Cette phase transforme SOGOMS en générateur d'API automatique.
- Multi-tenant avancé (workspaces, partage)
- Rate limiting
- Rôles utilisateurs (admin, manager, user)
## Phase 19 : Création d'App via Admin UI
Objectif : permettre la création et configuration d'une app directement depuis l'interface admin.
### 19a. Formulaire de création
- [x] Page `/admin/apps/new` : formulaire création app
- [x] Champs : nom app, version, base_path
- [x] Champs hosts : liste des domaines
- [x] Champs database : host, port, user, password, name
- [x] Champs auth : JWT secret (auto-généré ou manuel), expiry
- [ ] Validation : test connexion DB avant création
- [x] Création : génère `config/apps/{app}/app.yaml`
### 19b. Introspection et génération schema
- [x] Bouton "Scanner la base" : appelle introspection DB
- [x] Génération automatique `schema.yaml` depuis INFORMATION_SCHEMA
- [x] Détection : types, clés primaires/étrangères, contraintes
- [x] Détection auto `filter: owner` sur colonnes `user_id`
- [x] Sauvegarde `config/apps/{app}/schema.yaml`
### 19c. Bouton "Update Schema"
- [x] Relecture structure DB (nouvelle introspection)
- [x] Mise à jour schema.yaml (nouvelles tables/colonnes)
- [x] Régénération routes CRUD automatiques
- [x] Rechargement registry après scan
- [x] Rechargement automatique sogoway via SIGHUP (socket sogoctl)
Note : le bouton "Scanner la base" (19b) fait office d'Update Schema.
### 19d. Affichage dans Admin
- [ ] Page détail app : liste tables avec colonnes
- [ ] 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
- [ ] Génération auto fichiers secrets (`/secrets/{app}_*`)
- [ ] DB password : saisi une fois, stocké dans fichier
- [ ] 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).
### 18a. Préparation
- [ ] Introspection DB MariaDB existante
- [ ] Génération schema.yaml depuis introspection
- [ ] Création `config/apps/geosector/`
- [ ] Analyse des endpoints PHP existants
### 18b. Migration
- [ ] Configuration app.yaml (DB, auth, hosts)
- [ ] Adaptation des queries spécifiques
- [ ] Migration des endpoints custom si nécessaire
- [ ] Configuration jobs cron (si applicable)
- [ ] Configuration emails (si applicable)
### 18c. Tests et bascule
- [ ] Tests avec app Flutter (web)
- [ ] Tests avec app Flutter (iOS/Android)
- [ ] Configuration Nginx geosector.sogoms.com
- [ ] Bascule DNS production
- [ ] Monitoring post-migration

View File

@@ -1 +1 @@
1.0.1
1.0.7

BIN
admin Executable file

Binary file not shown.

View File

@@ -0,0 +1,703 @@
package main
import (
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"strings"
"sogoms.com/internal/admin"
"sogoms.com/internal/auth"
"sogoms.com/internal/config"
"sogoms.com/internal/infra"
)
// AdminServer contient les dépendances des handlers.
type AdminServer struct {
adminCfg *admin.AdminConfig
registry *config.Registry
sessions *SessionStore
version string
rateLimiter *RateLimiter
perms *admin.PermissionChecker
audit *admin.AuditLogger
services *ServicePool
templates *template.Template
infraDB *infra.DB
sshPool *infra.SSHPool
}
// getTemplates retourne les templates.
func (s *AdminServer) getTemplates() *template.Template {
return s.templates
}
// HandleLoginPage affiche la page de login.
func (s *AdminServer) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
// Si déjà authentifié, rediriger vers dashboard
if session, _ := s.sessions.GetSessionFromRequest(r); session != nil {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
// Générer un CSRF token pour le formulaire
csrfToken, _ := generateSecureToken(32)
data := map[string]any{
"Title": "Connexion",
"CSRFToken": csrfToken,
"Error": r.URL.Query().Get("error"),
}
s.render(w, "login.html", data)
}
// HandleLogin traite la soumission du formulaire de login.
func (s *AdminServer) HandleLogin(w http.ResponseWriter, r *http.Request) {
ip := getClientIP(r)
userAgent := r.UserAgent()
// Rate limiting
if !s.rateLimiter.Allow(ip) {
s.audit.LogLogin(false, "", ip, userAgent, "rate_limited")
http.Redirect(w, r, "/admin/login?error=Trop+de+tentatives", http.StatusSeeOther)
return
}
// Parser le formulaire
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/login?error=Formulaire+invalide", http.StatusSeeOther)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
// Enregistrer la tentative
s.rateLimiter.Record(ip)
// Vérifier les credentials
user := s.adminCfg.GetUser(username)
if user == nil {
s.audit.LogLogin(false, username, ip, userAgent, "user_not_found")
http.Redirect(w, r, "/admin/login?error=Identifiants+incorrects", http.StatusSeeOther)
return
}
if !auth.VerifyPassword(user.PasswordHash, password) {
s.audit.LogLogin(false, username, ip, userAgent, "wrong_password")
http.Redirect(w, r, "/admin/login?error=Identifiants+incorrects", http.StatusSeeOther)
return
}
// 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)
return
}
// Définir le cookie
s.sessions.SetCookie(w, session)
// 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)
}
// HandleLogout déconnecte l'utilisateur.
func (s *AdminServer) HandleLogout(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
if session != nil {
s.audit.LogLogout(session.Username, getClientIP(r))
s.sessions.Delete(session.ID)
}
s.sessions.ClearCookie(w)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
// HandleDashboard affiche le dashboard principal.
func (s *AdminServer) HandleDashboard(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
}
// Récupérer les apps accessibles
allApps := s.registry.Apps()
accessibleApps := s.perms.GetAccessibleApps(user, allApps)
data := map[string]any{
"Title": "Dashboard",
"User": user,
"Session": session,
"CSRFToken": session.CSRFToken,
"IsSuperAdmin": user.IsSuperAdmin(),
"Apps": accessibleApps,
"Permissions": s.perms.GetUserPermissions(user),
}
s.render(w, "dashboard.html", data)
}
// HandleAPIApps retourne la liste des apps (partial htmx).
func (s *AdminServer) HandleAPIApps(w http.ResponseWriter, r *http.Request) {
user := GetUserFromContext(r.Context())
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
allApps := s.registry.Apps()
accessibleApps := s.perms.GetAccessibleApps(user, allApps)
// Construire les infos des apps
type AppInfo struct {
ID string
Name string
}
apps := make([]AppInfo, 0, len(accessibleApps))
for _, appID := range accessibleApps {
apps = append(apps, AppInfo{
ID: appID,
Name: appID, // On pourrait charger le nom depuis la config
})
}
data := map[string]any{
"Apps": apps,
}
s.render(w, "partials/apps_list.html", data)
}
// HandleAPIServicesHealth retourne le statut des services (partial htmx).
func (s *AdminServer) HandleAPIServicesHealth(w http.ResponseWriter, r *http.Request) {
user := GetUserFromContext(r.Context())
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Seul super_admin peut voir le statut des services
if !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
statuses := s.services.HealthCheck()
data := map[string]any{
"Services": statuses,
}
s.render(w, "partials/services_status.html", data)
}
// AppInfo contient les informations d'une app pour le template.
type AppInfo struct {
App string
Version string
BasePath string
Hosts []string
Database DatabaseInfo
Schema bool
SchemaTableCount int
Queries bool
QueriesCount int
RoutesCount int
}
// DatabaseInfo contient les infos de connexion DB.
type DatabaseInfo struct {
Host string
Port int
User string
Name string
}
// TableInfo contient les infos d'une table pour le template.
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.
type RouteInfo struct {
Method string
Path string
Handler string
}
// HandleAppsPage affiche la liste des applications.
func (s *AdminServer) HandleAppsPage(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
}
// Récupérer les apps accessibles
allApps := s.registry.Apps()
accessibleApps := s.perms.GetAccessibleApps(user, allApps)
// Construire les infos détaillées
apps := make([]AppInfo, 0, len(accessibleApps))
for _, appID := range accessibleApps {
cfg, ok := s.registry.GetByApp(appID)
if !ok {
continue
}
info := AppInfo{
App: cfg.App,
Version: cfg.Version,
BasePath: cfg.BasePath,
Hosts: cfg.Hosts,
Database: DatabaseInfo{
Host: cfg.Database.Host,
Port: cfg.Database.Port,
User: cfg.Database.User,
Name: cfg.Database.Name,
},
}
if cfg.Schema != nil {
info.Schema = true
info.SchemaTableCount = len(cfg.Schema.Tables)
}
if cfg.Queries != nil {
info.Queries = true
info.QueriesCount = cfg.Queries.FileCount()
}
info.RoutesCount = len(cfg.Routes)
apps = append(apps, info)
}
data := map[string]any{
"Title": "Applications",
"User": user,
"Session": session,
"CSRFToken": session.CSRFToken,
"IsSuperAdmin": user.IsSuperAdmin(),
"Apps": apps,
}
s.render(w, "apps.html", data)
}
// HandleAppDetailPage affiche les détails d'une application.
func (s *AdminServer) HandleAppDetailPage(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
}
// Récupérer l'app ID depuis l'URL
appID := r.PathValue("appID")
if appID == "" {
http.Redirect(w, r, "/admin/apps", http.StatusSeeOther)
return
}
// Vérifier que l'utilisateur a accès à cette app
allApps := s.registry.Apps()
accessibleApps := s.perms.GetAccessibleApps(user, allApps)
hasAccess := false
for _, a := range accessibleApps {
if a == appID {
hasAccess = true
break
}
}
if !hasAccess {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Récupérer la config de l'app
cfg, ok := s.registry.GetByApp(appID)
if !ok {
http.Error(w, "App not found", http.StatusNotFound)
return
}
// Construire les infos de l'app
appInfo := AppInfo{
App: cfg.App,
Version: cfg.Version,
BasePath: cfg.BasePath,
Hosts: cfg.Hosts,
Database: DatabaseInfo{
Host: cfg.Database.Host,
Port: cfg.Database.Port,
User: cfg.Database.User,
Name: cfg.Database.Name,
},
RoutesCount: len(cfg.Routes),
}
if cfg.Schema != nil {
appInfo.Schema = true
appInfo.SchemaTableCount = len(cfg.Schema.Tables)
}
if cfg.Queries != nil {
appInfo.Queries = true
appInfo.QueriesCount = cfg.Queries.FileCount()
}
// Construire les infos des tables
var tables []TableInfo
if cfg.Schema != nil {
for name, table := range cfg.Schema.Tables {
// Clé primaire : composite ou simple
pk := ""
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,
})
}
}
// Construire les infos des routes
var routes []RouteInfo
for _, route := range cfg.Routes {
routes = append(routes, RouteInfo{
Method: route.Method,
Path: route.Path,
Handler: route.Scenario,
})
}
data := map[string]any{
"Title": cfg.App,
"User": user,
"Session": session,
"CSRFToken": session.CSRFToken,
"IsSuperAdmin": user.IsSuperAdmin(),
"App": appInfo,
"Tables": tables,
"Routes": routes,
}
// Flash message depuis URL
if flash := r.URL.Query().Get("flash"); flash != "" {
data["FlashType"] = flash
data["FlashMessage"] = r.URL.Query().Get("msg")
}
s.render(w, "app_detail.html", data)
}
// HandleAPICronJobs retourne la liste des jobs cron (partial htmx).
func (s *AdminServer) HandleAPICronJobs(w http.ResponseWriter, r *http.Request) {
user := GetUserFromContext(r.Context())
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Seul super_admin peut voir les jobs cron
if !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
jobs, err := s.services.GetCronJobs()
if err != nil {
log.Printf("[admin] cron jobs error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data := map[string]any{
"Jobs": jobs,
}
s.render(w, "partials/cron_jobs.html", data)
}
// HandleAppNewPage affiche le formulaire de création d'app.
func (s *AdminServer) HandleAppNewPage(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 créer des apps
if !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
data := map[string]any{
"Title": "Nouvelle Application",
"User": user,
"Session": session,
"CSRFToken": session.CSRFToken,
"IsSuperAdmin": user.IsSuperAdmin(),
}
s.render(w, "apps_new.html", data)
}
// HandleAppCreate traite la création d'une nouvelle app.
func (s *AdminServer) HandleAppCreate(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 créer des apps
if !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// Récupérer les valeurs du formulaire
appName := strings.TrimSpace(r.FormValue("app_name"))
version := strings.TrimSpace(r.FormValue("version"))
basePath := strings.TrimSpace(r.FormValue("base_path"))
hostsRaw := strings.TrimSpace(r.FormValue("hosts"))
dbHost := strings.TrimSpace(r.FormValue("db_host"))
dbPort := strings.TrimSpace(r.FormValue("db_port"))
dbUser := strings.TrimSpace(r.FormValue("db_user"))
dbName := strings.TrimSpace(r.FormValue("db_name"))
dbPassword := r.FormValue("db_password")
jwtExpiry := strings.TrimSpace(r.FormValue("jwt_expiry"))
// Validation basique
if appName == "" || basePath == "" || hostsRaw == "" {
http.Error(w, "Champs obligatoires manquants", http.StatusBadRequest)
return
}
if dbHost == "" || dbPort == "" || dbUser == "" || dbName == "" || dbPassword == "" {
http.Error(w, "Configuration base de données incomplète", http.StatusBadRequest)
return
}
// Parser les hosts (un par ligne)
var hosts []string
for _, h := range strings.Split(hostsRaw, "\n") {
h = strings.TrimSpace(h)
if h != "" {
hosts = append(hosts, h)
}
}
if len(hosts) == 0 {
http.Error(w, "Au moins un host requis", http.StatusBadRequest)
return
}
// Créer l'app via le service
err := s.services.CreateApp(appName, version, basePath, hosts, dbHost, dbPort, dbUser, dbName, dbPassword, jwtExpiry)
if err != nil {
log.Printf("[admin] create app error: %v", err)
http.Error(w, "Erreur création app: "+err.Error(), http.StatusInternalServerError)
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),
})
// Rediriger vers la page de l'app
http.Redirect(w, r, "/admin/apps/"+appName, http.StatusSeeOther)
}
// HandleAppScanDB scanne la base de données et génère le schema.yaml.
func (s *AdminServer) HandleAppScanDB(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 scanner les bases
if !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
appID := r.PathValue("appID")
if appID == "" {
http.Error(w, "App ID required", http.StatusBadRequest)
return
}
// Vérifier que l'app existe
if _, ok := s.registry.GetByApp(appID); !ok {
http.Error(w, "App not found", http.StatusNotFound)
return
}
// Scanner la base et générer le schema
tableCount, err := s.services.ScanAndGenerateSchema(appID)
if err != nil {
log.Printf("[admin] scan db error: %v", err)
redirectURL := fmt.Sprintf("/admin/apps/%s?flash=error&msg=%s", appID, url.QueryEscape("Erreur scan: "+err.Error()))
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
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)
}
// Demander à sogoway de recharger sa config
if err := s.services.ReloadGateway(); err != nil {
log.Printf("[admin] reload gateway error: %v", err)
// On ne bloque pas, on continue avec le message de succès
} else {
log.Printf("[admin] gateway reloaded successfully")
}
// Log l'action
s.audit.LogAction(user.Username, "scan_db", appID, map[string]any{
"ip": getClientIP(r),
"tables": tableCount,
})
// Rediriger vers la page de l'app avec message de succès
msg := fmt.Sprintf("Scan terminé : %d tables détectées", tableCount)
redirectURL := fmt.Sprintf("/admin/apps/%s?flash=success&msg=%s", appID, url.QueryEscape(msg))
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}
// render rend un template.
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)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

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

227
cmd/sogoms/admin/main.go Normal file
View File

@@ -0,0 +1,227 @@
// sogoms-admin : Interface web d'administration pour SOGOMS.
// Gestion des apps, schemas, queries, emails, crons et logs.
package main
import (
"embed"
"flag"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"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
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
var (
port = flag.Int("port", 9000, "HTTP server port")
configDir = flag.String("config", "/config", "Configuration directory")
secretsDir = flag.String("secrets", "/secrets", "Secrets directory")
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() {
flag.Parse()
log.SetFlags(log.Ltime | log.Lshortfile)
// Charger la config admin
adminConfigPath := *secretsDir + "/admin_users.yaml"
adminCfg, err := admin.LoadAdminConfig(adminConfigPath)
if err != nil {
log.Fatalf("load admin config: %v", err)
}
log.Printf("[admin] loaded %d admin users", len(adminCfg.Users))
// Charger le registry des apps
registry := config.NewRegistry(*configDir)
if err := registry.Load(); err != nil {
log.Fatalf("load config: %v", err)
}
log.Printf("[admin] loaded apps: %v", registry.Apps())
// Pools de connexion aux services
services := &ServicePool{}
if *dbSocket != "" {
services.DB = protocol.NewPool(*dbSocket, 2)
}
if *logsSocket != "" {
services.Logs = protocol.NewPool(*logsSocket, 2)
}
if *cronSocket != "" {
services.Cron = protocol.NewPool(*cronSocket, 2)
}
// Session store
sessions := NewSessionStore(&adminCfg.Session)
go sessions.Cleanup()
// Rate limiter
rateLimiter := NewRateLimiter(&adminCfg.RateLimit)
// Permission checker
perms := admin.NewPermissionChecker(adminCfg)
// Audit logger
audit := admin.NewAuditLogger(services.Logs)
// 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)
}
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,
infraDB: infraDB,
sshPool: sshPool,
}
// Router
mux := http.NewServeMux()
// Fichiers statiques (CSS, JS)
staticSubFS, _ := fs.Sub(staticFS, "static")
staticHandler := http.FileServerFS(staticSubFS)
mux.Handle("GET /admin/static/", http.StripPrefix("/admin/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=31536000")
staticHandler.ServeHTTP(w, r)
})))
// Routes publiques
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))
mux.HandleFunc("GET /admin/apps/new", AuthMiddleware(sessions, adminCfg, server.HandleAppNewPage))
mux.HandleFunc("POST /admin/apps/new", AuthMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleAppCreate)))
mux.HandleFunc("GET /admin/apps/{appID}", AuthMiddleware(sessions, adminCfg, server.HandleAppDetailPage))
mux.HandleFunc("POST /admin/apps/{appID}/scan", AuthMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleAppScanDB)))
mux.HandleFunc("POST /admin/logout", AuthMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleLogout)))
// API htmx (protégées)
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)
// Démarrer le serveur
addr := fmt.Sprintf(":%d", *port)
httpServer := &http.Server{
Addr: addr,
Handler: handler,
}
go func() {
log.Printf("[admin] sogoms-admin started on %s", addr)
if err := httpServer.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
// Attendre signal d'arrêt
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Printf("[admin] shutting down...")
httpServer.Close()
}
// 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)
},
}
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html", "templates/partials/*.html")
if err != nil {
return nil, err
}
return tmpl, nil
}

View File

@@ -0,0 +1,271 @@
package main
import (
"context"
"log"
"net/http"
"sync"
"time"
"sogoms.com/internal/admin"
)
// contextKey est une clé pour le contexte.
type contextKey string
const (
ctxSession contextKey = "session"
ctxUser contextKey = "user"
)
// GetSessionFromContext récupère la session depuis le contexte.
func GetSessionFromContext(ctx context.Context) *Session {
if session, ok := ctx.Value(ctxSession).(*Session); ok {
return session
}
return nil
}
// GetUserFromContext récupère l'utilisateur depuis le contexte.
func GetUserFromContext(ctx context.Context) *admin.AdminUser {
if user, ok := ctx.Value(ctxUser).(*admin.AdminUser); ok {
return user
}
return nil
}
// AuthMiddleware vérifie que l'utilisateur est authentifié.
func AuthMiddleware(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 {
log.Printf("[admin] auth failed: %v", err)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Récupérer l'utilisateur
user := adminCfg.GetUser(session.Username)
if user == nil {
log.Printf("[admin] user not found: %s", session.Username)
sessions.Delete(session.ID)
sessions.ClearCookie(w)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
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)
// Injecter dans le contexte
ctx := context.WithValue(r.Context(), ctxSession, session)
ctx = context.WithValue(ctx, ctxUser, user)
next(w, r.WithContext(ctx))
}
}
// 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) {
// Seules les requêtes de modification nécessitent CSRF
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
next(w, r)
return
}
session, err := sessions.GetSessionFromRequest(r)
if err != nil {
http.Error(w, "Invalid session", http.StatusForbidden)
return
}
// Récupérer le token CSRF (header ou form)
csrfToken := r.Header.Get("X-CSRF-Token")
if csrfToken == "" {
csrfToken = r.FormValue("csrf_token")
}
if csrfToken == "" || csrfToken != session.CSRFToken {
log.Printf("[admin] CSRF validation failed for %s", session.Username)
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
next(w, r)
}
}
// RateLimiter limite les tentatives de login.
type RateLimiter struct {
attempts map[string][]time.Time // IP -> timestamps
config *admin.RateLimitConfig
mu sync.Mutex
}
// NewRateLimiter crée un nouveau rate limiter.
func NewRateLimiter(config *admin.RateLimitConfig) *RateLimiter {
return &RateLimiter{
attempts: make(map[string][]time.Time),
config: config,
}
}
// Allow vérifie si une IP peut tenter un login.
func (r *RateLimiter) Allow(ip string) bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
window := time.Duration(r.config.LoginWindow) * time.Second
cutoff := now.Add(-window)
// Nettoyer les anciennes tentatives
var recent []time.Time
for _, t := range r.attempts[ip] {
if t.After(cutoff) {
recent = append(recent, t)
}
}
r.attempts[ip] = recent
return len(recent) < r.config.LoginMax
}
// Record enregistre une tentative.
func (r *RateLimiter) Record(ip string) {
r.mu.Lock()
defer r.mu.Unlock()
r.attempts[ip] = append(r.attempts[ip], time.Now())
}
// Remaining retourne le nombre de tentatives restantes.
func (r *RateLimiter) Remaining(ip string) int {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
window := time.Duration(r.config.LoginWindow) * time.Second
cutoff := now.Add(-window)
var count int
for _, t := range r.attempts[ip] {
if t.After(cutoff) {
count++
}
}
remaining := r.config.LoginMax - count
if remaining < 0 {
remaining = 0
}
return remaining
}
// RateLimitMiddleware applique le rate limiting.
func RateLimitMiddleware(rateLimiter *RateLimiter, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := getClientIP(r)
if !rateLimiter.Allow(ip) {
log.Printf("[admin] rate limit exceeded for IP: %s", ip)
http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests)
return
}
next(w, r)
}
}
// LoggingMiddleware log toutes les requêtes.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrapper pour capturer le status code
lw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(lw, r)
log.Printf("[admin] %s %s %d %s", r.Method, r.URL.Path, lw.statusCode, time.Since(start))
})
}
// loggingResponseWriter capture le status code.
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
func (lw *loggingResponseWriter) WriteHeader(code int) {
lw.statusCode = code
lw.ResponseWriter.WriteHeader(code)
}
// getClientIP récupère l'IP du client.
func getClientIP(r *http.Request) string {
// Vérifier X-Forwarded-For (proxy/load balancer)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Prendre la première IP
if idx := len(xff); idx > 0 {
for i, c := range xff {
if c == ',' {
return xff[:i]
}
}
return xff
}
}
// Vérifier X-Real-IP
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
// Fallback sur RemoteAddr
ip := r.RemoteAddr
// Enlever le port
for i := len(ip) - 1; i >= 0; i-- {
if ip[i] == ':' {
return ip[:i]
}
}
return ip
}

View File

@@ -0,0 +1,961 @@
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"net"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
"sogoms.com/internal/protocol"
)
// ServicePool centralise les connexions vers les microservices.
type ServicePool struct {
DB *protocol.Pool
Logs *protocol.Pool
Cron *protocol.Pool
}
// ServiceStatus représente le statut d'un service.
type ServiceStatus struct {
Name string `json:"name"`
Available bool `json:"available"`
LatencyMs int64 `json:"latency_ms"`
}
// HealthCheck vérifie l'état de tous les services.
func (sp *ServicePool) HealthCheck() []ServiceStatus {
statuses := make([]ServiceStatus, 0, 3)
// Check sogoms-db
if sp.DB != nil {
statuses = append(statuses, sp.checkService("sogoms-db", sp.DB))
}
// Check sogoms-logs
if sp.Logs != nil {
statuses = append(statuses, sp.checkService("sogoms-logs", sp.Logs))
}
// Check sogoms-cron
if sp.Cron != nil {
statuses = append(statuses, sp.checkService("sogoms-cron", sp.Cron))
}
return statuses
}
// checkService vérifie un service individuel.
func (sp *ServicePool) checkService(name string, pool *protocol.Pool) ServiceStatus {
status := ServiceStatus{
Name: name,
Available: false,
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
start := time.Now()
req := protocol.NewRequest("health", nil)
resp, err := pool.Call(ctx, req)
status.LatencyMs = time.Since(start).Milliseconds()
if err == nil && resp != nil && resp.Status == "success" {
status.Available = true
}
return status
}
// GetCronJobs récupère la liste des jobs cron.
func (sp *ServicePool) GetCronJobs() ([]map[string]any, error) {
if sp.Cron == nil {
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req := protocol.NewRequest("list", nil)
resp, err := sp.Cron.Call(ctx, req)
if err != nil {
return nil, err
}
if resp.Status != "success" {
return nil, nil
}
// Extraire les jobs
if result, ok := resp.Result.(map[string]any); ok {
if jobs, ok := result["jobs"].([]any); ok {
jobList := make([]map[string]any, 0, len(jobs))
for _, j := range jobs {
if job, ok := j.(map[string]any); ok {
jobList = append(jobList, job)
}
}
return jobList, nil
}
}
return nil, nil
}
// TriggerCronJob déclenche un job cron manuellement.
func (sp *ServicePool) TriggerCronJob(appID, jobName string) error {
if sp.Cron == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req := protocol.NewRequest("trigger", map[string]any{
"app_id": appID,
"job": jobName,
})
_, err := sp.Cron.Call(ctx, req)
return err
}
// GetCronHistory récupère l'historique des exécutions cron.
func (sp *ServicePool) GetCronHistory(appID string, limit int) ([]map[string]any, error) {
if sp.Cron == nil {
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
params := map[string]any{"limit": limit}
if appID != "" {
params["app_id"] = appID
}
req := protocol.NewRequest("status", params)
resp, err := sp.Cron.Call(ctx, req)
if err != nil {
return nil, err
}
if resp.Status != "success" {
return nil, nil
}
// Extraire les exécutions
if result, ok := resp.Result.(map[string]any); ok {
if execs, ok := result["executions"].([]any); ok {
execList := make([]map[string]any, 0, len(execs))
for _, e := range execs {
if exec, ok := e.(map[string]any); ok {
execList = append(execList, exec)
}
}
return execList, nil
}
}
return nil, nil
}
// CreateApp crée une nouvelle application avec sa configuration.
func (sp *ServicePool) CreateApp(appName, version, basePath string, hosts []string, dbHost, dbPort, dbUser, dbName, dbPassword, jwtExpiry string) error {
configDir := "/config"
secretsDir := "/secrets"
// Créer le dossier de l'app
appDir := filepath.Join(configDir, "apps", appName)
if err := os.MkdirAll(appDir, 0755); err != nil {
return fmt.Errorf("mkdir app: %w", err)
}
// Générer le JWT secret
jwtSecret := make([]byte, 32)
if _, err := rand.Read(jwtSecret); err != nil {
return fmt.Errorf("generate jwt secret: %w", err)
}
jwtSecretB64 := base64.StdEncoding.EncodeToString(jwtSecret)
// Créer les fichiers secrets
dbPassFile := filepath.Join(secretsDir, appName+"_db_pass")
if err := os.WriteFile(dbPassFile, []byte(dbPassword), 0600); err != nil {
return fmt.Errorf("write db password: %w", err)
}
jwtSecretFile := filepath.Join(secretsDir, appName+"_jwt_secret")
if err := os.WriteFile(jwtSecretFile, []byte(jwtSecretB64), 0600); err != nil {
return fmt.Errorf("write jwt secret: %w", err)
}
// Générer app.yaml
hostsYAML := ""
for _, h := range hosts {
hostsYAML += fmt.Sprintf(" - %s\n", h)
}
appYAML := fmt.Sprintf(`# Application %s
# Générée automatiquement par sogoms-admin
app: %s
version: "%s"
base_path: %s
hosts:
%s
database:
host: %s
port: %s
user: %s
password_file: %s
name: %s
auth:
jwt_secret_file: %s
jwt_expiry: %s
logs:
retention_days: 30
routes: []
`, appName, appName, version, basePath, hostsYAML, dbHost, dbPort, dbUser, dbPassFile, dbName, jwtSecretFile, jwtExpiry)
appYAMLFile := filepath.Join(appDir, "app.yaml")
if err := os.WriteFile(appYAMLFile, []byte(appYAML), 0644); err != nil {
return fmt.Errorf("write app.yaml: %w", err)
}
return nil
}
// ScanAndGenerateSchema introspect la DB et génère schema.yaml.
// Retourne le nombre de tables détectées.
func (sp *ServicePool) ScanAndGenerateSchema(appID string) (int, error) {
if sp.DB == nil {
return 0, fmt.Errorf("db service not available")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Appeler l'introspection
req := protocol.NewRequest("introspect", map[string]any{
"app_id": appID,
})
resp, err := sp.DB.Call(ctx, req)
if err != nil {
return 0, fmt.Errorf("introspect call: %w", err)
}
if resp.Status != "success" {
if resp.Error != nil {
return 0, fmt.Errorf("introspect failed: %s", resp.Error.Message)
}
return 0, fmt.Errorf("introspect failed")
}
// Extraire les tables du résultat
result, ok := resp.Result.(map[string]any)
if !ok {
return 0, fmt.Errorf("invalid introspect result")
}
tablesRaw, ok := result["tables"].(map[string]any)
if !ok {
return 0, fmt.Errorf("no tables in result")
}
tableCount := len(tablesRaw)
// Construire le schema
schema := map[string]any{
"app": appID,
"tables": convertTablesToSchema(tablesRaw),
}
// Sérialiser en YAML
yamlData, err := yaml.Marshal(schema)
if err != nil {
return 0, fmt.Errorf("yaml marshal: %w", err)
}
// Écrire le fichier
schemaFile := filepath.Join("/config", "apps", appID, "schema.yaml")
if err := os.WriteFile(schemaFile, yamlData, 0644); err != nil {
return 0, fmt.Errorf("write schema.yaml: %w", err)
}
return tableCount, nil
}
// convertTablesToSchema convertit les données d'introspection en format schema.
func convertTablesToSchema(tablesRaw map[string]any) map[string]any {
tables := make(map[string]any)
// Trier les tables par nom pour un output cohérent
tableNames := make([]string, 0, len(tablesRaw))
for name := range tablesRaw {
tableNames = append(tableNames, name)
}
sort.Strings(tableNames)
// Première passe : créer toutes les tables
for _, tableName := range tableNames {
tableData, ok := tablesRaw[tableName].(map[string]any)
if !ok {
continue
}
table := make(map[string]any)
// Colonnes
if columnsRaw, ok := tableData["columns"].(map[string]any); ok {
columns := make(map[string]any)
// Trier les colonnes
colNames := make([]string, 0, len(columnsRaw))
for name := range columnsRaw {
colNames = append(colNames, name)
}
sort.Strings(colNames)
for _, colName := range colNames {
colData, ok := columnsRaw[colName].(map[string]any)
if !ok {
continue
}
col := make(map[string]any)
// Type
if t, ok := colData["type"].(string); ok {
col["type"] = t
}
// Longueur
if l, ok := colData["length"].(float64); ok && l > 0 {
col["length"] = int(l)
}
// Primary
if p, ok := colData["primary"].(bool); ok && p {
col["primary"] = true
}
// Auto increment
if a, ok := colData["auto"].(bool); ok && a {
col["auto"] = true
}
// Required (NOT NULL)
if r, ok := colData["required"].(bool); ok && r {
col["required"] = true
}
// Default
if d, ok := colData["default"].(string); ok && d != "" {
col["default"] = d
}
// Unique
if u, ok := colData["unique"].(bool); ok && u {
col["unique"] = true
}
// Foreign key
if fk, ok := colData["foreign"].(string); ok && fk != "" {
col["foreign"] = fk
}
// Détecter filter: owner pour les colonnes user_id
if colName == "user_id" {
col["filter"] = "owner"
}
columns[colName] = col
}
table["columns"] = columns
}
// Primary keys
if pk, ok := tableData["primary"].([]any); ok && len(pk) > 0 {
pkStrings := make([]string, 0, len(pk))
for _, p := range pk {
if s, ok := p.(string); ok {
pkStrings = append(pkStrings, s)
}
}
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"}
} else {
table["crud"] = []string{}
}
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
}
// hasUserID vérifie si une table a une colonne user_id.
func hasUserID(tableData map[string]any) bool {
if columnsRaw, ok := tableData["columns"].(map[string]any); ok {
_, hasIt := columnsRaw["user_id"]
return hasIt
}
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)
if err != nil {
return fmt.Errorf("connect to sogoctl: %w", err)
}
defer conn.Close()
// Envoyer la commande reload
_, err = conn.Write([]byte("reload sogoway\n"))
if err != nil {
return fmt.Errorf("send command: %w", err)
}
// Lire la réponse
buf := make([]byte, 256)
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
n, err := conn.Read(buf)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
response := string(buf[:n])
if strings.HasPrefix(response, "error:") {
return fmt.Errorf("%s", strings.TrimPrefix(response, "error: "))
}
return nil
}

262
cmd/sogoms/admin/session.go Normal file
View File

@@ -0,0 +1,262 @@
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"strings"
"sync"
"time"
"sogoms.com/internal/admin"
)
// Session représente une session utilisateur.
type Session struct {
ID string
Username string
Role string
CSRFToken string
CreatedAt time.Time
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é.
func (s *Session) IsExpired() bool {
return time.Now().After(s.ExpiresAt)
}
// SessionStore gère les sessions en mémoire.
type SessionStore struct {
sessions map[string]*Session
config *admin.SessionConfig
mu sync.RWMutex
}
// NewSessionStore crée un nouveau store de sessions.
func NewSessionStore(config *admin.SessionConfig) *SessionStore {
return &SessionStore{
sessions: make(map[string]*Session),
config: config,
}
}
// Create crée une nouvelle session.
func (s *SessionStore) Create(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(time.Duration(s.config.MaxAge) * time.Second),
IP: ip,
UserAgent: userAgent,
}
s.mu.Lock()
s.sessions[sessionID] = session
s.mu.Unlock()
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()
session, ok := s.sessions[sessionID]
s.mu.RUnlock()
if !ok || session.IsExpired() {
return nil, false
}
return session, true
}
// Delete supprime une session.
func (s *SessionStore) Delete(sessionID string) {
s.mu.Lock()
delete(s.sessions, sessionID)
s.mu.Unlock()
}
// Refresh prolonge la durée de vie d'une session (sliding expiration).
func (s *SessionStore) Refresh(sessionID string) {
s.mu.Lock()
if session, ok := s.sessions[sessionID]; ok {
session.ExpiresAt = time.Now().Add(time.Duration(s.config.MaxAge) * time.Second)
}
s.mu.Unlock()
}
// Cleanup supprime les sessions expirées.
func (s *SessionStore) Cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
s.mu.Lock()
now := time.Now()
for id, session := range s.sessions {
if now.After(session.ExpiresAt) {
delete(s.sessions, id)
}
}
s.mu.Unlock()
}
}
// Count retourne le nombre de sessions actives.
func (s *SessionStore) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.sessions)
}
// SetCookie définit le cookie de session dans la réponse.
func (s *SessionStore) SetCookie(w http.ResponseWriter, session *Session) {
// Signer le session ID
signature := s.sign(session.ID)
value := session.ID + "." + signature
http.SetCookie(w, &http.Cookie{
Name: s.config.CookieName,
Value: value,
Path: "/admin",
MaxAge: s.config.MaxAge,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
// ClearCookie supprime le cookie de session.
func (s *SessionStore) ClearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: s.config.CookieName,
Value: "",
Path: "/admin",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
// GetSessionFromRequest extrait et valide la session depuis le cookie.
func (s *SessionStore) GetSessionFromRequest(r *http.Request) (*Session, error) {
cookie, err := r.Cookie(s.config.CookieName)
if err != nil {
return nil, fmt.Errorf("no session cookie")
}
// Séparer ID et signature
parts := strings.SplitN(cookie.Value, ".", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid cookie format")
}
sessionID := parts[0]
signature := parts[1]
// Vérifier la signature
if !s.verify(sessionID, signature) {
return nil, fmt.Errorf("invalid cookie signature")
}
// Récupérer la session
session, ok := s.Get(sessionID)
if !ok {
return nil, fmt.Errorf("session not found or expired")
}
return session, nil
}
// sign signe une donnée avec HMAC-SHA256.
func (s *SessionStore) sign(data string) string {
h := hmac.New(sha256.New, []byte(s.config.Secret))
h.Write([]byte(data))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
// verify vérifie une signature HMAC.
func (s *SessionStore) verify(data, signature string) bool {
expected := s.sign(data)
return hmac.Equal([]byte(expected), []byte(signature))
}
// generateSecureToken génère un token aléatoire sécurisé.
func generateSecureToken(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}

1
cmd/sogoms/admin/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

4
cmd/sogoms/admin/static/pico.min.css vendored Normal file

File diff suppressed because one or more lines are too long

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

@@ -0,0 +1,123 @@
{{define "app_detail.html"}}
{{template "partials/header.html" .}}
<h1>{{.App.App}}</h1>
<div class="card-grid">
<!-- Infos générales -->
<article>
<header><strong>Informations</strong></header>
<dl>
<dt>Version</dt>
<dd>{{if .App.Version}}{{.App.Version}}{{else}}<em>Non définie</em>{{end}}</dd>
<dt>Base Path</dt>
<dd><code>{{.App.BasePath}}</code></dd>
<dt>Hosts</dt>
<dd>
{{range .App.Hosts}}
<code>{{.}}</code><br>
{{end}}
</dd>
</dl>
</article>
<!-- Database -->
<article>
<header><strong>Base de données</strong></header>
<dl>
<dt>Host</dt>
<dd><code>{{.App.Database.Host}}:{{.App.Database.Port}}</code></dd>
<dt>Database</dt>
<dd><code>{{.App.Database.Name}}</code></dd>
<dt>User</dt>
<dd><code>{{.App.Database.User}}</code></dd>
</dl>
<form method="post" action="/admin/apps/{{.App.App}}/scan" style="margin-top: 1rem;">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="outline">Scanner la base</button>
</form>
</article>
<!-- Stats -->
<article>
<header><strong>Configuration</strong></header>
<dl>
{{if .App.Schema}}
<dt>Tables (schema)</dt>
<dd>{{.App.SchemaTableCount}} tables</dd>
{{end}}
{{if .App.Queries}}
<dt>Fichiers queries</dt>
<dd>{{.App.QueriesCount}} fichiers</dd>
{{end}}
<dt>Routes</dt>
<dd>{{.App.RoutesCount}} routes</dd>
</dl>
</article>
</div>
{{if .App.Schema}}
<!-- Schema / Tables -->
<article>
<header><strong>Schema - Tables</strong></header>
<table>
<thead>
<tr>
<th>Table</th>
<th>Colonnes</th>
<th>PK</th>
<th>Relations (FK)</th>
<th>SD/C</th>
</tr>
</thead>
<tbody>
{{range .Tables}}
<tr>
<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}}
{{if .Routes}}
<!-- Routes -->
<article>
<header><strong>Routes API</strong></header>
<table>
<thead>
<tr>
<th>Méthode</th>
<th>Path</th>
<th>Handler</th>
</tr>
</thead>
<tbody>
{{range .Routes}}
<tr>
<td><code>{{.Method}}</code></td>
<td><code>{{.Path}}</code></td>
<td>{{.Handler}}</td>
</tr>
{{end}}
</tbody>
</table>
</article>
{{end}}
{{template "partials/footer.html" .}}
{{end}}

View File

@@ -0,0 +1,58 @@
{{define "apps.html"}}
{{template "partials/header.html" .}}
<div style="display: flex; justify-content: space-between; align-items: center;">
<h1>Applications</h1>
{{if .IsSuperAdmin}}
<a href="/admin/apps/new" role="button">+ Nouvelle App</a>
{{end}}
</div>
{{if .Apps}}
<div class="apps-list">
{{range .Apps}}
<article>
<header>
<strong>{{.App}}</strong>
{{if .Version}}<small>v{{.Version}}</small>{{end}}
</header>
<dl>
<dt>Hosts</dt>
<dd>
{{range .Hosts}}
<code>{{.}}</code><br>
{{end}}
</dd>
<dt>Base Path</dt>
<dd><code>{{.BasePath}}</code></dd>
<dt>Database</dt>
<dd>
<code>{{.Database.User}}@{{.Database.Host}}:{{.Database.Port}}/{{.Database.Name}}</code>
</dd>
{{if .Schema}}
<dt>Tables (schema)</dt>
<dd>{{.SchemaTableCount}} tables</dd>
{{end}}
{{if .Queries}}
<dt>Queries</dt>
<dd>{{.QueriesCount}} fichiers</dd>
{{end}}
</dl>
<footer>
<a href="/admin/apps/{{.App}}" role="button" class="outline">Détails</a>
</footer>
</article>
{{end}}
</div>
{{else}}
<p>Aucune application configurée.</p>
{{end}}
{{template "partials/footer.html" .}}
{{end}}

View File

@@ -0,0 +1,88 @@
{{define "apps_new.html"}}
{{template "partials/header.html" .}}
<h1>Nouvelle Application</h1>
<form method="post" action="/admin/apps/new">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<!-- Informations générales -->
<article>
<header><strong>Informations</strong></header>
<label for="app_name">Nom de l'application *</label>
<input type="text" id="app_name" name="app_name" required
pattern="[a-z][a-z0-9_]*" placeholder="monapp"
aria-describedby="app_name_help">
<small id="app_name_help">Lettres minuscules, chiffres et underscore uniquement</small>
<label for="version">Version</label>
<input type="text" id="version" name="version" value="1.0" placeholder="1.0">
<label for="base_path">Base Path *</label>
<input type="text" id="base_path" name="base_path" value="/api" required placeholder="/api">
</article>
<!-- Hosts -->
<article>
<header><strong>Hosts</strong></header>
<label for="hosts">Domaines (un par ligne) *</label>
<textarea id="hosts" name="hosts" rows="3" required
placeholder="monapp.example.com&#10;monapp.sogoms.com"></textarea>
</article>
<!-- Database -->
<article>
<header><strong>Base de donn&eacute;es</strong></header>
<div class="grid">
<div>
<label for="db_host">Host *</label>
<input type="text" id="db_host" name="db_host" required placeholder="127.0.0.1">
</div>
<div>
<label for="db_port">Port *</label>
<input type="number" id="db_port" name="db_port" value="3306" required>
</div>
</div>
<div class="grid">
<div>
<label for="db_user">Utilisateur *</label>
<input type="text" id="db_user" name="db_user" required placeholder="monapp_user">
</div>
<div>
<label for="db_name">Base *</label>
<input type="text" id="db_name" name="db_name" required placeholder="monapp">
</div>
</div>
<label for="db_password">Mot de passe *</label>
<input type="password" id="db_password" name="db_password" required>
</article>
<!-- Auth -->
<article>
<header><strong>Authentification JWT</strong></header>
<label for="jwt_expiry">Dur&eacute;e du token</label>
<select id="jwt_expiry" name="jwt_expiry">
<option value="1h">1 heure</option>
<option value="12h">12 heures</option>
<option value="24h" selected>24 heures</option>
<option value="168h">7 jours</option>
<option value="720h">30 jours</option>
</select>
<small>Le secret JWT sera g&eacute;n&eacute;r&eacute; automatiquement</small>
</article>
<!-- Actions -->
<div class="grid">
<a href="/admin/apps" role="button" class="secondary outline">Annuler</a>
<button type="submit">Cr&eacute;er l'application</button>
</div>
</form>
{{template "partials/footer.html" .}}
{{end}}

View File

@@ -0,0 +1,81 @@
{{define "dashboard.html"}}
{{template "partials/header.html" .}}
<h1>Dashboard</h1>
<p class="user-info">
Connect&eacute; en tant que <strong>{{.User.Username}}</strong>
{{if .IsSuperAdmin}}(Super Admin){{else}}(App Admin){{end}}
</p>
<div class="card-grid">
{{if .IsSuperAdmin}}
<!-- Services Status (super admin only) -->
<article>
<header>
<strong>Services</strong>
<span class="htmx-indicator" aria-busy="true"></span>
</header>
<div hx-get="/admin/api/services/health"
hx-trigger="load, every 30s"
hx-indicator=".htmx-indicator">
Chargement...
</div>
</article>
{{end}}
<!-- Applications -->
<article>
<header>
<strong>Applications</strong>
{{if .IsSuperAdmin}}
<small>({{len .Apps}} apps)</small>
{{end}}
</header>
<div hx-get="/admin/api/apps"
hx-trigger="load">
Chargement...
</div>
</article>
{{if .IsSuperAdmin}}
<!-- Quick Stats -->
<article>
<header><strong>Statistiques</strong></header>
<dl>
<dt>Applications</dt>
<dd>{{len .Apps}}</dd>
</dl>
</article>
{{end}}
</div>
{{if .IsSuperAdmin}}
<!-- Jobs Cron (super admin only) -->
<article>
<header>
<strong>Jobs Cron</strong>
<span class="htmx-indicator" aria-busy="true"></span>
</header>
<div hx-get="/admin/api/cron/jobs"
hx-trigger="load"
hx-indicator=".htmx-indicator">
Chargement...
</div>
</article>
{{end}}
{{if not .IsSuperAdmin}}
<!-- Permissions de l'utilisateur -->
<article>
<header><strong>Vos permissions</strong></header>
<ul>
{{range .Permissions}}
<li><code>{{.}}</code></li>
{{end}}
</ul>
</article>
{{end}}
{{template "partials/footer.html" .}}
{{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

@@ -0,0 +1,72 @@
{{define "login.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>Connexion - 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;
}
.login-card {
width: 100%;
max-width: 400px;
}
.logo {
text-align: center;
font-size: 2rem;
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;
}
</style>
</head>
<body>
<article class="login-card">
<div class="logo">SOGO<span>MS</span> Admin</div>
{{if .Error}}
<div class="error-message">
{{.Error}}
</div>
{{end}}
<form action="/admin/login" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="username">
Nom d'utilisateur
<input type="text" id="username" name="username"
placeholder="Votre identifiant" required autofocus>
</label>
<label for="password">
Mot de passe
<input type="password" id="password" name="password"
placeholder="Votre mot de passe" required>
</label>
<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>
{{end}}

View File

@@ -0,0 +1,16 @@
{{define "partials/apps_list.html"}}
{{if .Apps}}
<ul>
{{range .Apps}}
<li>
<a href="/admin/apps/{{.ID}}">
<strong>{{.Name}}</strong>
</a>
</li>
{{end}}
</ul>
<p><a href="/admin/apps">Voir toutes les apps &rarr;</a></p>
{{else}}
<p><em>Aucune application accessible</em></p>
{{end}}
{{end}}

View File

@@ -0,0 +1,36 @@
{{define "partials/cron_jobs.html"}}
{{if .Jobs}}
<table>
<thead>
<tr>
<th>App</th>
<th>Job</th>
<th>Type</th>
<th>Schedule</th>
<th>Prochain run</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
{{range .Jobs}}
<tr>
<td><code>{{index . "app_id"}}</code></td>
<td><strong>{{index . "name"}}</strong></td>
<td>{{index . "type"}}</td>
<td><code>{{index . "schedule"}}</code></td>
<td>{{index . "next_run"}}</td>
<td>
{{if index . "enabled"}}
<span class="status-badge status-ok">Actif</span>
{{else}}
<span class="status-badge status-error">Inactif</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>Aucun job cron configuré.</p>
{{end}}
{{end}}

View File

@@ -0,0 +1,61 @@
{{define "partials/flash.html"}}
{{if .FlashMessage}}
<div id="flash-message" class="flash flash-{{.FlashType}}" role="alert">
{{.FlashMessage}}
</div>
<style>
.flash {
position: fixed;
top: 1rem;
left: 50%;
transform: translateX(-50%);
padding: 1rem 2rem;
border-radius: 4px;
font-weight: 500;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
animation: fadeIn 0.3s ease-out;
}
.flash.fade-out {
animation: fadeOut 0.5s ease-out forwards;
}
.flash-success {
background: #d1fae5;
color: #065f46;
border: 1px solid #6ee7b7;
}
.flash-info {
background: #dbeafe;
color: #1e40af;
border: 1px solid #93c5fd;
}
.flash-warning {
background: #fef3c7;
color: #92400e;
border: 1px solid #fcd34d;
}
.flash-error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fca5a5;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(-20px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
</style>
<script>
setTimeout(function() {
var flash = document.getElementById('flash-message');
if (flash) {
flash.classList.add('fade-out');
setTimeout(function() { flash.remove(); }, 500);
}
}, 4000);
</script>
{{end}}
{{end}}

View File

@@ -0,0 +1,9 @@
{{define "partials/footer.html"}}
</main>
<footer class="container">
<hr>
<small>SOGOMS Admin &copy; 2025</small>
</footer>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,53 @@
{{define "partials/header.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>{{.Title}} - SOGOMS Admin</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%231095c1' d='M0,3v8H11V0H3A3,3,0,0,0,0,3Z'/%3E%3Cpath fill='%231095c1' d='M21,0H13V11H24V3A3,3,0,0,0,21,0Z'/%3E%3Cpath fill='%231095c1' d='M0,21a3,3,0,0,0,3,3h8V13H0Z'/%3E%3Cpath fill='%231095c1' d='M13,24h8a3,3,0,0,0,3-3V13H13Z'/%3E%3C/svg%3E">
<link rel="stylesheet" href="/admin/static/pico.min.css">
<script src="/admin/static/htmx.min.js"></script>
<style>
.logo { font-weight: bold; font-size: 1.2rem; display: flex; align-items: center; gap: 0.5rem; }
.logo svg { width: 24px; height: 24px; }
.logo span { color: var(--pico-primary); }
.status-badge { display: inline-block; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.875rem; font-weight: 500; }
.status-ok { background: #10b981; color: white; }
.status-error { background: #ef4444; color: white; }
.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; }
.user-info { font-size: 0.875rem; color: var(--pico-muted-color); }
.htmx-indicator { opacity: 0; transition: opacity 200ms ease-in; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; }
</style>
</head>
<body>
<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> <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}}">
<button type="submit" class="outline secondary" style="margin:0;padding:0.5rem 1rem">
{{.User.Username}} - Logout
</button>
</form>
</li>
{{end}}
</ul>
</nav>
</header>
<main class="container">
{{template "partials/flash.html" .}}
{{end}}

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,17 @@
{{define "partials/services_status.html"}}
<ul>
{{range .Services}}
<li>
<strong>{{.Name}}</strong>
{{if .Available}}
<span class="status-badge status-ok">OK</span>
{{else}}
<span class="status-badge status-error">Erreur</span>
{{end}}
{{if .LatencyMs}}
<small>({{.LatencyMs}}ms)</small>
{{end}}
</li>
{{end}}
</ul>
{{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()
}

743
cmd/sogoms/cron/main.go Normal file
View File

@@ -0,0 +1,743 @@
// sogoms-cron : Microservice de tâches planifiées.
// Exécute des jobs périodiques définis dans config/apps/{app}/cron.yaml.
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
"gopkg.in/yaml.v3"
"sogoms.com/internal/config"
"sogoms.com/internal/cron"
"sogoms.com/internal/protocol"
)
var (
socketPath = flag.String("socket", "/run/sogoms-cron.1.sock", "Unix socket path")
configDir = flag.String("config", "/config", "Configuration directory")
dbSocket = flag.String("db-socket", "/run/sogoms-db.1.sock", "DB service socket")
smtpSocket = flag.String("smtp-socket", "/run/sogoms-smtp.1.sock", "SMTP service socket")
logsSocket = flag.String("logs-socket", "/run/sogoms-logs.1.sock", "Logs service socket")
)
// CronConfig représente la configuration cron d'une application.
type CronConfig struct {
Timezone string `yaml:"timezone"`
Retry RetryConfig `yaml:"retry"`
HistoryDays int `yaml:"history_days"`
Jobs map[string]*JobConfig `yaml:"jobs"`
location *time.Location
}
// RetryConfig configure les tentatives en cas d'échec.
type RetryConfig struct {
MaxAttempts int `yaml:"max_attempts"`
Delay string `yaml:"delay"`
delayDur time.Duration
}
// JobConfig représente un job planifié.
type JobConfig struct {
Schedule string `yaml:"schedule"`
Type string `yaml:"type"` // query_email, http, service
Enabled bool `yaml:"enabled"`
// Pour query_email
Query string `yaml:"query"`
GroupBy string `yaml:"group_by"`
Template string `yaml:"template"`
// Pour http
Method string `yaml:"method"`
URL string `yaml:"url"`
Headers map[string]string `yaml:"headers"`
Body string `yaml:"body"`
// Pour service
Service string `yaml:"service"`
Action string `yaml:"action"`
Params map[string]any `yaml:"params"`
// Runtime
schedule *cron.Schedule
nextRun time.Time
}
// JobExecution représente une exécution de job (historique).
type JobExecution struct {
JobName string `json:"job_name"`
AppID string `json:"app_id"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Success bool `json:"success"`
Attempt int `json:"attempt"`
Result string `json:"result"`
Error string `json:"error,omitempty"`
}
// CronManager gère les jobs cron pour toutes les applications.
type CronManager struct {
registry *config.Registry
configDir string
configs map[string]*CronConfig // appID -> config
executions []*JobExecution
historyDays int
dbPool *protocol.Pool
smtpPool *protocol.Pool
logsPool *protocol.Pool
stopCh chan struct{}
mu sync.RWMutex
}
// NewCronManager crée un nouveau gestionnaire cron.
func NewCronManager(registry *config.Registry, configDir string, dbPool, smtpPool, logsPool *protocol.Pool) *CronManager {
return &CronManager{
registry: registry,
configDir: configDir,
configs: make(map[string]*CronConfig),
executions: make([]*JobExecution, 0),
historyDays: 7,
dbPool: dbPool,
smtpPool: smtpPool,
logsPool: logsPool,
stopCh: make(chan struct{}),
}
}
// Load charge les configurations cron pour toutes les applications.
func (m *CronManager) Load() error {
m.mu.Lock()
defer m.mu.Unlock()
for _, appID := range m.registry.Apps() {
cronPath := filepath.Join(m.configDir, "apps", appID, "cron.yaml")
if _, err := os.Stat(cronPath); os.IsNotExist(err) {
continue // Pas de config cron pour cette app
}
cfg, err := m.loadCronConfig(cronPath)
if err != nil {
log.Printf("[cron] warning: cannot load %s: %v", appID, err)
continue
}
// Parser les schedules des jobs
for name, job := range cfg.Jobs {
if !job.Enabled {
continue
}
sched, err := cron.ParseSchedule(job.Schedule, cfg.location)
if err != nil {
log.Printf("[cron] warning: %s/%s invalid schedule: %v", appID, name, err)
job.Enabled = false
continue
}
job.schedule = sched
job.nextRun = sched.Next(time.Now())
}
m.configs[appID] = cfg
log.Printf("[cron] loaded %s: %d jobs", appID, len(cfg.Jobs))
}
return nil
}
// loadCronConfig charge une configuration cron depuis un fichier YAML.
func (m *CronManager) loadCronConfig(path string) (*CronConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg CronConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
// Timezone par défaut
if cfg.Timezone == "" {
cfg.Timezone = "UTC"
}
loc, err := time.LoadLocation(cfg.Timezone)
if err != nil {
return nil, fmt.Errorf("invalid timezone %s: %w", cfg.Timezone, err)
}
cfg.location = loc
// Retry par défaut
if cfg.Retry.MaxAttempts == 0 {
cfg.Retry.MaxAttempts = 3
}
if cfg.Retry.Delay == "" {
cfg.Retry.Delay = "5m"
}
cfg.Retry.delayDur, err = time.ParseDuration(cfg.Retry.Delay)
if err != nil {
cfg.Retry.delayDur = 5 * time.Minute
}
// History par défaut
if cfg.HistoryDays == 0 {
cfg.HistoryDays = 7
}
if cfg.HistoryDays > m.historyDays {
m.historyDays = cfg.HistoryDays
}
return &cfg, nil
}
// Start démarre le scheduler.
func (m *CronManager) Start() {
go m.run()
log.Printf("[cron] scheduler started")
}
// Stop arrête le scheduler.
func (m *CronManager) Stop() {
close(m.stopCh)
}
// run est la boucle principale du scheduler.
func (m *CronManager) run() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
// Vérification initiale
m.checkJobs()
for {
select {
case <-ticker.C:
m.checkJobs()
m.cleanHistory()
case <-m.stopCh:
return
}
}
}
// checkJobs vérifie et exécute les jobs dont l'heure est passée.
func (m *CronManager) checkJobs() {
m.mu.RLock()
defer m.mu.RUnlock()
now := time.Now()
for appID, cfg := range m.configs {
for jobName, job := range cfg.Jobs {
if !job.Enabled || job.schedule == nil {
continue
}
if now.After(job.nextRun) || now.Equal(job.nextRun) {
// Exécuter le job
go m.executeJob(appID, jobName, job, cfg)
// Calculer le prochain run
job.nextRun = job.schedule.Next(now)
}
}
}
}
// executeJob exécute un job avec retry.
func (m *CronManager) executeJob(appID, jobName string, job *JobConfig, cfg *CronConfig) {
var lastErr error
var result string
for attempt := 1; attempt <= cfg.Retry.MaxAttempts; attempt++ {
exec := &JobExecution{
JobName: jobName,
AppID: appID,
StartTime: time.Now(),
Attempt: attempt,
}
result, lastErr = m.runJob(appID, jobName, job)
exec.EndTime = time.Now()
exec.Success = lastErr == nil
exec.Result = result
if lastErr != nil {
exec.Error = lastErr.Error()
}
m.addExecution(exec)
if lastErr == nil {
m.logEvent(appID, "job_success", map[string]any{
"job": jobName,
"attempt": attempt,
"result": result,
})
return
}
m.logEvent(appID, "job_failed", map[string]any{
"job": jobName,
"attempt": attempt,
"error": lastErr.Error(),
})
if attempt < cfg.Retry.MaxAttempts {
time.Sleep(cfg.Retry.delayDur)
}
}
// Échec après tous les retries
m.logEvent(appID, "job_exhausted", map[string]any{
"job": jobName,
"attempts": cfg.Retry.MaxAttempts,
"error": lastErr.Error(),
})
}
// runJob exécute un job selon son type.
func (m *CronManager) runJob(appID, jobName string, job *JobConfig) (string, error) {
switch job.Type {
case "query_email":
return m.runQueryEmail(appID, job)
case "http":
return m.runHTTP(job)
case "service":
return m.runService(appID, job)
default:
return "", fmt.Errorf("unknown job type: %s", job.Type)
}
}
// runQueryEmail exécute une requête DB et envoie des emails groupés.
func (m *CronManager) runQueryEmail(appID string, job *JobConfig) (string, error) {
if m.dbPool == nil {
return "", fmt.Errorf("db service not available")
}
if m.smtpPool == nil {
return "", fmt.Errorf("smtp service not available")
}
// Exécuter la requête
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req := protocol.NewRequest("query", map[string]any{
"app_id": appID,
"query": job.Query,
"args": []any{},
})
resp, err := m.dbPool.Call(ctx, req)
if err != nil {
return "", fmt.Errorf("db query: %w", err)
}
if resp.Status != "success" {
return "", fmt.Errorf("db query failed: %s", resp.Error.Message)
}
// Extraire les résultats
resultMap, ok := resp.Result.(map[string]any)
if !ok {
return "", fmt.Errorf("invalid result format")
}
rows, ok := resultMap["rows"].([]any)
if !ok || len(rows) == 0 {
return "no data", nil
}
// Grouper par user si demandé
grouped := m.groupRows(rows, job.GroupBy)
// Envoyer un email par groupe
sent := 0
for key, groupRows := range grouped {
if err := m.sendGroupEmail(appID, job, key, groupRows); err != nil {
log.Printf("[cron] %s: email error for %s: %v", appID, key, err)
continue
}
sent++
}
return fmt.Sprintf("sent %d emails", sent), nil
}
// groupRows groupe les lignes par une clé.
func (m *CronManager) groupRows(rows []any, groupBy string) map[string][]map[string]any {
grouped := make(map[string][]map[string]any)
for _, row := range rows {
rowMap, ok := row.(map[string]any)
if !ok {
continue
}
key := "default"
if groupBy != "" {
if v, ok := rowMap[groupBy]; ok {
key = fmt.Sprintf("%v", v)
}
}
grouped[key] = append(grouped[key], rowMap)
}
return grouped
}
// sendGroupEmail envoie un email pour un groupe de lignes.
func (m *CronManager) sendGroupEmail(appID string, job *JobConfig, key string, rows []map[string]any) error {
if len(rows) == 0 {
return nil
}
// Extraire l'email du premier row (doit contenir "email")
email, ok := rows[0]["email"].(string)
if !ok {
return fmt.Errorf("no email field in row")
}
// Extraire le nom (optionnel)
name, _ := rows[0]["user_name"].(string)
if name == "" {
name, _ = rows[0]["name"].(string)
}
// Préparer les données du template
now := time.Now()
tasks := make([]map[string]any, 0, len(rows))
for _, row := range rows {
task := map[string]any{
"Name": row["title"],
"Project": row["project_name"],
"Status": row["status_name"],
"StatusColor": row["status_color"],
"DueTime": "",
}
if dt, ok := row["due_date"].(time.Time); ok {
task["DueTime"] = dt.Format("15:04")
}
tasks = append(tasks, task)
}
data := map[string]any{
"Name": name,
"Date": now.Format("02/01/2006"),
"Tasks": tasks,
"TaskCount": len(tasks),
}
// Envoyer via smtp service
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req := protocol.NewRequest("send_template", map[string]any{
"app_id": appID,
"to": email,
"template": job.Template,
"data": data,
})
resp, err := m.smtpPool.Call(ctx, req)
if err != nil {
return err
}
if resp.Status != "success" {
return fmt.Errorf("smtp: %s", resp.Error.Message)
}
return nil
}
// runHTTP exécute une requête HTTP.
func (m *CronManager) runHTTP(job *JobConfig) (string, error) {
method := job.Method
if method == "" {
method = "GET"
}
var body *bytes.Reader
if job.Body != "" {
body = bytes.NewReader([]byte(job.Body))
} else {
body = bytes.NewReader(nil)
}
req, err := http.NewRequest(method, job.URL, body)
if err != nil {
return "", err
}
for k, v := range job.Headers {
req.Header.Set(k, v)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
return fmt.Sprintf("HTTP %d", resp.StatusCode), nil
}
// runService appelle un service interne.
func (m *CronManager) runService(appID string, job *JobConfig) (string, error) {
var pool *protocol.Pool
switch job.Service {
case "db", "sogoms-db":
pool = m.dbPool
case "smtp", "sogoms-smtp":
pool = m.smtpPool
case "logs", "sogoms-logs":
pool = m.logsPool
default:
return "", fmt.Errorf("unknown service: %s", job.Service)
}
if pool == nil {
return "", fmt.Errorf("service %s not available", job.Service)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
params := make(map[string]any)
for k, v := range job.Params {
params[k] = v
}
params["app_id"] = appID
req := protocol.NewRequest(job.Action, params)
resp, err := pool.Call(ctx, req)
if err != nil {
return "", err
}
if resp.Status != "success" {
return "", fmt.Errorf("%s: %s", resp.Error.Code, resp.Error.Message)
}
result, _ := json.Marshal(resp.Result)
return string(result), nil
}
// addExecution ajoute une exécution à l'historique.
func (m *CronManager) addExecution(exec *JobExecution) {
m.mu.Lock()
defer m.mu.Unlock()
m.executions = append(m.executions, exec)
}
// cleanHistory supprime les exécutions plus vieilles que historyDays.
func (m *CronManager) cleanHistory() {
m.mu.Lock()
defer m.mu.Unlock()
cutoff := time.Now().AddDate(0, 0, -m.historyDays)
var kept []*JobExecution
for _, exec := range m.executions {
if exec.StartTime.After(cutoff) {
kept = append(kept, exec)
}
}
m.executions = kept
}
// logEvent envoie un log au service logs.
func (m *CronManager) logEvent(appID, eventType string, data map[string]any) {
if m.logsPool == nil {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req := protocol.NewRequest("log_event", map[string]any{
"app_id": appID,
"event_type": "cron_" + eventType,
"data": data,
})
m.logsPool.Call(ctx, req)
}()
}
// ListJobs retourne la liste des jobs avec leur prochain run.
func (m *CronManager) ListJobs() []map[string]any {
m.mu.RLock()
defer m.mu.RUnlock()
var jobs []map[string]any
for appID, cfg := range m.configs {
for name, job := range cfg.Jobs {
jobs = append(jobs, map[string]any{
"app_id": appID,
"name": name,
"type": job.Type,
"schedule": job.Schedule,
"enabled": job.Enabled,
"next_run": job.nextRun.Format(time.RFC3339),
})
}
}
return jobs
}
// GetHistory retourne l'historique des exécutions.
func (m *CronManager) GetHistory(appID, jobName string, limit int) []*JobExecution {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*JobExecution
for i := len(m.executions) - 1; i >= 0 && len(result) < limit; i-- {
exec := m.executions[i]
if appID != "" && exec.AppID != appID {
continue
}
if jobName != "" && exec.JobName != jobName {
continue
}
result = append(result, exec)
}
return result
}
// TriggerJob déclenche un job manuellement.
func (m *CronManager) TriggerJob(appID, jobName string) error {
m.mu.RLock()
cfg, ok := m.configs[appID]
if !ok {
m.mu.RUnlock()
return fmt.Errorf("app not found: %s", appID)
}
job, ok := cfg.Jobs[jobName]
if !ok {
m.mu.RUnlock()
return fmt.Errorf("job not found: %s", jobName)
}
m.mu.RUnlock()
go m.executeJob(appID, jobName, job, cfg)
return nil
}
func main() {
flag.Parse()
log.SetFlags(log.Ltime | log.Lshortfile)
// Charger les configurations des apps
registry := config.NewRegistry(*configDir)
if err := registry.Load(); err != nil {
log.Fatalf("load config: %v", err)
}
log.Printf("[cron] loaded apps: %v", registry.Apps())
// Pools de connexion aux services
var dbPool, smtpPool, logsPool *protocol.Pool
if *dbSocket != "" {
dbPool = protocol.NewPool(*dbSocket, 2)
}
if *smtpSocket != "" {
smtpPool = protocol.NewPool(*smtpSocket, 2)
}
if *logsSocket != "" {
logsPool = protocol.NewPool(*logsSocket, 2)
}
// Manager cron
manager := NewCronManager(registry, *configDir, dbPool, smtpPool, logsPool)
if err := manager.Load(); err != nil {
log.Fatalf("load cron config: %v", err)
}
manager.Start()
defer manager.Stop()
// Handler des requêtes IPC
handler := func(ctx context.Context, req *protocol.Request) *protocol.Response {
return handleRequest(ctx, req, manager)
}
// Démarrer le serveur
server := protocol.NewServer(*socketPath, handler)
if err := server.Start(); err != nil {
log.Fatalf("start server: %v", err)
}
log.Printf("[cron] sogoms-cron started on %s", *socketPath)
// Attendre signal d'arrêt
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Printf("[cron] shutting down...")
server.Stop()
}
func handleRequest(ctx context.Context, req *protocol.Request, manager *CronManager) *protocol.Response {
switch req.Action {
case "health":
return protocol.Success(req.ID, map[string]any{"status": "ok"})
case "list":
return handleList(req, manager)
case "trigger":
return handleTrigger(req, manager)
case "status":
return handleStatus(req, manager)
default:
return protocol.Failure(req.ID, "UNKNOWN_ACTION", "unknown action: "+req.Action)
}
}
// handleList retourne la liste des jobs.
func handleList(req *protocol.Request, manager *CronManager) *protocol.Response {
jobs := manager.ListJobs()
return protocol.Success(req.ID, map[string]any{"jobs": jobs})
}
// handleTrigger déclenche un job manuellement.
// Params: app_id, job
func handleTrigger(req *protocol.Request, manager *CronManager) *protocol.Response {
appID, _ := req.Params["app_id"].(string)
jobName, _ := req.Params["job"].(string)
if appID == "" || jobName == "" {
return protocol.Failure(req.ID, "MISSING_PARAMS", "app_id and job are required")
}
if err := manager.TriggerJob(appID, jobName); err != nil {
return protocol.Failure(req.ID, "TRIGGER_ERROR", err.Error())
}
return protocol.Success(req.ID, map[string]any{"triggered": true})
}
// handleStatus retourne l'historique des exécutions.
// Params: app_id (optionnel), job (optionnel), limit (optionnel, défaut 50)
func handleStatus(req *protocol.Request, manager *CronManager) *protocol.Response {
appID, _ := req.Params["app_id"].(string)
jobName, _ := req.Params["job"].(string)
limit := 50
if l, ok := req.Params["limit"].(float64); ok {
limit = int(l)
}
history := manager.GetHistory(appID, jobName, limit)
return protocol.Success(req.ID, map[string]any{"executions": history})
}

View File

@@ -157,7 +157,22 @@ func main() {
}
func handleRequest(ctx context.Context, req *protocol.Request, dbPool *DBPool) *protocol.Response {
// L'app_id doit être fourni
// Health check sans app_id (vérifie juste que le service tourne)
if req.Action == "health" {
if appID, ok := req.Params["app_id"].(string); ok && appID != "" {
// Health check avec app_id : vérifie la connexion DB
if db, err := dbPool.GetDB(appID); err == nil {
if err := db.Ping(); err != nil {
return protocol.Failure(req.ID, "UNHEALTHY", err.Error())
}
return protocol.Success(req.ID, map[string]any{"status": "ok", "app_id": appID})
}
}
// Health check simple : le service tourne
return protocol.Success(req.ID, map[string]any{"status": "ok"})
}
// L'app_id doit être fourni pour les autres actions
appID, ok := req.Params["app_id"].(string)
if !ok || appID == "" {
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
@@ -179,8 +194,8 @@ func handleRequest(ctx context.Context, req *protocol.Request, dbPool *DBPool) *
return handleUpdate(req, db, appID)
case "delete":
return handleDelete(req, db, appID)
case "health":
return handleHealth(req, db)
case "introspect":
return handleIntrospect(req, db, appID)
default:
return protocol.Failure(req.ID, "UNKNOWN_ACTION", "unknown action: "+req.Action)
}
@@ -277,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 {
@@ -293,13 +309,28 @@ func handleUpdate(req *protocol.Request, db *sql.DB, appID string) *protocol.Res
return protocol.Failure(req.ID, "INVALID_PARAMS", "where is required")
}
// Récupérer les colonnes avec expressions SQL brutes
rawCols := make(map[string]bool)
if rawList, ok := req.Params["raw"].([]any); ok {
for _, col := range rawList {
if colStr, ok := col.(string); ok {
rawCols[colStr] = true
}
}
}
// Construire SET
setClauses := make([]string, 0, len(data))
values := make([]any, 0, len(data)+len(where))
for col, val := range data {
setClauses = append(setClauses, col+" = ?")
values = append(values, val)
if rawCols[col] {
// Expression SQL brute (ex: NOW(), NULL, etc.)
setClauses = append(setClauses, col+" = "+val.(string))
} else {
setClauses = append(setClauses, col+" = ?")
values = append(values, val)
}
}
// Construire WHERE
@@ -363,14 +394,6 @@ func handleDelete(req *protocol.Request, db *sql.DB, appID string) *protocol.Res
})
}
// handleHealth vérifie la connexion à la DB.
func handleHealth(req *protocol.Request, db *sql.DB) *protocol.Response {
if err := db.Ping(); err != nil {
return protocol.Failure(req.ID, "UNHEALTHY", err.Error())
}
return protocol.Success(req.ID, map[string]any{"status": "ok"})
}
// extractQueryParams extrait query et args des paramètres.
func extractQueryParams(params map[string]any) (string, []any, error) {
query, ok := params["query"].(string)
@@ -434,3 +457,221 @@ func scanRows(rows *sql.Rows) ([]map[string]any, error) {
return results, rows.Err()
}
// handleIntrospect analyse la structure de la base de données.
// Retourne tables, colonnes, clés primaires et étrangères.
func handleIntrospect(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
// Récupérer le nom de la base
var dbName string
if err := db.QueryRow("SELECT DATABASE()").Scan(&dbName); err != nil {
return protocol.Failure(req.ID, "DB_ERROR", "cannot get database name: "+err.Error())
}
// 1. Récupérer les tables
tablesQuery := `
SELECT TABLE_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'
ORDER BY TABLE_NAME`
tableRows, err := db.Query(tablesQuery, dbName)
if err != nil {
return protocol.Failure(req.ID, "QUERY_ERROR", err.Error())
}
defer tableRows.Close()
var tableNames []string
for tableRows.Next() {
var name string
if err := tableRows.Scan(&name); err != nil {
return protocol.Failure(req.ID, "SCAN_ERROR", err.Error())
}
tableNames = append(tableNames, name)
}
// 2. Pour chaque table, récupérer les colonnes
columnsQuery := `
SELECT
COLUMN_NAME,
DATA_TYPE,
CHARACTER_MAXIMUM_LENGTH,
IS_NULLABLE,
COLUMN_DEFAULT,
EXTRA,
COLUMN_KEY
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION`
// 3. Récupérer les clés étrangères
fkQuery := `
SELECT
COLUMN_NAME,
REFERENCED_TABLE_NAME,
REFERENCED_COLUMN_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
AND REFERENCED_TABLE_NAME IS NOT NULL`
// 4. Récupérer les contraintes UNIQUE
uniqueQuery := `
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
ON kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
AND kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA
WHERE tc.TABLE_SCHEMA = ? AND tc.TABLE_NAME = ?
AND tc.CONSTRAINT_TYPE = 'UNIQUE'`
tables := make(map[string]any)
for _, tableName := range tableNames {
// Colonnes
colRows, err := db.Query(columnsQuery, dbName, tableName)
if err != nil {
logError(appID, "error", "introspect_columns_failed", map[string]any{"table": tableName, "error": err.Error()})
continue
}
columns := make(map[string]any)
var primaryKeys []string
for colRows.Next() {
var (
colName string
dataType string
maxLength sql.NullInt64
nullable string
colDefault sql.NullString
extra string
colKey string
)
if err := colRows.Scan(&colName, &dataType, &maxLength, &nullable, &colDefault, &extra, &colKey); err != nil {
colRows.Close()
continue
}
col := map[string]any{
"type": mapMySQLType(dataType),
}
// Longueur pour varchar/char
if maxLength.Valid && maxLength.Int64 > 0 {
col["length"] = maxLength.Int64
}
// Nullable
if nullable == "NO" {
col["required"] = true
}
// Default
if colDefault.Valid {
col["default"] = colDefault.String
}
// Auto increment
if strings.Contains(extra, "auto_increment") {
col["auto"] = true
}
// Primary key
if colKey == "PRI" {
col["primary"] = true
primaryKeys = append(primaryKeys, colName)
}
columns[colName] = col
}
colRows.Close()
// Clés étrangères
fkRows, err := db.Query(fkQuery, dbName, tableName)
if err == nil {
for fkRows.Next() {
var colName, refTable, refCol string
if err := fkRows.Scan(&colName, &refTable, &refCol); err != nil {
continue
}
if col, ok := columns[colName].(map[string]any); ok {
col["foreign"] = refTable + "." + refCol
// Détecter le pattern owner (user_id -> users.id)
if colName == "user_id" && refTable == "users" {
col["filter"] = "owner"
}
}
}
fkRows.Close()
}
// Contraintes UNIQUE
uqRows, err := db.Query(uniqueQuery, dbName, tableName)
if err == nil {
for uqRows.Next() {
var colName string
if err := uqRows.Scan(&colName); err != nil {
continue
}
if col, ok := columns[colName].(map[string]any); ok {
col["unique"] = true
}
}
uqRows.Close()
}
table := map[string]any{
"columns": columns,
}
// Clé primaire composite
if len(primaryKeys) > 1 {
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"}
tables[tableName] = table
}
return protocol.Success(req.ID, map[string]any{
"app": appID,
"database": dbName,
"tables": tables,
})
}
// mapMySQLType convertit un type MySQL en type schema simplifié.
func mapMySQLType(mysqlType string) string {
switch strings.ToLower(mysqlType) {
case "tinyint", "smallint", "mediumint", "int", "bigint":
return "int"
case "float", "double", "decimal":
return "float"
case "varchar", "char":
return "string"
case "text", "mediumtext", "longtext":
return "text"
case "tinyint(1)", "boolean", "bool":
return "bool"
case "date":
return "date"
case "datetime", "timestamp":
return "datetime"
case "time":
return "time"
case "json":
return "json"
case "blob", "mediumblob", "longblob":
return "blob"
default:
return mysqlType
}
}

View File

@@ -0,0 +1,44 @@
# Configuration des tâches planifiées pour Prokov
timezone: Europe/Paris
retry:
max_attempts: 3
delay: 5m
history_days: 7
jobs:
# Email quotidien des tâches à faire
tasks_today:
schedule: "0 8 * * 1-5" # 8h00 du lundi au vendredi
type: query_email
enabled: true
# Requête : tâches du jour pour chaque utilisateur
# Retourne les tâches dont la date de fin est aujourd'hui ou dépassée
# et qui ne sont pas dans un statut "terminé" (code >= 100)
query: |
SELECT
u.id AS user_id,
u.email,
u.name AS user_name,
t.id AS task_id,
t.title,
t.priority,
t.date_end,
p.name AS project_name,
s.name AS status_name,
s.color AS status_color
FROM users u
INNER JOIN tasks t ON t.user_id = u.id
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN statuses s ON t.status_id = s.id
WHERE (t.date_end <= CURDATE() OR t.date_start = CURDATE())
AND (s.code IS NULL OR s.code < 100)
ORDER BY u.id, t.priority DESC, t.date_end ASC, t.position ASC
# Grouper par user_id pour envoyer 1 email par utilisateur
group_by: user_id
# Template email à utiliser
template: tasks_today

View File

@@ -54,7 +54,7 @@ CREATE TABLE `projects` (
KEY `idx_projects_parent` (`user_id`,`parent_id`),
CONSTRAINT `projects_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `projects_ibfk_2` FOREIGN KEY (`parent_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
@@ -95,7 +95,7 @@ CREATE TABLE `tags` (
PRIMARY KEY (`id`),
UNIQUE KEY `unique_tag_per_user` (`user_id`,`name`),
CONSTRAINT `tags_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
@@ -146,7 +146,7 @@ CREATE TABLE `tasks` (
CONSTRAINT `tasks_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `tasks_ibfk_2` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE,
CONSTRAINT `tasks_ibfk_3` FOREIGN KEY (`status_id`) REFERENCES `statuses` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
@@ -161,6 +161,8 @@ CREATE TABLE `users` (
`email` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
`name` varchar(100) NOT NULL,
`language` varchar(5) NOT NULL DEFAULT 'fr',
`timezone` varchar(50) NOT NULL DEFAULT 'Europe/Paris',
`role_id` int(10) unsigned NOT NULL DEFAULT 1,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
@@ -199,4 +201,4 @@ CREATE TABLE `users_roles` (
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*M!100616 SET NOTE_VERBOSITY=@OLD_NOTE_VERBOSITY */;
-- Dump completed on 2025-12-16 10:58:46
-- Dump completed on 2025-12-18 10:24:52

View File

@@ -0,0 +1,255 @@
app: prokov
tables:
project_tags:
columns:
project_id:
foreign: projects.id
primary: true
required: true
type: int
tag_id:
foreign: tags.id
primary: true
required: true
type: int
crud: []
primary:
- project_id
- tag_id
projects:
columns:
created_at:
default: current_timestamp()
type: datetime
description:
default: "NULL"
length: 65535
type: text
id:
auto: true
primary: true
required: true
type: int
name:
length: 100
required: true
type: string
parent_id:
default: "NULL"
foreign: projects.id
type: int
position:
default: "0"
type: int
updated_at:
default: current_timestamp()
type: datetime
user_id:
filter: owner
foreign: users.id
required: true
type: int
crud:
- list
- show
- create
- update
- delete
statuses:
columns:
code:
required: true
type: int
color:
default: '''#6B7280'''
length: 7
type: string
created_at:
default: current_timestamp()
type: datetime
id:
auto: true
primary: true
required: true
type: int
name:
length: 50
required: true
type: string
position:
default: "0"
type: int
project_id:
default: "NULL"
type: int
user_id:
filter: owner
foreign: users.id
required: true
type: int
crud:
- list
- show
- create
- update
- delete
tags:
columns:
color:
default: '''#3B82F6'''
length: 7
type: string
created_at:
default: current_timestamp()
type: datetime
id:
auto: true
primary: true
required: true
type: int
name:
length: 50
required: true
type: string
unique: true
user_id:
filter: owner
foreign: users.id
required: true
type: int
unique: true
crud:
- list
- show
- create
- update
- delete
task_tags:
columns:
tag_id:
foreign: tags.id
primary: true
required: true
type: int
task_id:
foreign: tasks.id
primary: true
required: true
type: int
crud: []
primary:
- task_id
- tag_id
tasks:
columns:
billing:
default: "0.00"
type: float
created_at:
default: current_timestamp()
type: datetime
date_end:
default: "NULL"
type: date
date_start:
default: "NULL"
type: date
description:
default: "NULL"
length: 65535
type: text
id:
auto: true
primary: true
required: true
type: int
position:
default: "0"
type: int
priority:
default: "5"
type: int
project_id:
foreign: projects.id
required: true
type: int
status_id:
foreign: statuses.id
required: true
type: int
time_estimated:
default: "0"
type: int
time_spent:
default: "0"
type: int
title:
length: 255
required: true
type: string
updated_at:
default: current_timestamp()
type: datetime
user_id:
filter: owner
foreign: users.id
required: true
type: int
crud:
- list
- show
- create
- update
- delete
users:
columns:
created_at:
default: current_timestamp()
type: datetime
email:
length: 255
required: true
type: string
unique: true
id:
auto: true
primary: true
required: true
type: int
name:
length: 100
required: true
type: string
password:
length: 255
required: true
type: string
role_id:
default: "1"
foreign: users_roles.id
required: true
type: int
updated_at:
default: current_timestamp()
type: datetime
crud:
- list
- show
- create
- update
- delete
users_roles:
columns:
created_at:
default: current_timestamp()
type: datetime
id:
primary: true
required: true
type: int
name:
length: 50
required: true
type: string
crud: []
version: "1.0"

View File

@@ -43,6 +43,46 @@ services:
depends_on:
- sogoms-logs
sogoms-cron:
binary: /opt/sogoms/bin/sogoms-cron
args:
- "-config"
- "/config"
- "-socket"
- "/run/sogoms-cron.1.sock"
- "-db-socket"
- "/run/sogoms-db.1.sock"
- "-smtp-socket"
- "/run/sogoms-smtp.1.sock"
- "-logs-socket"
- "/run/sogoms-logs.1.sock"
health_socket: /run/sogoms-cron.1.sock
depends_on:
- sogoms-db
- sogoms-smtp
- sogoms-logs
sogoms-admin:
binary: /opt/sogoms/bin/sogoms-admin
args:
- "-config"
- "/config"
- "-secrets"
- "/secrets"
- "-port"
- "9000"
- "-db-socket"
- "/run/sogoms-db.1.sock"
- "-logs-socket"
- "/run/sogoms-logs.1.sock"
- "-cron-socket"
- "/run/sogoms-cron.1.sock"
health_url: http://localhost:9000/admin/login
depends_on:
- sogoms-db
- sogoms-logs
- sogoms-cron
sogoway:
binary: /opt/sogoms/bin/sogoway
args:

BIN
cron Executable file

Binary file not shown.

BIN
db Executable file

Binary file not shown.

View File

@@ -60,8 +60,8 @@ if [ ! -d "cmd/sogoms/db" ] || [ ! -d "cmd/sogoway" ] || [ ! -d "cmd/sogoctl" ];
echo_error "Source directories missing - are you in the sogoms directory?"
fi
if [ ! -d "config/routes" ]; then
echo_error "config/routes missing"
if [ ! -d "config/apps" ]; then
echo_error "config/apps missing"
fi
# Commande SSH vers IN3
@@ -80,10 +80,12 @@ mkdir -p bin
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-db ./cmd/sogoms/db || echo_error "Failed to build sogoms-db"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-logs ./cmd/sogoms/logs || echo_error "Failed to build sogoms-logs"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-smtp ./cmd/sogoms/smtp || echo_error "Failed to build sogoms-smtp"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-cron ./cmd/sogoms/cron || echo_error "Failed to build sogoms-cron"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-admin ./cmd/sogoms/admin || echo_error "Failed to build sogoms-admin"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoway ./cmd/sogoway || echo_error "Failed to build sogoway"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoctl ./cmd/sogoctl || echo_error "Failed to build sogoctl"
echo_info "Built: sogoms-db, sogoms-logs, sogoms-smtp, sogoway, sogoctl (v${VERSION})"
echo_info "Built: sogoms-db, sogoms-logs, sogoms-smtp, sogoms-cron, sogoms-admin, sogoway, sogoctl (v${VERSION})"
# Étape 2: Créer les archives
echo_step "Creating archives..."
@@ -92,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)
@@ -119,10 +122,10 @@ $SSH_CMD "
echo '📁 Deploying binaries...'
incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_BIN}
incus exec ${INCUS_CONTAINER} -- tar -xzvf /tmp/${BIN_ARCHIVE} -C ${REMOTE_BIN}/
incus exec ${INCUS_CONTAINER} -- chmod 755 ${REMOTE_BIN}/sogoms-db ${REMOTE_BIN}/sogoms-logs ${REMOTE_BIN}/sogoms-smtp ${REMOTE_BIN}/sogoway ${REMOTE_BIN}/sogoctl
incus exec ${INCUS_CONTAINER} -- chmod 755 ${REMOTE_BIN}/sogoms-db ${REMOTE_BIN}/sogoms-logs ${REMOTE_BIN}/sogoms-smtp ${REMOTE_BIN}/sogoms-cron ${REMOTE_BIN}/sogoms-admin ${REMOTE_BIN}/sogoway ${REMOTE_BIN}/sogoctl
echo '📁 Deploying config...'
incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_CONFIG}/routes ${REMOTE_CONFIG}/scenarios ${REMOTE_CONFIG}/queries ${REMOTE_CONFIG}/emails
incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_CONFIG}/apps
incus exec ${INCUS_CONTAINER} -- tar -xzf /tmp/${CONFIG_ARCHIVE} -C ${REMOTE_CONFIG}/
echo '📁 Setting up run and log directories...'
@@ -186,7 +189,8 @@ echo_info " Deployment time: $(date)"
echo ""
echo_warning "Next steps on gw3:"
echo_info " 1. Edit /secrets/prokov_db_pass with real DB password"
echo_info " 2. Start services: /opt/sogoms/bin/sogoctl"
echo_info " 2. Create /secrets/admin_users.yaml and /secrets/admin_session_secret"
echo_info " 3. Start services: /opt/sogoms/bin/sogoctl"
echo ""
echo_info "To connect: ssh in3 -t 'incus exec $INCUS_CONTAINER -- sh'"

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=

105
internal/admin/audit.go Normal file
View File

@@ -0,0 +1,105 @@
package admin
import (
"context"
"time"
"sogoms.com/internal/protocol"
)
// AuditEvent représente un type d'événement audit.
type AuditEvent string
// Événements audit.
const (
AuditLoginSuccess AuditEvent = "login_success"
AuditLoginFailed AuditEvent = "login_failed"
AuditLogout AuditEvent = "logout"
AuditSessionExpired AuditEvent = "session_expired"
AuditActionPerformed AuditEvent = "action_performed"
AuditPermissionDenied AuditEvent = "permission_denied"
)
// AuditLogger enregistre les événements admin vers sogoms-logs.
type AuditLogger struct {
logsPool *protocol.Pool
appID string // "admin" pour les logs admin
}
// NewAuditLogger crée un nouveau logger d'audit.
func NewAuditLogger(logsPool *protocol.Pool) *AuditLogger {
return &AuditLogger{
logsPool: logsPool,
appID: "admin",
}
}
// Log enregistre un événement d'audit (non-bloquant).
func (a *AuditLogger) Log(event AuditEvent, username string, data map[string]any) {
if a.logsPool == nil {
return
}
if data == nil {
data = make(map[string]any)
}
data["username"] = username
data["event"] = string(event)
data["timestamp"] = time.Now().Format(time.RFC3339)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req := protocol.NewRequest("log_event", map[string]any{
"app_id": a.appID,
"event_type": "audit_" + string(event),
"data": data,
})
a.logsPool.Call(ctx, req)
}()
}
// LogLogin enregistre une tentative de connexion.
func (a *AuditLogger) LogLogin(success bool, username, ip, userAgent string, reason string) {
event := AuditLoginSuccess
if !success {
event = AuditLoginFailed
}
data := map[string]any{
"ip": ip,
"user_agent": userAgent,
}
if reason != "" {
data["reason"] = reason
}
a.Log(event, username, data)
}
// LogLogout enregistre une déconnexion.
func (a *AuditLogger) LogLogout(username, ip string) {
a.Log(AuditLogout, username, map[string]any{
"ip": ip,
})
}
// LogAction enregistre une action effectuée.
func (a *AuditLogger) LogAction(username, action, appID string, details map[string]any) {
data := map[string]any{
"action": action,
"app_id": appID,
"details": details,
}
a.Log(AuditActionPerformed, username, data)
}
// LogPermissionDenied enregistre un refus de permission.
func (a *AuditLogger) LogPermissionDenied(username, action, appID, permission string) {
a.Log(AuditPermissionDenied, username, map[string]any{
"action": action,
"app_id": appID,
"permission": permission,
})
}

163
internal/admin/config.go Normal file
View File

@@ -0,0 +1,163 @@
// Package admin gère la configuration et les permissions de l'interface d'administration.
package admin
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// AdminConfig représente la configuration complète de l'admin.
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"`
MaxAge int `yaml:"max_age"` // secondes
CookieName string `yaml:"cookie_name"`
Secret string `yaml:"-"` // chargé depuis fichier
}
// RateLimitConfig configure le rate limiting.
type RateLimitConfig struct {
LoginMax int `yaml:"login_max"`
LoginWindow int `yaml:"login_window"` // secondes
}
// AdminUser représente un utilisateur admin.
type AdminUser struct {
Username string `yaml:"username"`
PasswordHash string `yaml:"password_hash"`
Role string `yaml:"role"`
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.
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)
if err != nil {
return nil, fmt.Errorf("read admin config: %w", err)
}
var cfg AdminConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse admin config: %w", err)
}
// Valeurs par défaut
if cfg.Session.MaxAge == 0 {
cfg.Session.MaxAge = 3600 // 1 heure
}
if cfg.Session.CookieName == "" {
cfg.Session.CookieName = "sogoms_admin_sid"
}
if cfg.RateLimit.LoginMax == 0 {
cfg.RateLimit.LoginMax = 5
}
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 != "" {
secretData, err := os.ReadFile(cfg.Session.SecretFile)
if err != nil {
return nil, fmt.Errorf("read session secret: %w", err)
}
cfg.Session.Secret = strings.TrimSpace(string(secretData))
}
// Valider
if len(cfg.Users) == 0 {
return nil, fmt.Errorf("no users defined")
}
if cfg.Session.Secret == "" {
return nil, fmt.Errorf("session secret is required")
}
return &cfg, nil
}
// GetUser retourne un utilisateur par son username.
func (cfg *AdminConfig) GetUser(username string) *AdminUser {
for i := range cfg.Users {
if cfg.Users[i].Username == username {
return &cfg.Users[i]
}
}
return nil
}
// GetUserByEmail retourne un utilisateur par son email.
func (cfg *AdminConfig) GetUserByEmail(email string) *AdminUser {
for i := range cfg.Users {
if cfg.Users[i].Email == email {
return &cfg.Users[i]
}
}
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

@@ -0,0 +1,131 @@
package admin
// Permission représente une permission granulaire.
type Permission string
// Permissions disponibles.
const (
PermSchemaRead Permission = "schema:read"
PermSchemaWrite Permission = "schema:write"
PermSchemaUpload Permission = "schema:upload"
PermQueriesRead Permission = "queries:read"
PermQueriesWrite Permission = "queries:write"
PermEmailsRead Permission = "emails:read"
PermEmailsWrite Permission = "emails:write"
PermCronRead Permission = "cron:read"
PermCronTrigger Permission = "cron:trigger"
PermCronWrite Permission = "cron:write"
PermLogsRead Permission = "logs:read"
PermDBIntrospect Permission = "db:introspect"
PermAll Permission = "*"
)
// PermissionChecker vérifie les droits d'un utilisateur.
type PermissionChecker struct {
config *AdminConfig
}
// NewPermissionChecker crée un nouveau vérificateur de permissions.
func NewPermissionChecker(config *AdminConfig) *PermissionChecker {
return &PermissionChecker{config: config}
}
// HasPermission vérifie si l'utilisateur a une permission pour une app.
func (pc *PermissionChecker) HasPermission(user *AdminUser, appID string, perm Permission) bool {
if user == nil {
return false
}
// Super admin a toutes les permissions
if user.IsSuperAdmin() {
return true
}
// Vérifier l'accès à l'app
if !pc.CanAccessApp(user, appID) {
return false
}
// Vérifier la permission
for _, p := range user.Permissions {
if Permission(p) == PermAll || Permission(p) == perm {
return true
}
}
return false
}
// CanAccessApp vérifie si l'utilisateur peut accéder à une app.
func (pc *PermissionChecker) CanAccessApp(user *AdminUser, appID string) bool {
if user == nil {
return false
}
// Super admin a accès à tout
if user.IsSuperAdmin() {
return true
}
// App admin : vérifier la liste des apps autorisées
for _, app := range user.Apps {
if app == appID {
return true
}
}
return false
}
// GetAccessibleApps retourne les apps accessibles par l'utilisateur.
func (pc *PermissionChecker) GetAccessibleApps(user *AdminUser, allApps []string) []string {
if user == nil {
return nil
}
// Super admin voit tout
if user.IsSuperAdmin() {
return allApps
}
// App admin : filtrer sur ses apps autorisées
var accessible []string
for _, app := range allApps {
if pc.CanAccessApp(user, app) {
accessible = append(accessible, app)
}
}
return accessible
}
// GetUserPermissions retourne les permissions effectives d'un utilisateur.
func (pc *PermissionChecker) GetUserPermissions(user *AdminUser) []Permission {
if user == nil {
return nil
}
// Super admin a toutes les permissions
if user.IsSuperAdmin() {
return []Permission{
PermSchemaRead, PermSchemaWrite, PermSchemaUpload,
PermQueriesRead, PermQueriesWrite,
PermEmailsRead, PermEmailsWrite,
PermCronRead, PermCronTrigger, PermCronWrite,
PermLogsRead,
PermDBIntrospect,
}
}
// Convertir les permissions string en Permission
perms := make([]Permission, 0, len(user.Permissions))
for _, p := range user.Permissions {
perms = append(perms, Permission(p))
}
return perms
}

View File

@@ -41,6 +41,11 @@ func NewJWT(secret string, expiration time.Duration) *JWT {
}
}
// Secret retourne le secret utilisé (pour comparaison lors du reload).
func (j *JWT) Secret() string {
return string(j.secret)
}
// Generate génère un nouveau token JWT.
func (j *JWT) Generate(userID int64, email, name, appID string) (string, error) {
now := time.Now()

View File

@@ -20,7 +20,8 @@ type AppConfig struct {
Database Database `yaml:"database"`
Auth Auth `yaml:"auth"`
Routes []Route `yaml:"routes"`
Queries *Queries // Chargé depuis config/queries/{app}/
Queries *Queries // Chargé depuis config/apps/{app}/queries/
Schema *Schema // Chargé depuis config/apps/{app}/schema.yaml
}
// Queries stocke les requêtes SQL par domaine.
@@ -48,6 +49,14 @@ func (q *Queries) Get(domain, key string) string {
return ""
}
// FileCount retourne le nombre de fichiers de queries chargés.
func (q *Queries) FileCount() int {
if q == nil || q.files == nil {
return 0
}
return len(q.files)
}
// GetMap retourne une map de requêtes (ex: login_data).
func (q *Queries) GetMap(domain, key string) map[string]string {
if q == nil || q.files == nil {
@@ -301,26 +310,33 @@ func NewRegistry(configDir string) *Registry {
}
}
// Load charge toutes les configurations depuis le répertoire routes.
// Load charge toutes les configurations depuis le répertoire apps.
func (r *Registry) Load() error {
r.mu.Lock()
defer r.mu.Unlock()
routesDir := filepath.Join(r.configDir, "routes")
entries, err := os.ReadDir(routesDir)
appsDir := filepath.Join(r.configDir, "apps")
entries, err := os.ReadDir(appsDir)
if err != nil {
return fmt.Errorf("read routes dir: %w", err)
return fmt.Errorf("read apps dir: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
if !entry.IsDir() {
continue
}
path := filepath.Join(routesDir, entry.Name())
cfg, err := r.loadAppConfig(path)
appID := entry.Name()
appConfigPath := filepath.Join(appsDir, appID, "app.yaml")
// Vérifier que app.yaml existe
if _, err := os.Stat(appConfigPath); os.IsNotExist(err) {
continue
}
cfg, err := r.loadAppConfig(appConfigPath, appID)
if err != nil {
return fmt.Errorf("load %s: %w", entry.Name(), err)
return fmt.Errorf("load %s: %w", appID, err)
}
r.apps[cfg.App] = cfg
@@ -333,7 +349,7 @@ func (r *Registry) Load() error {
}
// loadAppConfig charge une configuration d'application depuis un fichier YAML.
func (r *Registry) loadAppConfig(path string) (*AppConfig, error) {
func (r *Registry) loadAppConfig(path string, appID string) (*AppConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
@@ -372,15 +388,18 @@ func (r *Registry) loadAppConfig(path string) (*AppConfig, error) {
cfg.Auth.JWTExpiry = "24h"
}
// Charger les requêtes depuis config/queries/{app}/
cfg.Queries = r.loadQueries(cfg.App)
// Charger les requêtes depuis config/apps/{app}/queries/
cfg.Queries = r.loadQueries(appID)
// Charger le schema depuis config/apps/{app}/schema.yaml
cfg.Schema = loadSchema(r.configDir, appID)
return &cfg, nil
}
// loadQueries charge les fichiers de requêtes pour une application.
func (r *Registry) loadQueries(appID string) *Queries {
queriesDir := filepath.Join(r.configDir, "queries", appID)
queriesDir := filepath.Join(r.configDir, "apps", appID, "queries")
entries, err := os.ReadDir(queriesDir)
if err != nil {
return nil // Pas de répertoire queries, c'est OK

320
internal/config/schema.go Normal file
View File

@@ -0,0 +1,320 @@
// Package config - Schema représente la structure de la base de données.
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// Schema représente le schema d'une application.
type Schema struct {
App string `yaml:"app"`
Version string `yaml:"version"`
Tables map[string]*Table `yaml:"tables"`
}
// Table représente une table de la base de données.
type Table struct {
Columns map[string]*Column `yaml:"columns"`
Primary []string `yaml:"primary,omitempty"` // Clé primaire composite
CRUD []string `yaml:"crud"`
Order string `yaml:"order,omitempty"`
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.
type Column struct {
Type string `yaml:"type"`
Length int64 `yaml:"length,omitempty"`
Required bool `yaml:"required,omitempty"`
Primary bool `yaml:"primary,omitempty"`
Auto bool `yaml:"auto,omitempty"`
Unique bool `yaml:"unique,omitempty"`
Default string `yaml:"default,omitempty"`
Foreign string `yaml:"foreign,omitempty"` // table.column
Filter string `yaml:"filter,omitempty"` // "owner" pour filtrage auto
}
// HasCRUD vérifie si une opération CRUD est autorisée pour cette table.
func (t *Table) HasCRUD(op string) bool {
for _, c := range t.CRUD {
if c == op {
return true
}
}
return false
}
// GetOwnerColumn retourne le nom de la colonne avec filter: owner, ou "".
func (t *Table) GetOwnerColumn() string {
for name, col := range t.Columns {
if col.Filter == "owner" {
return name
}
}
return ""
}
// GetPrimaryKey retourne le nom de la clé primaire (simple).
func (t *Table) GetPrimaryKey() string {
// D'abord chercher dans Primary (composite)
if len(t.Primary) == 1 {
return t.Primary[0]
}
// Sinon chercher une colonne avec primary: true
for name, col := range t.Columns {
if col.Primary {
return name
}
}
return "id" // Par défaut
}
// IsCompositePK vérifie si la table a une clé primaire composite.
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))
for name := range t.Columns {
cols = append(cols, name)
}
return cols
}
// GetInsertColumns retourne les colonnes pour un INSERT (exclut auto-increment).
func (t *Table) GetInsertColumns() []string {
cols := make([]string, 0, len(t.Columns))
for name, col := range t.Columns {
if !col.Auto {
cols = append(cols, name)
}
}
return cols
}
// GetUpdateColumns retourne les colonnes pour un UPDATE (exclut PK et auto).
func (t *Table) GetUpdateColumns() []string {
cols := make([]string, 0, len(t.Columns))
pk := t.GetPrimaryKey()
for name, col := range t.Columns {
if name != pk && !col.Auto && col.Filter != "owner" {
cols = append(cols, name)
}
}
return cols
}
// 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)
// Construire les conditions WHERE
var conditions []string
// Filtre owner si présent
if ownerCol := t.GetOwnerColumn(); 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
if t.Order != "" {
query += " ORDER BY " + t.Order
}
return query
}
// 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()
query := fmt.Sprintf("SELECT %s FROM %s WHERE %s = ?", strings.Join(cols, ", "), tableName, pk)
// Ajouter filtre owner si présent
if ownerCol := t.GetOwnerColumn(); ownerCol != "" {
query += fmt.Sprintf(" AND %s = ?", ownerCol)
}
// Filtre soft delete
if t.SoftDelete {
query += " AND deleted_at IS NULL"
}
return query
}
// BuildInsertQuery génère la requête INSERT.
func (t *Table) BuildInsertQuery(tableName string, data map[string]any) (string, []any) {
cols := make([]string, 0)
placeholders := make([]string, 0)
args := make([]any, 0)
for name, col := range t.Columns {
if col.Auto {
continue // Skip auto-increment
}
if val, ok := data[name]; ok {
cols = append(cols, name)
placeholders = append(placeholders, "?")
args = append(args, val)
} else if col.Required && col.Default == "" {
// Champ requis sans valeur et sans default - sera une erreur DB
continue
}
}
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
tableName,
strings.Join(cols, ", "),
strings.Join(placeholders, ", "))
return query, args
}
// BuildUpdateQuery génère la requête UPDATE.
func (t *Table) BuildUpdateQuery(tableName string, id any, ownerID any, data map[string]any) (string, []any) {
setClauses := make([]string, 0)
args := make([]any, 0)
pk := t.GetPrimaryKey()
ownerCol := t.GetOwnerColumn()
for name := range t.Columns {
// Skip PK, auto, et owner
if name == pk || t.Columns[name].Auto || name == ownerCol {
continue
}
if val, ok := data[name]; ok {
setClauses = append(setClauses, name+" = ?")
args = append(args, val)
}
}
if len(setClauses) == 0 {
return "", nil
}
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s = ?",
tableName,
strings.Join(setClauses, ", "),
pk)
args = append(args, id)
// Ajouter filtre owner
if ownerCol != "" && ownerID != nil {
query += fmt.Sprintf(" AND %s = ?", ownerCol)
args = append(args, ownerID)
}
return query, args
}
// BuildDeleteQuery génère la requête DELETE.
func (t *Table) BuildDeleteQuery(tableName string, id any, ownerID any) (string, []any) {
pk := t.GetPrimaryKey()
query := fmt.Sprintf("DELETE FROM %s WHERE %s = ?", tableName, pk)
args := []any{id}
// Ajouter filtre owner
if ownerCol := t.GetOwnerColumn(); ownerCol != "" && ownerID != nil {
query += fmt.Sprintf(" AND %s = ?", ownerCol)
args = append(args, ownerID)
}
return query, args
}
// ValidateInput valide les données d'entrée selon le schema.
// Retourne les données filtrées (seuls les champs connus sont gardés).
func (t *Table) ValidateInput(data map[string]any) map[string]any {
filtered := make(map[string]any)
for name := range t.Columns {
if val, ok := data[name]; ok {
filtered[name] = val
}
}
return filtered
}
// loadSchema charge le schema depuis config/apps/{app}/schema.yaml.
func loadSchema(configDir, appID string) *Schema {
schemaPath := filepath.Join(configDir, "apps", appID, "schema.yaml")
data, err := os.ReadFile(schemaPath)
if err != nil {
return nil // Pas de schema, c'est OK
}
var schema Schema
if err := yaml.Unmarshal(data, &schema); err != nil {
return nil
}
return &schema
}

238
internal/cron/scheduler.go Normal file
View File

@@ -0,0 +1,238 @@
// Package cron fournit un scheduler pour les tâches planifiées.
// Supporte le format cron standard (* * * * *) avec timezone.
package cron
import (
"fmt"
"strconv"
"strings"
"time"
)
// Schedule représente une expression cron parsée.
type Schedule struct {
Minute []int // 0-59
Hour []int // 0-23
DayOfMonth []int // 1-31
Month []int // 1-12
DayOfWeek []int // 0-6 (0=dimanche)
Location *time.Location
}
// ParseSchedule parse une expression cron standard.
// Format: "minute hour day month weekday"
// Exemples:
// - "0 8 * * 1-5" : 8h00 du lundi au vendredi
// - "*/15 * * * *" : toutes les 15 minutes
// - "0 9 1 * *" : 9h00 le premier de chaque mois
func ParseSchedule(expr string, location *time.Location) (*Schedule, error) {
if location == nil {
location = time.UTC
}
parts := strings.Fields(expr)
if len(parts) != 5 {
return nil, fmt.Errorf("invalid cron expression: expected 5 fields, got %d", len(parts))
}
s := &Schedule{Location: location}
var err error
// Minute (0-59)
s.Minute, err = parseField(parts[0], 0, 59)
if err != nil {
return nil, fmt.Errorf("minute: %w", err)
}
// Hour (0-23)
s.Hour, err = parseField(parts[1], 0, 23)
if err != nil {
return nil, fmt.Errorf("hour: %w", err)
}
// Day of month (1-31)
s.DayOfMonth, err = parseField(parts[2], 1, 31)
if err != nil {
return nil, fmt.Errorf("day of month: %w", err)
}
// Month (1-12)
s.Month, err = parseField(parts[3], 1, 12)
if err != nil {
return nil, fmt.Errorf("month: %w", err)
}
// Day of week (0-6, 0=Sunday)
s.DayOfWeek, err = parseField(parts[4], 0, 6)
if err != nil {
return nil, fmt.Errorf("day of week: %w", err)
}
return s, nil
}
// parseField parse un champ cron individuel.
// Supporte: *, */n, n, n-m, n,m,o
func parseField(field string, min, max int) ([]int, error) {
var values []int
// Gérer les listes (ex: "1,3,5")
for _, part := range strings.Split(field, ",") {
part = strings.TrimSpace(part)
// Step (*/n ou n-m/s)
step := 1
if idx := strings.Index(part, "/"); idx != -1 {
stepStr := part[idx+1:]
var err error
step, err = strconv.Atoi(stepStr)
if err != nil || step < 1 {
return nil, fmt.Errorf("invalid step: %s", stepStr)
}
part = part[:idx]
}
// Range ou valeur
var rangeMin, rangeMax int
if part == "*" {
rangeMin, rangeMax = min, max
} else if idx := strings.Index(part, "-"); idx != -1 {
var err error
rangeMin, err = strconv.Atoi(part[:idx])
if err != nil {
return nil, fmt.Errorf("invalid range start: %s", part[:idx])
}
rangeMax, err = strconv.Atoi(part[idx+1:])
if err != nil {
return nil, fmt.Errorf("invalid range end: %s", part[idx+1:])
}
} else {
val, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("invalid value: %s", part)
}
rangeMin, rangeMax = val, val
}
// Valider les bornes
if rangeMin < min || rangeMax > max || rangeMin > rangeMax {
return nil, fmt.Errorf("value out of range [%d-%d]: %d-%d", min, max, rangeMin, rangeMax)
}
// Générer les valeurs
for v := rangeMin; v <= rangeMax; v += step {
values = append(values, v)
}
}
if len(values) == 0 {
return nil, fmt.Errorf("no values")
}
return values, nil
}
// Next calcule la prochaine exécution après 'from'.
func (s *Schedule) Next(from time.Time) time.Time {
// Convertir dans la timezone du schedule
t := from.In(s.Location)
// Commencer à la minute suivante
t = t.Truncate(time.Minute).Add(time.Minute)
// Limite de recherche (1 an)
limit := t.Add(366 * 24 * time.Hour)
for t.Before(limit) {
// Vérifier le mois
if !contains(s.Month, int(t.Month())) {
// Passer au premier jour du mois suivant
t = time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, s.Location)
continue
}
// Vérifier le jour du mois ET le jour de la semaine
// En cron standard, si les deux sont spécifiés (non-*), c'est un OR
dayMatch := contains(s.DayOfMonth, t.Day())
weekdayMatch := contains(s.DayOfWeek, int(t.Weekday()))
// Si les deux champs sont "*", les deux sont vrais
// Si l'un est spécifié et pas l'autre, seul celui spécifié compte
// Si les deux sont spécifiés, c'est OR (comportement cron standard)
bothWildcard := len(s.DayOfMonth) == 31 && len(s.DayOfWeek) == 7
if bothWildcard {
// Les deux sont *, donc on accepte tous les jours
} else if len(s.DayOfMonth) == 31 {
// Jour du mois est *, seul le jour de semaine compte
if !weekdayMatch {
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, s.Location)
continue
}
} else if len(s.DayOfWeek) == 7 {
// Jour de semaine est *, seul le jour du mois compte
if !dayMatch {
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, s.Location)
continue
}
} else {
// Les deux sont spécifiés : OR
if !dayMatch && !weekdayMatch {
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, s.Location)
continue
}
}
// Vérifier l'heure
if !contains(s.Hour, t.Hour()) {
// Passer à l'heure suivante
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, s.Location)
continue
}
// Vérifier la minute
if !contains(s.Minute, t.Minute()) {
// Passer à la minute suivante
t = t.Add(time.Minute)
continue
}
// Trouvé !
return t
}
// Pas trouvé dans l'année, retourner zero
return time.Time{}
}
// NextN retourne les N prochaines exécutions.
func (s *Schedule) NextN(from time.Time, n int) []time.Time {
var times []time.Time
t := from
for i := 0; i < n; i++ {
next := s.Next(t)
if next.IsZero() {
break
}
times = append(times, next)
t = next
}
return times
}
// contains vérifie si une valeur est dans une liste.
func contains(list []int, val int) bool {
for _, v := range list {
if v == val {
return true
}
}
return false
}
// LoadLocation charge une timezone par nom (ex: "Europe/Paris").
func LoadLocation(name string) (*time.Location, error) {
if name == "" || name == "UTC" {
return time.UTC, nil
}
return time.LoadLocation(name)
}

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

2
sogoms.svg Normal file
View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24" width="512" height="512"><path d="M0,3v8H11V0H3A3,3,0,0,0,0,3Z"/><path d="M21,0H13V11H24V3A3,3,0,0,0,21,0Z"/><path d="M0,21a3,3,0,0,0,3,3h8V13H0Z"/><path d="M13,24h8a3,3,0,0,0,3-3V13H13Z"/></svg>

After

Width:  |  Height:  |  Size: 328 B