Compare commits
3 Commits
v1.0.1
...
feature/ph
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b1977e0c4 | |||
| 1274400b08 | |||
| 65da4efdad |
214
DOCTECH.md
214
DOCTECH.md
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
**Service Oriented GO MicroServices** - Plateforme SaaS modulaire multi-tenant.
|
**Service Oriented GO MicroServices** - Plateforme SaaS modulaire multi-tenant.
|
||||||
|
|
||||||
Version: 1.0.1
|
Version: 1.0.5
|
||||||
Date: 16 décembre 2025
|
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-db` | Accès MariaDB, pool par application | `/run/sogoms-db.1.sock` |
|
||||||
| `sogoms-logs` | Logging centralisé, rotation auto | `/run/sogoms-logs.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-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)
|
### sogoctl (Superviseur)
|
||||||
|
|
||||||
@@ -63,6 +65,9 @@ Client → Nginx(:443) → Sogoway(:8080) → Sogoms-* → MariaDB
|
|||||||
- Health checks périodiques (socket ou HTTP)
|
- Health checks périodiques (socket ou HTTP)
|
||||||
- Redémarrage automatique en cas de crash
|
- Redémarrage automatique en cas de crash
|
||||||
- Arrêt gracieux sur SIGTERM/SIGINT
|
- 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
|
```yaml
|
||||||
# config/sogoctl.yaml
|
# config/sogoctl.yaml
|
||||||
@@ -96,6 +101,7 @@ services:
|
|||||||
- Authentification JWT (HS256)
|
- Authentification JWT (HS256)
|
||||||
- CRUD générique paramétré par YAML
|
- CRUD générique paramétré par YAML
|
||||||
- Logging des événements (login, register)
|
- Logging des événements (login, register)
|
||||||
|
- **Rechargement à chaud** : SIGHUP recharge registry + JWT sans restart
|
||||||
|
|
||||||
### sogoms-db (Base de données)
|
### sogoms-db (Base de données)
|
||||||
|
|
||||||
@@ -151,6 +157,167 @@ body_html: |
|
|||||||
<p>Bienvenue sur notre plateforme.</p>
|
<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
|
## Communication IPC
|
||||||
@@ -461,7 +628,15 @@ sogoms/
|
|||||||
│ └── sogoms/
|
│ └── sogoms/
|
||||||
│ ├── db/main.go # Microservice DB
|
│ ├── db/main.go # Microservice DB
|
||||||
│ ├── logs/main.go # Microservice Logs
|
│ ├── 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/
|
├── internal/
|
||||||
│ ├── protocol/
|
│ ├── protocol/
|
||||||
│ │ ├── message.go # Structs Request/Response
|
│ │ ├── message.go # Structs Request/Response
|
||||||
@@ -469,30 +644,31 @@ sogoms/
|
|||||||
│ │ └── client.go # Client + Pool connexions
|
│ │ └── client.go # Client + Pool connexions
|
||||||
│ ├── config/
|
│ ├── config/
|
||||||
│ │ └── config.go # Registry, Queries, CUD
|
│ │ └── config.go # Registry, Queries, CUD
|
||||||
|
│ ├── cron/
|
||||||
|
│ │ └── scheduler.go # Parser cron, calcul next run
|
||||||
│ ├── auth/
|
│ ├── auth/
|
||||||
│ │ ├── jwt.go # Génération/validation JWT
|
│ │ ├── jwt.go # Génération/validation JWT
|
||||||
│ │ └── password.go # Hash bcrypt
|
│ │ └── password.go # Hash bcrypt
|
||||||
|
│ ├── admin/
|
||||||
|
│ │ ├── config.go # Chargement admin_users.yaml
|
||||||
|
│ │ ├── permissions.go # Vérification droits
|
||||||
|
│ │ └── audit.go # Logging actions
|
||||||
│ └── version/
|
│ └── version/
|
||||||
│ └── version.go # Version, BuildTime
|
│ └── version.go # Version, BuildTime
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── sogoctl.yaml
|
│ ├── sogoctl.yaml
|
||||||
│ ├── routes/
|
│ └── apps/
|
||||||
│ │ └── prokov.yaml # Config app (DB, auth, SMTP)
|
|
||||||
│ ├── queries/
|
|
||||||
│ │ └── prokov/
|
|
||||||
│ │ ├── auth.yaml
|
|
||||||
│ │ ├── projects.yaml
|
|
||||||
│ │ ├── tasks.yaml
|
|
||||||
│ │ ├── tags.yaml
|
|
||||||
│ │ └── statuses.yaml
|
|
||||||
│ └── emails/
|
|
||||||
│ └── prokov/
|
│ └── prokov/
|
||||||
│ ├── welcome.yaml
|
│ ├── app.yaml # Config app (DB, auth, SMTP)
|
||||||
│ ├── password_reset.yaml
|
│ ├── schema.yaml # Schema DB généré
|
||||||
│ ├── task_assigned.yaml
|
│ ├── cron.yaml # Jobs planifiés
|
||||||
│ └── tasks_today.yaml
|
│ ├── queries/ # Requêtes SQL
|
||||||
├── clients/
|
│ │ ├── auth.yaml
|
||||||
│ └── prokov.sql # Schéma DB
|
│ │ ├── projects.yaml
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── emails/ # Templates email
|
||||||
|
│ ├── welcome.yaml
|
||||||
|
│ └── tasks_today.yaml
|
||||||
├── bin/ # Binaires compilés
|
├── bin/ # Binaires compilés
|
||||||
├── deploy.sh # Script déploiement
|
├── deploy.sh # Script déploiement
|
||||||
├── VERSION # Numéro de version
|
├── VERSION # Numéro de version
|
||||||
|
|||||||
338
GO-HTMX.md
Normal file
338
GO-HTMX.md
Normal 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
|
||||||
@@ -18,7 +18,7 @@ schema.yaml → SOGOMS → API REST + Auth + CRUD + Push
|
|||||||
- **Sécurisé** : JWT, isolation par user_id, bcrypt
|
- **Sécurisé** : JWT, isolation par user_id, bcrypt
|
||||||
- **Auto-supervisé** : health checks, restart automatique
|
- **Auto-supervisé** : health checks, restart automatique
|
||||||
- **Temps réel** : push MQTT vers les applications (roadmap)
|
- **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
|
## Services actuels
|
||||||
|
|
||||||
@@ -29,6 +29,8 @@ schema.yaml → SOGOMS → API REST + Auth + CRUD + Push
|
|||||||
| `sogoms-db` | Accès MariaDB | Stable |
|
| `sogoms-db` | Accès MariaDB | Stable |
|
||||||
| `sogoms-logs` | Logging centralisé | Stable |
|
| `sogoms-logs` | Logging centralisé | Stable |
|
||||||
| `sogoms-smtp` | Envoi emails, templates | Stable |
|
| `sogoms-smtp` | Envoi emails, templates | Stable |
|
||||||
|
| `sogoms-cron` | Tâches planifiées | Stable |
|
||||||
|
| `sogoms-admin` | Interface web administration, scan DB | Stable |
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -36,7 +38,6 @@ schema.yaml → SOGOMS → API REST + Auth + CRUD + Push
|
|||||||
|-------|---------|-------------|
|
|-------|---------|-------------|
|
||||||
| 11 | sogoms-crypt | Chiffrement données sensibles |
|
| 11 | sogoms-crypt | Chiffrement données sensibles |
|
||||||
| 12 | sogoms-imap/mailproc | Lecture et traitement emails |
|
| 12 | sogoms-imap/mailproc | Lecture et traitement emails |
|
||||||
| 13 | sogoms-cron | Tâches planifiées |
|
|
||||||
| 14 | sogoms-push | Push temps réel (MQTT) |
|
| 14 | sogoms-push | Push temps réel (MQTT) |
|
||||||
| 15 | sogoms-schema | API auto-générée depuis schema |
|
| 15 | sogoms-schema | API auto-générée depuis schema |
|
||||||
|
|
||||||
|
|||||||
559
TODO.md
559
TODO.md
@@ -161,18 +161,24 @@ curl https://prokov.unikoffice.com/api/auth/me \
|
|||||||
|
|
||||||
## Phase 13 : Microservice Cron
|
## Phase 13 : Microservice Cron
|
||||||
|
|
||||||
- [ ] `cmd/sogoms/cron/main.go` : point d'entrée
|
- [x] `cmd/sogoms/cron/main.go` : point d'entrée
|
||||||
- [ ] Écoute sur Unix socket `/run/sogoms-cron.1.sock`
|
- [x] `internal/cron/scheduler.go` : parser cron + calcul next run
|
||||||
- [ ] Config YAML par application (`config/cron/{app}.yaml`)
|
- [x] Écoute sur Unix socket `/run/sogoms-cron.1.sock`
|
||||||
- [ ] Parser cron schedule (format standard `* * * * *`)
|
- [x] Config YAML par application (`config/apps/{app}/cron.yaml`)
|
||||||
- [ ] Action `list` : liste les jobs configurés
|
- [x] Parser cron schedule (format standard `* * * * *`)
|
||||||
- [ ] Action `trigger` : déclenche un job manuellement
|
- [x] Support timezone (Europe/Paris)
|
||||||
- [ ] Action `status` : statut des dernières exécutions
|
- [x] Retry configurable (max_attempts, delay)
|
||||||
- [ ] Type `service` : appel service interne (sogoms-smtp, sogoms-db, etc.)
|
- [x] Historique des exécutions (configurable, défaut 7 jours)
|
||||||
- [ ] Type `http` : appel HTTP (GET/POST) vers endpoint interne ou externe
|
- [x] Action `list` : liste les jobs configurés avec prochain run
|
||||||
- [ ] Type `query_email` : requête DB + envoi email avec résultat
|
- [x] Action `trigger` : déclenche un job manuellement
|
||||||
- [ ] Logging des exécutions dans sogoms-logs
|
- [x] Action `status` : historique des dernières exécutions
|
||||||
- [ ] Application Prokov : email quotidien `tasks_today`
|
- [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)
|
## 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
|
### 15a. Définition du Schema
|
||||||
|
|
||||||
- [ ] Format `config/schema/{app}.yaml` : tables, fields, relations
|
- [x] Format `config/schemas/{app}.yaml` : tables, fields, relations
|
||||||
- [ ] Types supportés : int, string, text, bool, date, datetime, json
|
- [x] Types supportés : int, string, text, float, date, datetime, json
|
||||||
- [ ] Contraintes : primary, auto, unique, required, default
|
- [x] Contraintes : primary, auto, unique, required, default
|
||||||
- [ ] Relations : foreign key avec `foreign: table.field`
|
- [x] Relations : foreign key avec `foreign: table.field`
|
||||||
- [ ] Sécurité : `filter: owner` pour filtrage auto par user_id
|
- [x] Sécurité : `filter: owner` pour filtrage auto par user_id
|
||||||
- [ ] Auth : `auth: login`, `auth: password` pour détection auto
|
- [ ] 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
|
- [ ] 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
|
- [x] Action `introspect` dans sogoms-db : scan INFORMATION_SCHEMA
|
||||||
- [ ] Commande `generate {app}` : génère queries YAML depuis 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 `validate {app}` : valide le schema
|
||||||
- [ ] Commande `diff {app}` : compare schema vs DB réelle
|
- [ ] Commande `diff {app}` : compare schema vs DB réelle
|
||||||
- [ ] Commande `migrate {app}` : génère SQL de migration
|
- [ ] Commande `migrate {app}` : génère SQL de migration
|
||||||
- [ ] Commande `init {app}` : crée schema depuis DB existante (reverse)
|
|
||||||
|
|
||||||
### 15c. Runtime Dynamique (sogoway)
|
### 15c. Runtime Dynamique (sogoway)
|
||||||
|
|
||||||
- [ ] Chargement schema au démarrage
|
- [x] Chargement schema au démarrage
|
||||||
- [ ] Routes CRUD auto-générées depuis schema
|
- [x] Routes CRUD auto-générées depuis schema
|
||||||
- [ ] Validation des inputs selon types/contraintes
|
- [x] Validation des inputs selon types/contraintes
|
||||||
- [ ] Filtrage user_id automatique (filter: owner)
|
- [x] Filtrage user_id automatique (filter: owner)
|
||||||
- [ ] Gestion relations (include, nested)
|
- [ ] 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
|
### 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
|
- [ ] Documentation auto-générée
|
||||||
- [ ] Utilisable par Flutter pour génération de formulaires
|
- [ ] 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
|
## Hors scope V1
|
||||||
|
|
||||||
- sogorch (orchestrateur scénarios)
|
- sogorch (orchestrateur scénarios)
|
||||||
@@ -251,3 +417,342 @@ Cette phase transforme SOGOMS en générateur d'API automatique.
|
|||||||
- Multi-tenant avancé (workspaces, partage)
|
- Multi-tenant avancé (workspaces, partage)
|
||||||
- Rate limiting
|
- Rate limiting
|
||||||
- Rôles utilisateurs (admin, manager, user)
|
- 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
|
||||||
|
|||||||
703
cmd/sogoms/admin/handlers.go
Normal file
703
cmd/sogoms/admin/handlers.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
455
cmd/sogoms/admin/handlers_2fa.go
Normal file
455
cmd/sogoms/admin/handlers_2fa.go
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"sogoms.com/internal/admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleTwoFAPage affiche la page de vérification 2FA.
|
||||||
|
func (s *AdminServer) HandleTwoFAPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Récupérer la session (doit être pending)
|
||||||
|
session, err := s.sessions.GetSessionFromRequest(r)
|
||||||
|
if err != nil || session == nil {
|
||||||
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que la session est en attente de 2FA
|
||||||
|
if !session.TwoFAPending {
|
||||||
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"Title": "Vérification 2FA",
|
||||||
|
"CSRFToken": session.CSRFToken,
|
||||||
|
"Error": r.URL.Query().Get("error"),
|
||||||
|
"Username": session.Username,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.render(w, "2fa_verify.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleTwoFAVerify valide le code TOTP ou le code de secours.
|
||||||
|
func (s *AdminServer) HandleTwoFAVerify(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := getClientIP(r)
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
if !s.rateLimiter.Allow(ip) {
|
||||||
|
http.Redirect(w, r, "/admin/2fa/verify?error=Trop+de+tentatives", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer la session
|
||||||
|
session, err := s.sessions.GetSessionFromRequest(r)
|
||||||
|
if err != nil || session == nil {
|
||||||
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !session.TwoFAPending {
|
||||||
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/2fa/verify?error=Formulaire+invalide", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le CSRF token
|
||||||
|
if r.FormValue("csrf_token") != session.CSRFToken {
|
||||||
|
http.Redirect(w, r, "/admin/2fa/verify?error=Token+CSRF+invalide", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.rateLimiter.Record(ip)
|
||||||
|
|
||||||
|
// Récupérer l'utilisateur
|
||||||
|
user := s.adminCfg.GetUser(session.Username)
|
||||||
|
if user == nil {
|
||||||
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := strings.TrimSpace(r.FormValue("code"))
|
||||||
|
useBackup := r.FormValue("use_backup") == "true"
|
||||||
|
backupCode := strings.TrimSpace(r.FormValue("backup_code"))
|
||||||
|
|
||||||
|
var verified bool
|
||||||
|
|
||||||
|
if useBackup && backupCode != "" {
|
||||||
|
// Vérifier le code de secours
|
||||||
|
index := VerifyBackupCode(backupCode, user.BackupCodes)
|
||||||
|
if index >= 0 {
|
||||||
|
verified = true
|
||||||
|
// Supprimer le code utilisé
|
||||||
|
user.BackupCodes = RemoveBackupCode(user.BackupCodes, index)
|
||||||
|
// TODO: sauvegarder la config mise à jour
|
||||||
|
log.Printf("[admin] 2FA backup code used by %s, %d remaining", session.Username, len(user.BackupCodes))
|
||||||
|
}
|
||||||
|
} else if code != "" {
|
||||||
|
// Vérifier le code TOTP
|
||||||
|
if ValidateTOTPCode(user.TwoFASecret, code) {
|
||||||
|
verified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verified {
|
||||||
|
s.audit.LogAction(session.Username, "2fa_failed", "", map[string]any{
|
||||||
|
"ip": ip,
|
||||||
|
"use_backup": useBackup,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/admin/2fa/verify?error=Code+invalide", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2FA validé - compléter la session
|
||||||
|
s.sessions.CompleteTwoFA(session.ID)
|
||||||
|
|
||||||
|
// Mettre à jour le cookie avec la nouvelle expiration
|
||||||
|
session, _ = s.sessions.Get(session.ID)
|
||||||
|
s.sessions.SetCookie(w, session)
|
||||||
|
|
||||||
|
// Log succès
|
||||||
|
s.audit.LogAction(session.Username, "2fa_verified", "", map[string]any{
|
||||||
|
"ip": ip,
|
||||||
|
"use_backup": useBackup,
|
||||||
|
})
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleTwoFASetupPage affiche la page de configuration 2FA.
|
||||||
|
func (s *AdminServer) HandleTwoFASetupPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || user == nil {
|
||||||
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si 2FA déjà activé, rediriger vers les paramètres
|
||||||
|
if user.TwoFAEnabled {
|
||||||
|
http.Redirect(w, r, "/admin/security?info=2FA+déjà+activé", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer un nouveau secret TOTP
|
||||||
|
key, err := GenerateTOTPSecret(s.adminCfg.TwoFA.IssuerName, user.Username)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[admin] generate TOTP secret error: %v", err)
|
||||||
|
http.Error(w, "Erreur génération secret", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer le QR code
|
||||||
|
qrDataURL, err := GenerateQRCodeDataURL(key)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[admin] generate QR code error: %v", err)
|
||||||
|
http.Error(w, "Erreur génération QR code", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer les codes de secours
|
||||||
|
backupCodes, err := GenerateBackupCodes(10)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[admin] generate backup codes error: %v", err)
|
||||||
|
http.Error(w, "Erreur génération codes secours", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"Title": "Activer 2FA",
|
||||||
|
"User": user,
|
||||||
|
"Session": session,
|
||||||
|
"CSRFToken": session.CSRFToken,
|
||||||
|
"QRCodeDataURL": qrDataURL,
|
||||||
|
"TwoFASecret": key.Secret(),
|
||||||
|
"BackupCodes": backupCodes,
|
||||||
|
"BackupCodesFormatted": FormatBackupCodes(backupCodes),
|
||||||
|
"Error": r.URL.Query().Get("error"),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.render(w, "2fa_setup.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleTwoFASetupConfirm confirme l'activation du 2FA.
|
||||||
|
func (s *AdminServer) HandleTwoFASetupConfirm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || user == nil {
|
||||||
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/2fa/setup?error=Formulaire+invalide", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le CSRF token
|
||||||
|
if r.FormValue("csrf_token") != session.CSRFToken {
|
||||||
|
http.Redirect(w, r, "/admin/2fa/setup?error=Token+CSRF+invalide", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := r.FormValue("temp_secret")
|
||||||
|
verifyCode := strings.TrimSpace(r.FormValue("verify_code"))
|
||||||
|
backupCodesRaw := r.FormValue("backup_codes") // JSON array
|
||||||
|
|
||||||
|
// Valider le code TOTP
|
||||||
|
if !ValidateTOTPCode(secret, verifyCode) {
|
||||||
|
http.Redirect(w, r, "/admin/2fa/setup?error=Code+invalide", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser et hasher les backup codes
|
||||||
|
backupCodes := strings.Split(backupCodesRaw, ",")
|
||||||
|
hashedCodes, err := HashBackupCodes(backupCodes)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[admin] hash backup codes error: %v", err)
|
||||||
|
http.Error(w, "Erreur hash codes secours", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour l'utilisateur
|
||||||
|
user.TwoFAEnabled = true
|
||||||
|
user.TwoFASecret = secret
|
||||||
|
user.BackupCodes = hashedCodes
|
||||||
|
|
||||||
|
// Sauvegarder la configuration
|
||||||
|
if err := s.saveAdminConfig(); err != nil {
|
||||||
|
log.Printf("[admin] save admin config error: %v", err)
|
||||||
|
http.Error(w, "Erreur sauvegarde configuration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log l'action
|
||||||
|
s.audit.LogAction(user.Username, "2fa_enabled", "", map[string]any{
|
||||||
|
"ip": getClientIP(r),
|
||||||
|
})
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/admin/?flash=success&msg=2FA+activé+avec+succès", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleTwoFADisable désactive le 2FA.
|
||||||
|
func (s *AdminServer) HandleTwoFADisable(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || user == nil {
|
||||||
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le CSRF token
|
||||||
|
if r.FormValue("csrf_token") != session.CSRFToken {
|
||||||
|
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si le rôle exige 2FA
|
||||||
|
if user.NeedsTwoFA(&s.adminCfg.TwoFA) && !user.TwoFAEnabled {
|
||||||
|
http.Error(w, "2FA obligatoire pour votre rôle", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demander le mot de passe pour confirmer
|
||||||
|
password := r.FormValue("password")
|
||||||
|
if !verifyUserPassword(user, password) {
|
||||||
|
http.Redirect(w, r, "/admin/security?error=Mot+de+passe+incorrect", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Désactiver 2FA
|
||||||
|
user.TwoFAEnabled = false
|
||||||
|
user.TwoFASecret = ""
|
||||||
|
user.BackupCodes = nil
|
||||||
|
|
||||||
|
// Sauvegarder la configuration
|
||||||
|
if err := s.saveAdminConfig(); err != nil {
|
||||||
|
log.Printf("[admin] save admin config error: %v", err)
|
||||||
|
http.Error(w, "Erreur sauvegarde configuration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log l'action
|
||||||
|
s.audit.LogAction(user.Username, "2fa_disabled", "", map[string]any{
|
||||||
|
"ip": getClientIP(r),
|
||||||
|
})
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/admin/?flash=success&msg=2FA+désactivé", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSecurityPage affiche la page de sécurité (2FA settings).
|
||||||
|
func (s *AdminServer) HandleSecurityPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || user == nil {
|
||||||
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si 2FA est requis pour ce user
|
||||||
|
twoFARequired := user.NeedsTwoFA(&s.adminCfg.TwoFA)
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"Title": "Sécurité",
|
||||||
|
"User": user,
|
||||||
|
"Session": session,
|
||||||
|
"CSRFToken": session.CSRFToken,
|
||||||
|
"IsSuperAdmin": user.IsSuperAdmin(),
|
||||||
|
"TwoFAEnabled": user.TwoFAEnabled,
|
||||||
|
"TwoFARequired": twoFARequired,
|
||||||
|
"BackupCount": len(user.BackupCodes),
|
||||||
|
"Error": r.URL.Query().Get("error"),
|
||||||
|
"Info": r.URL.Query().Get("info"),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.render(w, "security.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveAdminConfig sauvegarde la configuration admin dans le fichier YAML.
|
||||||
|
func (s *AdminServer) saveAdminConfig() error {
|
||||||
|
return admin.SaveAdminConfig(s.adminCfg, "/secrets/admin_users.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyUserPassword vérifie le mot de passe d'un utilisateur.
|
||||||
|
func verifyUserPassword(user *admin.AdminUser, password string) bool {
|
||||||
|
if user == nil || password == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Utiliser bcrypt pour vérifier
|
||||||
|
return checkPasswordHash(password, user.PasswordHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPasswordHash vérifie un mot de passe contre son hash bcrypt.
|
||||||
|
func checkPasswordHash(password, hash string) bool {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUsersPage affiche la liste des utilisateurs admin (super_admin only).
|
||||||
|
func (s *AdminServer) HandleUsersPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || user == nil {
|
||||||
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seul super_admin peut voir cette page
|
||||||
|
if !user.IsSuperAdmin() {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire la liste des utilisateurs
|
||||||
|
type UserInfo struct {
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
Role string
|
||||||
|
TwoFAEnabled bool
|
||||||
|
BackupCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
users := make([]UserInfo, 0, len(s.adminCfg.Users))
|
||||||
|
for _, u := range s.adminCfg.Users {
|
||||||
|
users = append(users, UserInfo{
|
||||||
|
Username: u.Username,
|
||||||
|
Email: u.Email,
|
||||||
|
Role: u.Role,
|
||||||
|
TwoFAEnabled: u.TwoFAEnabled,
|
||||||
|
BackupCount: len(u.BackupCodes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"Title": "Utilisateurs",
|
||||||
|
"User": user,
|
||||||
|
"Session": session,
|
||||||
|
"CSRFToken": session.CSRFToken,
|
||||||
|
"IsSuperAdmin": user.IsSuperAdmin(),
|
||||||
|
"Users": users,
|
||||||
|
"Flash": r.URL.Query().Get("flash"),
|
||||||
|
"FlashMessage": r.URL.Query().Get("msg"),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.render(w, "users.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleReset2FA reset le 2FA d'un utilisateur (super_admin only).
|
||||||
|
func (s *AdminServer) HandleReset2FA(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
currentUser := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || currentUser == nil {
|
||||||
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seul super_admin peut reset le 2FA
|
||||||
|
if !currentUser.IsSuperAdmin() {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier CSRF
|
||||||
|
if r.FormValue("csrf_token") != session.CSRFToken {
|
||||||
|
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetUsername := r.FormValue("username")
|
||||||
|
if targetUsername == "" {
|
||||||
|
http.Redirect(w, r, "/admin/users?flash=error&msg=Username+manquant", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trouver l'utilisateur cible
|
||||||
|
targetUser := s.adminCfg.GetUser(targetUsername)
|
||||||
|
if targetUser == nil {
|
||||||
|
http.Redirect(w, r, "/admin/users?flash=error&msg=Utilisateur+non+trouvé", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset le 2FA
|
||||||
|
targetUser.TwoFAEnabled = false
|
||||||
|
targetUser.TwoFASecret = ""
|
||||||
|
targetUser.BackupCodes = nil
|
||||||
|
|
||||||
|
// Sauvegarder
|
||||||
|
if err := s.saveAdminConfig(); err != nil {
|
||||||
|
log.Printf("[admin] save admin config error: %v", err)
|
||||||
|
http.Redirect(w, r, "/admin/users?flash=error&msg=Erreur+sauvegarde", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log l'action
|
||||||
|
s.audit.LogAction(currentUser.Username, "2fa_reset", targetUsername, map[string]any{
|
||||||
|
"ip": getClientIP(r),
|
||||||
|
"target_user": targetUsername,
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("[admin] 2FA reset for user %s by %s", targetUsername, currentUser.Username)
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/admin/users?flash=success&msg=2FA+réinitialisé+pour+"+targetUsername, http.StatusSeeOther)
|
||||||
|
}
|
||||||
659
cmd/sogoms/admin/handlers_infra.go
Normal file
659
cmd/sogoms/admin/handlers_infra.go
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sogoms.com/internal/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Servers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// HandleInfraPage affiche la page principale de l'infrastructure.
|
||||||
|
func (s *AdminServer) HandleInfraPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || user == nil {
|
||||||
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seul super_admin peut gérer l'infra
|
||||||
|
if !user.IsSuperAdmin() {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les serveurs
|
||||||
|
servers, err := s.infraDB.ListServers()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[admin] list servers error: %v", err)
|
||||||
|
servers = []infra.Server{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pour chaque serveur, récupérer les containers
|
||||||
|
type ServerView struct {
|
||||||
|
infra.Server
|
||||||
|
Containers []infra.Container
|
||||||
|
ContainerCount int
|
||||||
|
NginxCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
serverViews := make([]ServerView, 0, len(servers))
|
||||||
|
for _, srv := range servers {
|
||||||
|
containers, _ := s.infraDB.ListContainersByServer(srv.ID)
|
||||||
|
nginxConfigs, _ := s.infraDB.ListNginxConfigsByServer(srv.ID)
|
||||||
|
|
||||||
|
serverViews = append(serverViews, ServerView{
|
||||||
|
Server: srv,
|
||||||
|
Containers: containers,
|
||||||
|
ContainerCount: len(containers),
|
||||||
|
NginxCount: len(nginxConfigs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"Title": "Infrastructure",
|
||||||
|
"User": user,
|
||||||
|
"Session": session,
|
||||||
|
"CSRFToken": session.CSRFToken,
|
||||||
|
"IsSuperAdmin": user.IsSuperAdmin(),
|
||||||
|
"Servers": serverViews,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flash message
|
||||||
|
if flash := r.URL.Query().Get("flash"); flash != "" {
|
||||||
|
data["Flash"] = flash
|
||||||
|
data["FlashMessage"] = r.URL.Query().Get("msg")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.render(w, "infra.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleServerNewPage affiche le formulaire d'ajout de serveur.
|
||||||
|
func (s *AdminServer) HandleServerNewPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || user == nil {
|
||||||
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.IsSuperAdmin() {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"Title": "Nouveau Serveur",
|
||||||
|
"User": user,
|
||||||
|
"Session": session,
|
||||||
|
"CSRFToken": session.CSRFToken,
|
||||||
|
"IsSuperAdmin": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.render(w, "server_new.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleServerCreate crée un nouveau serveur.
|
||||||
|
func (s *AdminServer) HandleServerCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser les valeurs
|
||||||
|
sshPort, _ := strconv.Atoi(r.FormValue("ssh_port"))
|
||||||
|
if sshPort == 0 {
|
||||||
|
sshPort = 22
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &infra.Server{
|
||||||
|
Name: r.FormValue("name"),
|
||||||
|
Host: r.FormValue("host"),
|
||||||
|
VpnIP: r.FormValue("vpn_ip"),
|
||||||
|
SSHPort: sshPort,
|
||||||
|
SSHUser: r.FormValue("ssh_user"),
|
||||||
|
SSHKeyFile: r.FormValue("ssh_key_file"),
|
||||||
|
HasIncus: r.FormValue("has_incus") == "on",
|
||||||
|
HasNginx: r.FormValue("has_nginx") == "on",
|
||||||
|
Status: infra.ServerStatusUnknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.Name == "" || server.Host == "" {
|
||||||
|
http.Error(w, "Nom et host requis", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.infraDB.CreateServer(server); err != nil {
|
||||||
|
log.Printf("[admin] create server error: %v", err)
|
||||||
|
http.Error(w, "Erreur création serveur", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audit.LogAction(user.Username, "create_server", server.Name, map[string]any{
|
||||||
|
"ip": getClientIP(r),
|
||||||
|
"host": server.Host,
|
||||||
|
})
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/admin/infra?flash=success&msg=Serveur+créé", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleServerDetailPage affiche les détails d'un serveur.
|
||||||
|
func (s *AdminServer) HandleServerDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid server ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := s.infraDB.GetServer(serverID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
http.Error(w, "Server not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les containers et nginx configs
|
||||||
|
containers, _ := s.infraDB.ListContainersByServer(serverID)
|
||||||
|
nginxConfigs, _ := s.infraDB.ListNginxConfigsByServer(serverID)
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"Title": server.Name,
|
||||||
|
"User": user,
|
||||||
|
"Session": session,
|
||||||
|
"CSRFToken": session.CSRFToken,
|
||||||
|
"IsSuperAdmin": true,
|
||||||
|
"Server": server,
|
||||||
|
"Containers": containers,
|
||||||
|
"NginxConfigs": nginxConfigs,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flash message
|
||||||
|
if flash := r.URL.Query().Get("flash"); flash != "" {
|
||||||
|
data["Flash"] = flash
|
||||||
|
data["FlashMessage"] = r.URL.Query().Get("msg")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.render(w, "server_detail.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleServerDelete supprime un serveur.
|
||||||
|
func (s *AdminServer) HandleServerDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid server ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server, _ := s.infraDB.GetServer(serverID)
|
||||||
|
if server == nil {
|
||||||
|
http.Error(w, "Server not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déconnecter SSH si connecté
|
||||||
|
s.sshPool.Disconnect(serverID)
|
||||||
|
|
||||||
|
if err := s.infraDB.DeleteServer(serverID); err != nil {
|
||||||
|
log.Printf("[admin] delete server error: %v", err)
|
||||||
|
http.Error(w, "Erreur suppression", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audit.LogAction(user.Username, "delete_server", server.Name, nil)
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/admin/infra?flash=success&msg=Serveur+supprimé", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleServerTestSSH teste la connexion SSH à un serveur.
|
||||||
|
func (s *AdminServer) HandleServerTestSSH(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid server ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := s.infraDB.GetServer(serverID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
http.Error(w, "Server not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tester la connexion SSH
|
||||||
|
client, err := s.sshPool.Connect(server)
|
||||||
|
if err != nil {
|
||||||
|
s.infraDB.UpdateServerStatus(serverID, infra.ServerStatusOffline)
|
||||||
|
msg := fmt.Sprintf("Erreur SSH: %v", err)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécuter une commande de test
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := client.ExecSimple(ctx, "hostname && uptime")
|
||||||
|
if err != nil {
|
||||||
|
s.infraDB.UpdateServerStatus(serverID, infra.ServerStatusOffline)
|
||||||
|
msg := fmt.Sprintf("Erreur commande: %v", err)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mise à jour du statut
|
||||||
|
s.infraDB.UpdateServerStatus(serverID, infra.ServerStatusOnline)
|
||||||
|
|
||||||
|
s.audit.LogAction(user.Username, "test_ssh", server.Name, map[string]any{
|
||||||
|
"result": result,
|
||||||
|
})
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Connexion OK: %s", result)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=success&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleServerSyncContainers synchronise les containers depuis Incus.
|
||||||
|
func (s *AdminServer) HandleServerSyncContainers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid server ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := s.infraDB.GetServer(serverID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
http.Error(w, "Server not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !server.HasIncus {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Incus+non+activé", serverID), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connexion SSH
|
||||||
|
client, err := s.sshPool.Connect(server)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Erreur+SSH", serverID), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Récupérer les containers Incus
|
||||||
|
incusContainers, err := client.ListIncusContainers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("Erreur Incus: %v", err)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchroniser avec la base
|
||||||
|
synced := 0
|
||||||
|
for _, ic := range incusContainers {
|
||||||
|
// Vérifier si existe déjà
|
||||||
|
existing, _ := s.infraDB.ListContainersByServer(serverID)
|
||||||
|
found := false
|
||||||
|
for _, c := range existing {
|
||||||
|
if c.IncusName == ic.Name {
|
||||||
|
// Mettre à jour le statut
|
||||||
|
status := infra.ContainerStatusUnknown
|
||||||
|
if ic.State == "running" {
|
||||||
|
status = infra.ContainerStatusRunning
|
||||||
|
} else if ic.State == "stopped" {
|
||||||
|
status = infra.ContainerStatusStopped
|
||||||
|
}
|
||||||
|
s.infraDB.UpdateContainerStatus(c.ID, status)
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
// Créer le container
|
||||||
|
ip := ""
|
||||||
|
if len(ic.IPv4) > 0 {
|
||||||
|
ip = ic.IPv4[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
status := infra.ContainerStatusUnknown
|
||||||
|
if ic.State == "running" {
|
||||||
|
status = infra.ContainerStatusRunning
|
||||||
|
} else if ic.State == "stopped" {
|
||||||
|
status = infra.ContainerStatusStopped
|
||||||
|
}
|
||||||
|
|
||||||
|
container := &infra.Container{
|
||||||
|
ServerID: serverID,
|
||||||
|
Name: ic.Name,
|
||||||
|
IncusName: ic.Name,
|
||||||
|
IP: ip,
|
||||||
|
Image: ic.Image,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
if err := s.infraDB.CreateContainer(container); err == nil {
|
||||||
|
synced++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audit.LogAction(user.Username, "sync_containers", server.Name, map[string]any{
|
||||||
|
"synced": synced,
|
||||||
|
"total": len(incusContainers),
|
||||||
|
})
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Sync OK: %d nouveaux containers", synced)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=success&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Containers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// HandleContainerAction effectue une action sur un container (start/stop/restart).
|
||||||
|
func (s *AdminServer) HandleContainerAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
containerID, err := strconv.ParseInt(r.PathValue("containerID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid container ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
action := r.FormValue("action")
|
||||||
|
if action != "start" && action != "stop" && action != "restart" {
|
||||||
|
http.Error(w, "Invalid action", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
container, err := s.infraDB.GetContainer(containerID)
|
||||||
|
if err != nil || container == nil {
|
||||||
|
http.Error(w, "Container not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := s.infraDB.GetServer(container.ServerID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
http.Error(w, "Server not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connexion SSH
|
||||||
|
client, err := s.sshPool.Connect(server)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Erreur+SSH", server.ID), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Exécuter l'action
|
||||||
|
switch action {
|
||||||
|
case "start":
|
||||||
|
err = client.StartIncusContainer(ctx, container.IncusName)
|
||||||
|
if err == nil {
|
||||||
|
s.infraDB.UpdateContainerStatus(containerID, infra.ContainerStatusRunning)
|
||||||
|
}
|
||||||
|
case "stop":
|
||||||
|
err = client.StopIncusContainer(ctx, container.IncusName)
|
||||||
|
if err == nil {
|
||||||
|
s.infraDB.UpdateContainerStatus(containerID, infra.ContainerStatusStopped)
|
||||||
|
}
|
||||||
|
case "restart":
|
||||||
|
err = client.RestartIncusContainer(ctx, container.IncusName)
|
||||||
|
if err == nil {
|
||||||
|
s.infraDB.UpdateContainerStatus(containerID, infra.ContainerStatusRunning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("Erreur %s: %v", action, err)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", server.ID, msg), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audit.LogAction(user.Username, "container_"+action, container.Name, map[string]any{
|
||||||
|
"server": server.Name,
|
||||||
|
})
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Container %s: %s OK", container.Name, action)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=success&msg=%s", server.ID, msg), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Nginx
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// HandleNginxReload recharge Nginx sur un serveur.
|
||||||
|
func (s *AdminServer) HandleNginxReload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid server ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := s.infraDB.GetServer(serverID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
http.Error(w, "Server not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !server.HasNginx {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Nginx+non+activé", serverID), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := s.sshPool.Connect(server)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Erreur+SSH", serverID), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := client.ReloadNginx(ctx); err != nil {
|
||||||
|
msg := fmt.Sprintf("Erreur reload: %v", err)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audit.LogAction(user.Username, "nginx_reload", server.Name, nil)
|
||||||
|
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=success&msg=Nginx+rechargé", serverID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleNginxSyncSites synchronise les sites Nginx depuis le serveur.
|
||||||
|
func (s *AdminServer) HandleNginxSyncSites(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := GetSessionFromContext(r.Context())
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
|
||||||
|
if session == nil || user == nil || !user.IsSuperAdmin() {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverID, err := strconv.ParseInt(r.PathValue("serverID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid server ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := s.infraDB.GetServer(serverID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
http.Error(w, "Server not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !server.HasNginx {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Nginx+non+activé", serverID), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := s.sshPool.Connect(server)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=Erreur+SSH", serverID), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sites, err := client.ListNginxSites(ctx)
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("Erreur liste sites: %v", err)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=error&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
synced := 0
|
||||||
|
for _, site := range sites {
|
||||||
|
// Vérifier si existe déjà
|
||||||
|
existing, _ := s.infraDB.GetNginxConfigByDomain(serverID, site.Name)
|
||||||
|
if existing != nil {
|
||||||
|
// Mettre à jour le statut
|
||||||
|
status := infra.NginxConfigStatusInactive
|
||||||
|
if site.Enabled {
|
||||||
|
status = infra.NginxConfigStatusActive
|
||||||
|
}
|
||||||
|
existing.Status = status
|
||||||
|
s.infraDB.UpdateNginxConfig(existing)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer la config
|
||||||
|
status := infra.NginxConfigStatusInactive
|
||||||
|
if site.Enabled {
|
||||||
|
status = infra.NginxConfigStatusActive
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &infra.NginxConfig{
|
||||||
|
ServerID: serverID,
|
||||||
|
Domain: site.Name,
|
||||||
|
Type: infra.NginxTypeProxy,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
if err := s.infraDB.CreateNginxConfig(config); err == nil {
|
||||||
|
synced++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audit.LogAction(user.Username, "sync_nginx", server.Name, map[string]any{
|
||||||
|
"synced": synced,
|
||||||
|
"total": len(sites),
|
||||||
|
})
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Sync OK: %d nouveaux sites", synced)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/infra/servers/%d?flash=success&msg=%s", serverID, msg), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API (htmx)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// HandleAPIInfraStatus retourne le statut de l'infrastructure (partial htmx).
|
||||||
|
func (s *AdminServer) HandleAPIInfraStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := GetUserFromContext(r.Context())
|
||||||
|
if user == nil || !user.IsSuperAdmin() {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
servers, _ := s.infraDB.ListServers()
|
||||||
|
|
||||||
|
// Compter les statuts
|
||||||
|
online := 0
|
||||||
|
offline := 0
|
||||||
|
for _, srv := range servers {
|
||||||
|
if srv.Status == infra.ServerStatusOnline {
|
||||||
|
online++
|
||||||
|
} else if srv.Status == infra.ServerStatusOffline {
|
||||||
|
offline++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
containers, _ := s.infraDB.ListAllContainers()
|
||||||
|
running := 0
|
||||||
|
stopped := 0
|
||||||
|
for _, c := range containers {
|
||||||
|
if c.Status == infra.ContainerStatusRunning {
|
||||||
|
running++
|
||||||
|
} else if c.Status == infra.ContainerStatusStopped {
|
||||||
|
stopped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"ServerCount": len(servers),
|
||||||
|
"ServersOnline": online,
|
||||||
|
"ServersOffline": offline,
|
||||||
|
"ContainerCount": len(containers),
|
||||||
|
"Running": running,
|
||||||
|
"Stopped": stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.render(w, "partials/infra_status.html", data)
|
||||||
|
}
|
||||||
227
cmd/sogoms/admin/main.go
Normal file
227
cmd/sogoms/admin/main.go
Normal 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
|
||||||
|
}
|
||||||
271
cmd/sogoms/admin/middleware.go
Normal file
271
cmd/sogoms/admin/middleware.go
Normal 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
|
||||||
|
}
|
||||||
961
cmd/sogoms/admin/services.go
Normal file
961
cmd/sogoms/admin/services.go
Normal 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
262
cmd/sogoms/admin/session.go
Normal 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
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
4
cmd/sogoms/admin/static/pico.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
154
cmd/sogoms/admin/templates/2fa_setup.html
Normal file
154
cmd/sogoms/admin/templates/2fa_setup.html
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
{{define "2fa_setup.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Activer 2FA - SOGOMS Admin</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.logo span {
|
||||||
|
color: var(--pico-primary);
|
||||||
|
}
|
||||||
|
.error-message {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.success-message {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
color: #16a34a;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.qr-container {
|
||||||
|
text-align: center;
|
||||||
|
margin: 2rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
}
|
||||||
|
.qr-container img {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
.secret-code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
background: var(--pico-form-element-background-color);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.backup-codes {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: var(--pico-form-element-background-color);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.warning {
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
margin: 1rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.step h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.code-input {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
letter-spacing: 0.5rem;
|
||||||
|
font-family: monospace;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="logo">SOGO<span>MS</span> Admin</div>
|
||||||
|
|
||||||
|
<h1>Activer l'authentification à deux facteurs</h1>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error-message">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<h3>Étape 1 : Scanner le QR Code</h3>
|
||||||
|
<p>Scannez ce code avec votre application d'authentification (Google Authenticator, Authy, Microsoft Authenticator...).</p>
|
||||||
|
|
||||||
|
<div class="qr-container">
|
||||||
|
{{if .QRCodeDataURL}}
|
||||||
|
<img src="{{.QRCodeDataURL | safeURL}}" alt="QR Code pour 2FA">
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Ou entrez le secret manuellement</summary>
|
||||||
|
<p style="margin-top: 1rem;">
|
||||||
|
<code class="secret-code">{{.TwoFASecret}}</code>
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<h3>Étape 2 : Sauvegardez vos codes de secours</h3>
|
||||||
|
<div class="warning">
|
||||||
|
Conservez ces codes dans un endroit sûr. Chaque code ne peut être utilisé qu'une seule fois en cas de perte de votre téléphone.
|
||||||
|
</div>
|
||||||
|
<div class="backup-codes">{{.BackupCodesFormatted}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<h3>Étape 3 : Vérifier le code</h3>
|
||||||
|
<p>Entrez le code à 6 chiffres affiché dans votre application pour confirmer l'activation.</p>
|
||||||
|
|
||||||
|
<form action="/admin/2fa/setup" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<input type="hidden" name="temp_secret" value="{{.TwoFASecret}}">
|
||||||
|
<input type="hidden" name="backup_codes" value="{{range $i, $code := .BackupCodes}}{{if $i}},{{end}}{{$code}}{{end}}">
|
||||||
|
|
||||||
|
<label for="verify_code">
|
||||||
|
Code d'authentification
|
||||||
|
<input type="text" id="verify_code" name="verify_code" class="code-input"
|
||||||
|
inputmode="numeric" maxlength="6" pattern="[0-9]{6}"
|
||||||
|
placeholder="000000" required autofocus
|
||||||
|
autocomplete="one-time-code">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Vérifier et activer le 2FA</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer style="text-align: center; margin-top: 2rem;">
|
||||||
|
<a href="/admin/" style="font-size: 0.9rem;">Annuler</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
107
cmd/sogoms/admin/templates/2fa_verify.html
Normal file
107
cmd/sogoms/admin/templates/2fa_verify.html
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
{{define "2fa_verify.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Vérification 2FA - SOGOMS Admin</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.verify-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.logo span {
|
||||||
|
color: var(--pico-primary);
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.error-message {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.code-input {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
letter-spacing: 0.5rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
details {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="verify-card">
|
||||||
|
<div class="logo">SOGO<span>MS</span> Admin</div>
|
||||||
|
<p class="subtitle">Vérification en deux étapes</p>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error-message">
|
||||||
|
{{.Error}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p>Entrez le code à 6 chiffres de votre application d'authentification.</p>
|
||||||
|
|
||||||
|
<form action="/admin/2fa/verify" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
|
||||||
|
<label for="code">
|
||||||
|
Code d'authentification
|
||||||
|
<input type="text" id="code" name="code" class="code-input"
|
||||||
|
inputmode="numeric" maxlength="6" pattern="[0-9]{6}"
|
||||||
|
placeholder="000000" required autofocus
|
||||||
|
autocomplete="one-time-code">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Vérifier</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Utiliser un code de secours</summary>
|
||||||
|
<form action="/admin/2fa/verify" method="post" style="margin-top: 1rem;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<input type="hidden" name="use_backup" value="true">
|
||||||
|
|
||||||
|
<label for="backup_code">
|
||||||
|
Code de secours
|
||||||
|
<input type="text" id="backup_code" name="backup_code"
|
||||||
|
placeholder="XXXX-XXXX" style="font-family: monospace;">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" class="secondary">Utiliser le code</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<footer style="text-align: center; margin-top: 2rem;">
|
||||||
|
<a href="/admin/login" style="font-size: 0.9rem;">Annuler et se reconnecter</a>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
123
cmd/sogoms/admin/templates/app_detail.html
Normal file
123
cmd/sogoms/admin/templates/app_detail.html
Normal 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}}
|
||||||
58
cmd/sogoms/admin/templates/apps.html
Normal file
58
cmd/sogoms/admin/templates/apps.html
Normal 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}}
|
||||||
88
cmd/sogoms/admin/templates/apps_new.html
Normal file
88
cmd/sogoms/admin/templates/apps_new.html
Normal 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 monapp.sogoms.com"></textarea>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Database -->
|
||||||
|
<article>
|
||||||
|
<header><strong>Base de donné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é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énéré automatiquement</small>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="grid">
|
||||||
|
<a href="/admin/apps" role="button" class="secondary outline">Annuler</a>
|
||||||
|
<button type="submit">Créer l'application</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{template "partials/footer.html" .}}
|
||||||
|
{{end}}
|
||||||
81
cmd/sogoms/admin/templates/dashboard.html
Normal file
81
cmd/sogoms/admin/templates/dashboard.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{{define "dashboard.html"}}
|
||||||
|
{{template "partials/header.html" .}}
|
||||||
|
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
|
||||||
|
<p class="user-info">
|
||||||
|
Connecté 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}}
|
||||||
159
cmd/sogoms/admin/templates/infra.html
Normal file
159
cmd/sogoms/admin/templates/infra.html
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
{{define "infra.html"}}
|
||||||
|
{{template "partials/header.html" .}}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.server-card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.server-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.server-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.server-info dt {
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.server-info dd {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge-online { background: #dcfce7; color: #166534; }
|
||||||
|
.badge-offline { background: #fef2f2; color: #dc2626; }
|
||||||
|
.badge-unknown { background: #f3f4f6; color: #6b7280; }
|
||||||
|
.badge-incus { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.badge-nginx { background: #fef3c7; color: #92400e; }
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: var(--pico-card-background-color);
|
||||||
|
border: 1px solid var(--pico-muted-border-color);
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--pico-primary);
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h1>Infrastructure</h1>
|
||||||
|
|
||||||
|
<p class="user-info">
|
||||||
|
Gestion des serveurs, containers Incus et configurations Nginx.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{if eq .Flash "success"}}
|
||||||
|
<div style="background:#f0fdf4;border:1px solid #bbf7d0;color:#16a34a;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||||
|
{{.FlashMessage}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if eq .Flash "error"}}
|
||||||
|
<div style="background:#fef2f2;border:1px solid #fecaca;color:#dc2626;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||||
|
{{.FlashMessage}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Statistiques -->
|
||||||
|
<div class="stats-grid" hx-get="/admin/api/infra/status" hx-trigger="every 30s" hx-swap="innerHTML">
|
||||||
|
{{$online := 0}}
|
||||||
|
{{$containers := 0}}
|
||||||
|
{{range .Servers}}
|
||||||
|
{{if eq .Status "online"}}{{$online = 1}}{{end}}
|
||||||
|
{{$containers = .ContainerCount}}
|
||||||
|
{{end}}
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{len .Servers}}</div>
|
||||||
|
<div class="stat-label">Serveurs</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{range .Servers}}{{.ContainerCount}}{{end}}</div>
|
||||||
|
<div class="stat-label">Containers</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{range .Servers}}{{.NginxCount}}{{end}}</div>
|
||||||
|
<div class="stat-label">Sites Nginx</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<a href="/admin/infra/servers/new" role="button">+ Nouveau Serveur</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste des serveurs -->
|
||||||
|
{{if .Servers}}
|
||||||
|
{{range .Servers}}
|
||||||
|
<article class="server-card">
|
||||||
|
<header class="server-header">
|
||||||
|
<div>
|
||||||
|
<strong>{{.Name}}</strong>
|
||||||
|
{{if eq .Status "online"}}<span class="badge badge-online">Online</span>{{end}}
|
||||||
|
{{if eq .Status "offline"}}<span class="badge badge-offline">Offline</span>{{end}}
|
||||||
|
{{if eq .Status "unknown"}}<span class="badge badge-unknown">?</span>{{end}}
|
||||||
|
{{if .HasIncus}}<span class="badge badge-incus">Incus</span>{{end}}
|
||||||
|
{{if .HasNginx}}<span class="badge badge-nginx">Nginx</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<a href="/admin/infra/servers/{{.ID}}" role="button" class="outline">Détails</a>
|
||||||
|
</header>
|
||||||
|
<dl class="server-info">
|
||||||
|
<div>
|
||||||
|
<dt>Host</dt>
|
||||||
|
<dd>{{.Host}}</dd>
|
||||||
|
</div>
|
||||||
|
{{if .VpnIP}}
|
||||||
|
<div>
|
||||||
|
<dt>VPN IP</dt>
|
||||||
|
<dd>{{.VpnIP}}</dd>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div>
|
||||||
|
<dt>SSH</dt>
|
||||||
|
<dd>{{.SSHUser}}@:{{.SSHPort}}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Containers</dt>
|
||||||
|
<dd>{{.ContainerCount}}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Sites Nginx</dt>
|
||||||
|
<dd>{{.NginxCount}}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<article>
|
||||||
|
<p style="text-align:center;color:var(--pico-muted-color);">
|
||||||
|
Aucun serveur configuré. <a href="/admin/infra/servers/new">Ajouter un serveur</a>
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "partials/footer.html" .}}
|
||||||
|
{{end}}
|
||||||
72
cmd/sogoms/admin/templates/login.html
Normal file
72
cmd/sogoms/admin/templates/login.html
Normal 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}}
|
||||||
16
cmd/sogoms/admin/templates/partials/apps_list.html
Normal file
16
cmd/sogoms/admin/templates/partials/apps_list.html
Normal 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 →</a></p>
|
||||||
|
{{else}}
|
||||||
|
<p><em>Aucune application accessible</em></p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
36
cmd/sogoms/admin/templates/partials/cron_jobs.html
Normal file
36
cmd/sogoms/admin/templates/partials/cron_jobs.html
Normal 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}}
|
||||||
61
cmd/sogoms/admin/templates/partials/flash.html
Normal file
61
cmd/sogoms/admin/templates/partials/flash.html
Normal 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}}
|
||||||
9
cmd/sogoms/admin/templates/partials/footer.html
Normal file
9
cmd/sogoms/admin/templates/partials/footer.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{{define "partials/footer.html"}}
|
||||||
|
</main>
|
||||||
|
<footer class="container">
|
||||||
|
<hr>
|
||||||
|
<small>SOGOMS Admin © 2025</small>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
53
cmd/sogoms/admin/templates/partials/header.html
Normal file
53
cmd/sogoms/admin/templates/partials/header.html
Normal 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}}
|
||||||
10
cmd/sogoms/admin/templates/partials/infra_status.html
Normal file
10
cmd/sogoms/admin/templates/partials/infra_status.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{{define "partials/infra_status.html"}}
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{.ServerCount}}</div>
|
||||||
|
<div class="stat-label">Serveurs ({{.ServersOnline}} online)</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{.ContainerCount}}</div>
|
||||||
|
<div class="stat-label">Containers ({{.Running}} running)</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
17
cmd/sogoms/admin/templates/partials/services_status.html
Normal file
17
cmd/sogoms/admin/templates/partials/services_status.html
Normal 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}}
|
||||||
118
cmd/sogoms/admin/templates/security.html
Normal file
118
cmd/sogoms/admin/templates/security.html
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
{{define "security.html"}}
|
||||||
|
{{template "partials/header.html" .}}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.security-card {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
.status-enabled {
|
||||||
|
color: #16a34a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.status-disabled {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.backup-count {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
}
|
||||||
|
.warning-box {
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h1>Sécurité</h1>
|
||||||
|
|
||||||
|
<p class="user-info">
|
||||||
|
Paramètres de sécurité pour <strong>{{.User.Username}}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error-message" style="background:#fef2f2;border:1px solid #fecaca;color:#dc2626;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||||
|
{{.Error}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Info}}
|
||||||
|
<div style="background:#eff6ff;border:1px solid #bfdbfe;color:#1d4ed8;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||||
|
{{.Info}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<article class="security-card">
|
||||||
|
<header>
|
||||||
|
<strong>Authentification à deux facteurs (2FA)</strong>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Statut :
|
||||||
|
{{if .TwoFAEnabled}}
|
||||||
|
<span class="status-enabled">Activé</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="status-disabled">Désactivé</span>
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{if .TwoFAEnabled}}
|
||||||
|
<p class="backup-count">
|
||||||
|
Codes de secours restants : <strong>{{.BackupCount}}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{if .TwoFARequired}}
|
||||||
|
<div class="warning-box">
|
||||||
|
Le 2FA est obligatoire pour votre rôle. Vous ne pouvez pas le désactiver.
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<form action="/admin/2fa/disable" method="post" onsubmit="return confirm('Êtes-vous sûr de vouloir désactiver le 2FA ?');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
|
||||||
|
<label for="password">
|
||||||
|
Mot de passe (confirmation)
|
||||||
|
<input type="password" id="password" name="password" required placeholder="Votre mot de passe">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" class="secondary">Désactiver le 2FA</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{else}}
|
||||||
|
|
||||||
|
{{if .TwoFARequired}}
|
||||||
|
<div class="warning-box">
|
||||||
|
Le 2FA est obligatoire pour votre rôle. Veuillez l'activer.
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Protégez votre compte avec une couche de sécurité supplémentaire.
|
||||||
|
Vous aurez besoin d'une application d'authentification (Google Authenticator, Authy, etc.).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a href="/admin/2fa/setup" role="button">Activer le 2FA</a>
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="security-card" style="margin-top: 1rem;">
|
||||||
|
<header>
|
||||||
|
<strong>Informations de connexion</strong>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Nom d'utilisateur</dt>
|
||||||
|
<dd>{{.User.Username}}</dd>
|
||||||
|
<dt>Email</dt>
|
||||||
|
<dd>{{.User.Email}}</dd>
|
||||||
|
<dt>Rôle</dt>
|
||||||
|
<dd>{{.User.Role}}</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{{template "partials/footer.html" .}}
|
||||||
|
{{end}}
|
||||||
245
cmd/sogoms/admin/templates/server_detail.html
Normal file
245
cmd/sogoms/admin/templates/server_detail.html
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
{{define "server_detail.html"}}
|
||||||
|
{{template "partials/header.html" .}}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge-online { background: #dcfce7; color: #166534; }
|
||||||
|
.badge-offline { background: #fef2f2; color: #dc2626; }
|
||||||
|
.badge-unknown { background: #f3f4f6; color: #6b7280; }
|
||||||
|
.badge-running { background: #dcfce7; color: #166534; }
|
||||||
|
.badge-stopped { background: #fef2f2; color: #dc2626; }
|
||||||
|
.badge-active { background: #dcfce7; color: #166534; }
|
||||||
|
.badge-inactive { background: #f3f4f6; color: #6b7280; }
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.info-item dt {
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.info-item dd {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.action-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.action-bar button, .action-bar a {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0.1rem;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc2626;
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/admin/infra">Infrastructure</a></li>
|
||||||
|
<li>{{.Server.Name}}</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
{{.Server.Name}}
|
||||||
|
{{if eq .Server.Status "online"}}<span class="badge badge-online">Online</span>{{end}}
|
||||||
|
{{if eq .Server.Status "offline"}}<span class="badge badge-offline">Offline</span>{{end}}
|
||||||
|
{{if eq .Server.Status "unknown"}}<span class="badge badge-unknown">?</span>{{end}}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{{if eq .Flash "success"}}
|
||||||
|
<div style="background:#f0fdf4;border:1px solid #bbf7d0;color:#16a34a;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||||
|
{{.FlashMessage}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if eq .Flash "error"}}
|
||||||
|
<div style="background:#fef2f2;border:1px solid #fecaca;color:#dc2626;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||||
|
{{.FlashMessage}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Informations -->
|
||||||
|
<article>
|
||||||
|
<header>Informations</header>
|
||||||
|
<dl class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<dt>Host</dt>
|
||||||
|
<dd>{{.Server.Host}}</dd>
|
||||||
|
</div>
|
||||||
|
{{if .Server.VpnIP}}
|
||||||
|
<div class="info-item">
|
||||||
|
<dt>VPN IP</dt>
|
||||||
|
<dd>{{.Server.VpnIP}}</dd>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="info-item">
|
||||||
|
<dt>SSH</dt>
|
||||||
|
<dd>{{.Server.SSHUser}}@{{.Server.Host}}:{{.Server.SSHPort}}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<dt>Clé SSH</dt>
|
||||||
|
<dd style="font-size:0.8rem;">{{.Server.SSHKeyFile}}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="action-bar">
|
||||||
|
<form action="/admin/infra/servers/{{.Server.ID}}/test-ssh" method="post" style="display:inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<button type="submit" class="outline">Tester SSH</button>
|
||||||
|
</form>
|
||||||
|
<form action="/admin/infra/servers/{{.Server.ID}}/delete" method="post" style="display:inline;"
|
||||||
|
onsubmit="return confirm('Supprimer ce serveur ?');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<button type="submit" class="btn-danger outline">Supprimer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Containers Incus -->
|
||||||
|
{{if .Server.HasIncus}}
|
||||||
|
<article>
|
||||||
|
<header style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<span>Containers Incus ({{len .Containers}})</span>
|
||||||
|
<form action="/admin/infra/servers/{{.Server.ID}}/sync-containers" method="post" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<button type="submit" class="outline btn-sm">Synchroniser</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if .Containers}}
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Image</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Containers}}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{.Name}}</strong></td>
|
||||||
|
<td>{{if .IP}}{{.IP}}{{else}}-{{end}}</td>
|
||||||
|
<td style="font-size:0.8rem;">{{if .Image}}{{.Image}}{{else}}-{{end}}</td>
|
||||||
|
<td>
|
||||||
|
{{if eq .Status "running"}}<span class="badge badge-running">Running</span>{{end}}
|
||||||
|
{{if eq .Status "stopped"}}<span class="badge badge-stopped">Stopped</span>{{end}}
|
||||||
|
{{if eq .Status "unknown"}}<span class="badge badge-unknown">?</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{if eq .Status "stopped"}}
|
||||||
|
<form action="/admin/infra/containers/{{.ID}}/action" method="post" style="display:inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
|
<input type="hidden" name="action" value="start">
|
||||||
|
<button type="submit" class="btn-sm outline">Start</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{if eq .Status "running"}}
|
||||||
|
<form action="/admin/infra/containers/{{.ID}}/action" method="post" style="display:inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
|
<input type="hidden" name="action" value="stop">
|
||||||
|
<button type="submit" class="btn-sm outline secondary">Stop</button>
|
||||||
|
</form>
|
||||||
|
<form action="/admin/infra/containers/{{.ID}}/action" method="post" style="display:inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
|
<input type="hidden" name="action" value="restart">
|
||||||
|
<button type="submit" class="btn-sm outline">Restart</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p style="color:var(--pico-muted-color);text-align:center;">
|
||||||
|
Aucun container. Cliquez sur "Synchroniser" pour importer depuis Incus.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Configurations Nginx -->
|
||||||
|
{{if .Server.HasNginx}}
|
||||||
|
<article>
|
||||||
|
<header style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<span>Sites Nginx ({{len .NginxConfigs}})</span>
|
||||||
|
<div>
|
||||||
|
<form action="/admin/infra/servers/{{.Server.ID}}/sync-nginx" method="post" style="display:inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<button type="submit" class="outline btn-sm">Synchroniser</button>
|
||||||
|
</form>
|
||||||
|
<form action="/admin/infra/servers/{{.Server.ID}}/nginx-reload" method="post" style="display:inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<button type="submit" class="outline btn-sm">Reload Nginx</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if .NginxConfigs}}
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Domaine</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>SSL</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .NginxConfigs}}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{.Domain}}</strong></td>
|
||||||
|
<td>{{.Type}}</td>
|
||||||
|
<td>{{if .SSLEnabled}}Oui{{else}}Non{{end}}</td>
|
||||||
|
<td>
|
||||||
|
{{if eq .Status "active"}}<span class="badge badge-active">Actif</span>{{end}}
|
||||||
|
{{if eq .Status "inactive"}}<span class="badge badge-inactive">Inactif</span>{{end}}
|
||||||
|
{{if eq .Status "error"}}<span class="badge badge-offline">Erreur</span>{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p style="color:var(--pico-muted-color);text-align:center;">
|
||||||
|
Aucun site. Cliquez sur "Synchroniser" pour importer depuis Nginx.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "partials/footer.html" .}}
|
||||||
|
{{end}}
|
||||||
71
cmd/sogoms/admin/templates/server_new.html
Normal file
71
cmd/sogoms/admin/templates/server_new.html
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{{define "server_new.html"}}
|
||||||
|
{{template "partials/header.html" .}}
|
||||||
|
|
||||||
|
<h1>Nouveau Serveur</h1>
|
||||||
|
|
||||||
|
<p class="user-info">
|
||||||
|
Ajoutez un serveur pour le gérer depuis l'interface admin.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<form action="/admin/infra/servers/new" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
Nom *
|
||||||
|
<input type="text" name="name" placeholder="in3" required>
|
||||||
|
<small>Identifiant unique du serveur</small>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Host *
|
||||||
|
<input type="text" name="host" placeholder="192.168.1.100 ou hostname.local" required>
|
||||||
|
<small>IP ou hostname pour la connexion SSH</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
VPN IP
|
||||||
|
<input type="text" name="vpn_ip" placeholder="11.1.2.1">
|
||||||
|
<small>IP WireGuard (optionnel)</small>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Port SSH
|
||||||
|
<input type="number" name="ssh_port" value="22" min="1" max="65535">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
Utilisateur SSH
|
||||||
|
<input type="text" name="ssh_user" value="root" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Fichier clé SSH *
|
||||||
|
<input type="text" name="ssh_key_file" placeholder="/root/.ssh/id_ed25519" required>
|
||||||
|
<small>Chemin vers la clé privée sur le container admin</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Services disponibles</legend>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="has_incus">
|
||||||
|
Incus (gestion de containers)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="has_nginx">
|
||||||
|
Nginx (reverse proxy)
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:1rem;margin-top:1rem;">
|
||||||
|
<button type="submit">Créer le serveur</button>
|
||||||
|
<a href="/admin/infra" role="button" class="outline secondary">Annuler</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{{template "partials/footer.html" .}}
|
||||||
|
{{end}}
|
||||||
105
cmd/sogoms/admin/templates/users.html
Normal file
105
cmd/sogoms/admin/templates/users.html
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
{{define "users.html"}}
|
||||||
|
{{template "partials/header.html" .}}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.users-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.users-table th, .users-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge-success {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
.badge-warning {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
.badge-role {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc2626;
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #b91c1c;
|
||||||
|
border-color: #b91c1c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h1>Utilisateurs Admin</h1>
|
||||||
|
|
||||||
|
<p class="user-info">
|
||||||
|
Gestion des utilisateurs de l'interface d'administration.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{if eq .Flash "success"}}
|
||||||
|
<div style="background:#f0fdf4;border:1px solid #bbf7d0;color:#16a34a;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||||
|
{{.FlashMessage}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if eq .Flash "error"}}
|
||||||
|
<div style="background:#fef2f2;border:1px solid #fecaca;color:#dc2626;padding:0.75rem 1rem;border-radius:var(--pico-border-radius);margin-bottom:1rem;">
|
||||||
|
{{.FlashMessage}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<table class="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Utilisateur</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Rôle</th>
|
||||||
|
<th>2FA</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Users}}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{.Username}}</strong></td>
|
||||||
|
<td>{{.Email}}</td>
|
||||||
|
<td><span class="badge badge-role">{{.Role}}</span></td>
|
||||||
|
<td>
|
||||||
|
{{if .TwoFAEnabled}}
|
||||||
|
<span class="badge badge-success">Activé</span>
|
||||||
|
<small style="color:var(--pico-muted-color);">({{.BackupCount}} codes)</small>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge badge-warning">Désactivé</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{if .TwoFAEnabled}}
|
||||||
|
<form action="/admin/users/reset-2fa" method="post" style="display:inline;"
|
||||||
|
onsubmit="return confirm('Réinitialiser le 2FA pour {{.Username}} ?');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
|
<input type="hidden" name="username" value="{{.Username}}">
|
||||||
|
<button type="submit" class="btn-danger" style="padding:0.4rem 0.75rem;font-size:0.85rem;">
|
||||||
|
Reset 2FA
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<span style="color:var(--pico-muted-color);font-size:0.85rem;">-</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{{template "partials/footer.html" .}}
|
||||||
|
{{end}}
|
||||||
138
cmd/sogoms/admin/totp.go
Normal file
138
cmd/sogoms/admin/totp.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"image/png"
|
||||||
|
"math/big"
|
||||||
|
|
||||||
|
"github.com/pquerna/otp"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
"github.com/skip2/go-qrcode"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateTOTPSecret génère un nouveau secret TOTP pour un utilisateur.
|
||||||
|
func GenerateTOTPSecret(issuer, username string) (*otp.Key, error) {
|
||||||
|
key, err := totp.Generate(totp.GenerateOpts{
|
||||||
|
Issuer: issuer,
|
||||||
|
AccountName: username,
|
||||||
|
Period: 30,
|
||||||
|
SecretSize: 20,
|
||||||
|
Digits: otp.DigitsSix,
|
||||||
|
Algorithm: otp.AlgorithmSHA1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate TOTP key: %w", err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateTOTPCode valide un code TOTP à 6 chiffres.
|
||||||
|
func ValidateTOTPCode(secret, code string) bool {
|
||||||
|
return totp.Validate(code, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateQRCodeDataURL génère une image QR code en data URL base64.
|
||||||
|
func GenerateQRCodeDataURL(key *otp.Key) (string, error) {
|
||||||
|
// Générer l'image QR
|
||||||
|
qr, err := qrcode.New(key.URL(), qrcode.Medium)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create QR code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encoder en PNG
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = png.Encode(&buf, qr.Image(256))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("encode QR code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir en data URL
|
||||||
|
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||||
|
return dataURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateBackupCodes génère 10 codes de secours au format XXXX-XXXX.
|
||||||
|
func GenerateBackupCodes(count int) ([]string, error) {
|
||||||
|
if count <= 0 {
|
||||||
|
count = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
codes := make([]string, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
// Générer 2 groupes de 4 chiffres
|
||||||
|
part1, err := randomDigits(4)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
part2, err := randomDigits(4)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
codes[i] = fmt.Sprintf("%s-%s", part1, part2)
|
||||||
|
}
|
||||||
|
return codes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomDigits génère une chaîne de n chiffres aléatoires.
|
||||||
|
func randomDigits(n int) (string, error) {
|
||||||
|
max := new(big.Int)
|
||||||
|
max.Exp(big.NewInt(10), big.NewInt(int64(n)), nil)
|
||||||
|
|
||||||
|
num, err := rand.Int(rand.Reader, max)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("generate random digits: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
format := fmt.Sprintf("%%0%dd", n)
|
||||||
|
return fmt.Sprintf(format, num), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashBackupCodes hache tous les codes de secours avec bcrypt.
|
||||||
|
func HashBackupCodes(codes []string) ([]string, error) {
|
||||||
|
hashed := make([]string, len(codes))
|
||||||
|
for i, code := range codes {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hash backup code: %w", err)
|
||||||
|
}
|
||||||
|
hashed[i] = string(hash)
|
||||||
|
}
|
||||||
|
return hashed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyBackupCode vérifie un code de secours contre une liste de hashes.
|
||||||
|
// Retourne l'index du code trouvé ou -1 si non trouvé.
|
||||||
|
func VerifyBackupCode(code string, hashedCodes []string) int {
|
||||||
|
for i, hash := range hashedCodes {
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(code)) == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveBackupCode supprime un code de secours de la liste (après utilisation).
|
||||||
|
func RemoveBackupCode(codes []string, index int) []string {
|
||||||
|
if index < 0 || index >= len(codes) {
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
return append(codes[:index], codes[index+1:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatBackupCodes formate les codes pour affichage (2 colonnes).
|
||||||
|
func FormatBackupCodes(codes []string) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
for i, code := range codes {
|
||||||
|
buf.WriteString(code)
|
||||||
|
if i%2 == 0 && i < len(codes)-1 {
|
||||||
|
buf.WriteString(" ")
|
||||||
|
} else {
|
||||||
|
buf.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
743
cmd/sogoms/cron/main.go
Normal file
743
cmd/sogoms/cron/main.go
Normal 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})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -157,7 +157,22 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleRequest(ctx context.Context, req *protocol.Request, dbPool *DBPool) *protocol.Response {
|
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)
|
appID, ok := req.Params["app_id"].(string)
|
||||||
if !ok || appID == "" {
|
if !ok || appID == "" {
|
||||||
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
|
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)
|
return handleUpdate(req, db, appID)
|
||||||
case "delete":
|
case "delete":
|
||||||
return handleDelete(req, db, appID)
|
return handleDelete(req, db, appID)
|
||||||
case "health":
|
case "introspect":
|
||||||
return handleHealth(req, db)
|
return handleIntrospect(req, db, appID)
|
||||||
default:
|
default:
|
||||||
return protocol.Failure(req.ID, "UNKNOWN_ACTION", "unknown action: "+req.Action)
|
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.
|
// 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 {
|
func handleUpdate(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
|
||||||
table, ok := req.Params["table"].(string)
|
table, ok := req.Params["table"].(string)
|
||||||
if !ok {
|
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")
|
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
|
// Construire SET
|
||||||
setClauses := make([]string, 0, len(data))
|
setClauses := make([]string, 0, len(data))
|
||||||
values := make([]any, 0, len(data)+len(where))
|
values := make([]any, 0, len(data)+len(where))
|
||||||
|
|
||||||
for col, val := range data {
|
for col, val := range data {
|
||||||
setClauses = append(setClauses, col+" = ?")
|
if rawCols[col] {
|
||||||
values = append(values, val)
|
// Expression SQL brute (ex: NOW(), NULL, etc.)
|
||||||
|
setClauses = append(setClauses, col+" = "+val.(string))
|
||||||
|
} else {
|
||||||
|
setClauses = append(setClauses, col+" = ?")
|
||||||
|
values = append(values, val)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construire WHERE
|
// 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.
|
// extractQueryParams extrait query et args des paramètres.
|
||||||
func extractQueryParams(params map[string]any) (string, []any, error) {
|
func extractQueryParams(params map[string]any) (string, []any, error) {
|
||||||
query, ok := params["query"].(string)
|
query, ok := params["query"].(string)
|
||||||
@@ -434,3 +457,221 @@ func scanRows(rows *sql.Rows) ([]map[string]any, error) {
|
|||||||
|
|
||||||
return results, rows.Err()
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
44
config/apps/prokov/cron.yaml
Normal file
44
config/apps/prokov/cron.yaml
Normal 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
|
||||||
@@ -54,7 +54,7 @@ CREATE TABLE `projects` (
|
|||||||
KEY `idx_projects_parent` (`user_id`,`parent_id`),
|
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_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
|
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 */;
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
--
|
--
|
||||||
@@ -95,7 +95,7 @@ CREATE TABLE `tags` (
|
|||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `unique_tag_per_user` (`user_id`,`name`),
|
UNIQUE KEY `unique_tag_per_user` (`user_id`,`name`),
|
||||||
CONSTRAINT `tags_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
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 */;
|
/*!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_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_2` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE,
|
||||||
CONSTRAINT `tasks_ibfk_3` FOREIGN KEY (`status_id`) REFERENCES `statuses` (`id`)
|
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 */;
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
--
|
--
|
||||||
@@ -161,6 +161,8 @@ CREATE TABLE `users` (
|
|||||||
`email` varchar(255) NOT NULL,
|
`email` varchar(255) NOT NULL,
|
||||||
`password` varchar(255) NOT NULL,
|
`password` varchar(255) NOT NULL,
|
||||||
`name` varchar(100) 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,
|
`role_id` int(10) unsigned NOT NULL DEFAULT 1,
|
||||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE 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 */;
|
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||||
/*M!100616 SET NOTE_VERBOSITY=@OLD_NOTE_VERBOSITY */;
|
/*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
|
||||||
255
config/apps/prokov/schema.yaml
Normal file
255
config/apps/prokov/schema.yaml
Normal 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"
|
||||||
@@ -43,6 +43,46 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- sogoms-logs
|
- 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:
|
sogoway:
|
||||||
binary: /opt/sogoms/bin/sogoway
|
binary: /opt/sogoms/bin/sogoway
|
||||||
args:
|
args:
|
||||||
|
|||||||
18
deploy.sh
18
deploy.sh
@@ -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?"
|
echo_error "Source directories missing - are you in the sogoms directory?"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -d "config/routes" ]; then
|
if [ ! -d "config/apps" ]; then
|
||||||
echo_error "config/routes missing"
|
echo_error "config/apps missing"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Commande SSH vers IN3
|
# 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-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-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-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/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"
|
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
|
# Étape 2: Créer les archives
|
||||||
echo_step "Creating archives..."
|
echo_step "Creating archives..."
|
||||||
@@ -92,7 +94,8 @@ BIN_ARCHIVE="sogoms-bin-${TIMESTAMP}.tar.gz"
|
|||||||
CONFIG_ARCHIVE="sogoms-config-${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/${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)
|
BIN_SIZE=$(du -h "/tmp/${BIN_ARCHIVE}" | cut -f1)
|
||||||
CONFIG_SIZE=$(du -h "/tmp/${CONFIG_ARCHIVE}" | cut -f1)
|
CONFIG_SIZE=$(du -h "/tmp/${CONFIG_ARCHIVE}" | cut -f1)
|
||||||
@@ -119,10 +122,10 @@ $SSH_CMD "
|
|||||||
echo '📁 Deploying binaries...'
|
echo '📁 Deploying binaries...'
|
||||||
incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_BIN}
|
incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_BIN}
|
||||||
incus exec ${INCUS_CONTAINER} -- tar -xzvf /tmp/${BIN_ARCHIVE} -C ${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...'
|
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}/
|
incus exec ${INCUS_CONTAINER} -- tar -xzf /tmp/${CONFIG_ARCHIVE} -C ${REMOTE_CONFIG}/
|
||||||
|
|
||||||
echo '📁 Setting up run and log directories...'
|
echo '📁 Setting up run and log directories...'
|
||||||
@@ -186,7 +189,8 @@ echo_info " Deployment time: $(date)"
|
|||||||
echo ""
|
echo ""
|
||||||
echo_warning "Next steps on gw3:"
|
echo_warning "Next steps on gw3:"
|
||||||
echo_info " 1. Edit /secrets/prokov_db_pass with real DB password"
|
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 ""
|
||||||
echo_info "To connect: ssh in3 -t 'incus exec $INCUS_CONTAINER -- sh'"
|
echo_info "To connect: ssh in3 -t 'incus exec $INCUS_CONTAINER -- sh'"
|
||||||
|
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -6,7 +6,12 @@ toolchain go1.24.11
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
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/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/crypto v0.46.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
14
go.sum
14
go.sum
@@ -1,9 +1,23 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
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 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
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 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
105
internal/admin/audit.go
Normal file
105
internal/admin/audit.go
Normal 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
163
internal/admin/config.go
Normal 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
|
||||||
|
}
|
||||||
131
internal/admin/permissions.go
Normal file
131
internal/admin/permissions.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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.
|
// Generate génère un nouveau token JWT.
|
||||||
func (j *JWT) Generate(userID int64, email, name, appID string) (string, error) {
|
func (j *JWT) Generate(userID int64, email, name, appID string) (string, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ type AppConfig struct {
|
|||||||
Database Database `yaml:"database"`
|
Database Database `yaml:"database"`
|
||||||
Auth Auth `yaml:"auth"`
|
Auth Auth `yaml:"auth"`
|
||||||
Routes []Route `yaml:"routes"`
|
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.
|
// Queries stocke les requêtes SQL par domaine.
|
||||||
@@ -48,6 +49,14 @@ func (q *Queries) Get(domain, key string) string {
|
|||||||
return ""
|
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).
|
// GetMap retourne une map de requêtes (ex: login_data).
|
||||||
func (q *Queries) GetMap(domain, key string) map[string]string {
|
func (q *Queries) GetMap(domain, key string) map[string]string {
|
||||||
if q == nil || q.files == nil {
|
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 {
|
func (r *Registry) Load() error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
routesDir := filepath.Join(r.configDir, "routes")
|
appsDir := filepath.Join(r.configDir, "apps")
|
||||||
entries, err := os.ReadDir(routesDir)
|
entries, err := os.ReadDir(appsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read routes dir: %w", err)
|
return fmt.Errorf("read apps dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
|
if !entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
path := filepath.Join(routesDir, entry.Name())
|
appID := entry.Name()
|
||||||
cfg, err := r.loadAppConfig(path)
|
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 {
|
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
|
r.apps[cfg.App] = cfg
|
||||||
@@ -333,7 +349,7 @@ func (r *Registry) Load() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadAppConfig charge une configuration d'application depuis un fichier YAML.
|
// 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)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -372,15 +388,18 @@ func (r *Registry) loadAppConfig(path string) (*AppConfig, error) {
|
|||||||
cfg.Auth.JWTExpiry = "24h"
|
cfg.Auth.JWTExpiry = "24h"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charger les requêtes depuis config/queries/{app}/
|
// Charger les requêtes depuis config/apps/{app}/queries/
|
||||||
cfg.Queries = r.loadQueries(cfg.App)
|
cfg.Queries = r.loadQueries(appID)
|
||||||
|
|
||||||
|
// Charger le schema depuis config/apps/{app}/schema.yaml
|
||||||
|
cfg.Schema = loadSchema(r.configDir, appID)
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadQueries charge les fichiers de requêtes pour une application.
|
// loadQueries charge les fichiers de requêtes pour une application.
|
||||||
func (r *Registry) loadQueries(appID string) *Queries {
|
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)
|
entries, err := os.ReadDir(queriesDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil // Pas de répertoire queries, c'est OK
|
return nil // Pas de répertoire queries, c'est OK
|
||||||
|
|||||||
320
internal/config/schema.go
Normal file
320
internal/config/schema.go
Normal 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
238
internal/cron/scheduler.go
Normal 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
52
internal/infra/db.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DB représente la connexion à la base SQLite.
|
||||||
|
type DB struct {
|
||||||
|
*sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open ouvre ou crée la base de données SQLite.
|
||||||
|
func Open(dbPath string) (*DB, error) {
|
||||||
|
// Créer le répertoire si nécessaire
|
||||||
|
dir := filepath.Dir(dbPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create db directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ouvrir la connexion
|
||||||
|
sqlDB, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tester la connexion
|
||||||
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("ping database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db := &DB{sqlDB}
|
||||||
|
|
||||||
|
// Exécuter les migrations
|
||||||
|
if err := db.Migrate(); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("migrate database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close ferme la connexion.
|
||||||
|
func (db *DB) Close() error {
|
||||||
|
return db.DB.Close()
|
||||||
|
}
|
||||||
227
internal/infra/incus.go
Normal file
227
internal/infra/incus.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IncusContainer représente un container Incus retourné par incus list.
|
||||||
|
type IncusContainer struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state"`
|
||||||
|
IPv4 []string `json:"ipv4"`
|
||||||
|
IPv6 []string `json:"ipv6"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Snapshots int `json:"snapshots"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Image string `json:"-"` // Rempli séparément
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// incusListJSON représente le format JSON de incus list.
|
||||||
|
type incusListJSON struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Snapshots int `json:"snapshots"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
IPv4 string `json:"ipv4"`
|
||||||
|
IPv6 string `json:"ipv6"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIncusContainers liste les containers Incus sur le serveur.
|
||||||
|
func (c *SSHClient) ListIncusContainers(ctx context.Context) ([]IncusContainer, error) {
|
||||||
|
// Récupérer la liste en format JSON
|
||||||
|
result, err := c.Exec(ctx, "incus list --format json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("incus list: %w", err)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return nil, fmt.Errorf("incus list failed: %s", result.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser le JSON
|
||||||
|
var rawContainers []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Config struct {
|
||||||
|
Image string `json:"image.description"`
|
||||||
|
} `json:"config"`
|
||||||
|
State struct {
|
||||||
|
Network map[string]struct {
|
||||||
|
Addresses []struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Family string `json:"family"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
} `json:"addresses"`
|
||||||
|
} `json:"network"`
|
||||||
|
} `json:"state"`
|
||||||
|
Snapshots []interface{} `json:"snapshots"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(result.Stdout), &rawContainers); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse incus json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir en notre format
|
||||||
|
containers := make([]IncusContainer, 0, len(rawContainers))
|
||||||
|
for _, rc := range rawContainers {
|
||||||
|
container := IncusContainer{
|
||||||
|
Name: rc.Name,
|
||||||
|
State: strings.ToLower(rc.Status),
|
||||||
|
Type: rc.Type,
|
||||||
|
Snapshots: len(rc.Snapshots),
|
||||||
|
Location: rc.Location,
|
||||||
|
Image: rc.Config.Image,
|
||||||
|
CreatedAt: rc.CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraire les IPs
|
||||||
|
for _, net := range rc.State.Network {
|
||||||
|
for _, addr := range net.Addresses {
|
||||||
|
if addr.Scope == "global" {
|
||||||
|
if addr.Family == "inet" {
|
||||||
|
container.IPv4 = append(container.IPv4, addr.Address)
|
||||||
|
} else if addr.Family == "inet6" {
|
||||||
|
container.IPv6 = append(container.IPv6, addr.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
containers = append(containers, container)
|
||||||
|
}
|
||||||
|
|
||||||
|
return containers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIncusContainer récupère les infos d'un container spécifique.
|
||||||
|
func (c *SSHClient) GetIncusContainer(ctx context.Context, name string) (*IncusContainer, error) {
|
||||||
|
containers, err := c.ListIncusContainers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, container := range containers {
|
||||||
|
if container.Name == name {
|
||||||
|
return &container, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("container %s not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartIncusContainer démarre un container.
|
||||||
|
func (c *SSHClient) StartIncusContainer(ctx context.Context, name string) error {
|
||||||
|
result, err := c.Exec(ctx, fmt.Sprintf("incus start %s", name))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("incus start: %w", err)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return fmt.Errorf("incus start %s failed: %s", name, result.Stderr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopIncusContainer arrête un container.
|
||||||
|
func (c *SSHClient) StopIncusContainer(ctx context.Context, name string) error {
|
||||||
|
result, err := c.Exec(ctx, fmt.Sprintf("incus stop %s", name))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("incus stop: %w", err)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return fmt.Errorf("incus stop %s failed: %s", name, result.Stderr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestartIncusContainer redémarre un container.
|
||||||
|
func (c *SSHClient) RestartIncusContainer(ctx context.Context, name string) error {
|
||||||
|
result, err := c.Exec(ctx, fmt.Sprintf("incus restart %s", name))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("incus restart: %w", err)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return fmt.Errorf("incus restart %s failed: %s", name, result.Stderr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecInContainer exécute une commande dans un container.
|
||||||
|
func (c *SSHClient) ExecInContainer(ctx context.Context, containerName, cmd string) (*SSHResult, error) {
|
||||||
|
fullCmd := fmt.Sprintf("incus exec %s -- %s", containerName, cmd)
|
||||||
|
return c.Exec(ctx, fullCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecInContainerSimple exécute une commande et retourne stdout.
|
||||||
|
func (c *SSHClient) ExecInContainerSimple(ctx context.Context, containerName, cmd string) (string, error) {
|
||||||
|
result, err := c.ExecInContainer(ctx, containerName, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return "", fmt.Errorf("command failed in %s (exit %d): %s", containerName, result.ExitCode, result.Stderr)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(result.Stdout), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushFileToContainer envoie un fichier vers un container.
|
||||||
|
func (c *SSHClient) PushFileToContainer(ctx context.Context, containerName, localPath, remotePath string) error {
|
||||||
|
// D'abord copier vers le serveur hôte
|
||||||
|
tempPath := fmt.Sprintf("/tmp/incus-push-%d", ctx.Value("request_id"))
|
||||||
|
if err := c.CopyFile(ctx, localPath, tempPath); err != nil {
|
||||||
|
return fmt.Errorf("copy to host: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puis push vers le container
|
||||||
|
result, err := c.Exec(ctx, fmt.Sprintf("incus file push %s %s%s && rm %s",
|
||||||
|
tempPath, containerName, remotePath, tempPath))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("incus file push: %w", err)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return fmt.Errorf("incus file push failed: %s", result.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullFileFromContainer récupère un fichier depuis un container.
|
||||||
|
func (c *SSHClient) PullFileFromContainer(ctx context.Context, containerName, remotePath, localPath string) error {
|
||||||
|
tempPath := fmt.Sprintf("/tmp/incus-pull-%d", ctx.Value("request_id"))
|
||||||
|
|
||||||
|
// Pull depuis le container vers l'hôte
|
||||||
|
result, err := c.Exec(ctx, fmt.Sprintf("incus file pull %s%s %s",
|
||||||
|
containerName, remotePath, tempPath))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("incus file pull: %w", err)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return fmt.Errorf("incus file pull failed: %s", result.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puis copier vers local
|
||||||
|
if err := c.CopyFrom(ctx, tempPath, localPath); err != nil {
|
||||||
|
return fmt.Errorf("copy from host: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nettoyer
|
||||||
|
c.Exec(ctx, fmt.Sprintf("rm %s", tempPath))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContainerLogs récupère les logs d'un container.
|
||||||
|
func (c *SSHClient) GetContainerLogs(ctx context.Context, containerName string, lines int) (string, error) {
|
||||||
|
if lines <= 0 {
|
||||||
|
lines = 100
|
||||||
|
}
|
||||||
|
return c.ExecInContainerSimple(ctx, containerName, fmt.Sprintf("journalctl -n %d --no-pager", lines))
|
||||||
|
}
|
||||||
92
internal/infra/migrations.go
Normal file
92
internal/infra/migrations.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// migrations contient les migrations SQL à exécuter dans l'ordre.
|
||||||
|
var migrations = []string{
|
||||||
|
// Table servers
|
||||||
|
`CREATE TABLE IF NOT EXISTS servers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
vpn_ip TEXT,
|
||||||
|
ssh_port INTEGER NOT NULL DEFAULT 22,
|
||||||
|
ssh_user TEXT NOT NULL DEFAULT 'root',
|
||||||
|
ssh_key_file TEXT NOT NULL,
|
||||||
|
has_incus INTEGER NOT NULL DEFAULT 0,
|
||||||
|
has_nginx INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Table containers
|
||||||
|
`CREATE TABLE IF NOT EXISTS containers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
incus_name TEXT NOT NULL,
|
||||||
|
ip TEXT,
|
||||||
|
vpn_ip TEXT,
|
||||||
|
image TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(server_id, incus_name)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Table nginx_configs
|
||||||
|
`CREATE TABLE IF NOT EXISTS nginx_configs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_id INTEGER NOT NULL,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'proxy',
|
||||||
|
template TEXT,
|
||||||
|
upstream TEXT,
|
||||||
|
ssl_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
config_content TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'inactive',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(server_id, domain)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Table app_bindings
|
||||||
|
`CREATE TABLE IF NOT EXISTS app_bindings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
app_id TEXT NOT NULL,
|
||||||
|
container_id INTEGER,
|
||||||
|
nginx_config_id INTEGER,
|
||||||
|
server_id INTEGER,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (container_id) REFERENCES containers(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (nginx_config_id) REFERENCES nginx_configs(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Index pour accélérer les requêtes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_containers_server ON containers(server_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_nginx_configs_server ON nginx_configs(server_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_nginx_configs_domain ON nginx_configs(domain)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_app_bindings_app ON app_bindings(app_id)`,
|
||||||
|
|
||||||
|
// Table migrations pour tracking des versions
|
||||||
|
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate exécute toutes les migrations.
|
||||||
|
func (db *DB) Migrate() error {
|
||||||
|
for i, migration := range migrations {
|
||||||
|
if _, err := db.Exec(migration); err != nil {
|
||||||
|
return fmt.Errorf("migration %d failed: %w", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
129
internal/infra/models.go
Normal file
129
internal/infra/models.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ServerStatus représente l'état d'un serveur.
|
||||||
|
type ServerStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ServerStatusOnline ServerStatus = "online"
|
||||||
|
ServerStatusOffline ServerStatus = "offline"
|
||||||
|
ServerStatusUnknown ServerStatus = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContainerStatus représente l'état d'un container.
|
||||||
|
type ContainerStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ContainerStatusRunning ContainerStatus = "running"
|
||||||
|
ContainerStatusStopped ContainerStatus = "stopped"
|
||||||
|
ContainerStatusUnknown ContainerStatus = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NginxConfigStatus représente l'état d'une config Nginx.
|
||||||
|
type NginxConfigStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
NginxConfigStatusActive NginxConfigStatus = "active"
|
||||||
|
NginxConfigStatusInactive NginxConfigStatus = "inactive"
|
||||||
|
NginxConfigStatusError NginxConfigStatus = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NginxConfigType représente le type de config Nginx.
|
||||||
|
type NginxConfigType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
NginxTypeProxy NginxConfigType = "proxy"
|
||||||
|
NginxTypeStatic NginxConfigType = "static"
|
||||||
|
NginxTypeSocket NginxConfigType = "socket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BindingType représente le type de binding app.
|
||||||
|
type BindingType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BindingTypeContainer BindingType = "container"
|
||||||
|
BindingTypeNginx BindingType = "nginx"
|
||||||
|
BindingTypeServer BindingType = "server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server représente un serveur physique ou VM.
|
||||||
|
type Server struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
VpnIP string `json:"vpn_ip,omitempty"`
|
||||||
|
SSHPort int `json:"ssh_port"`
|
||||||
|
SSHUser string `json:"ssh_user"`
|
||||||
|
SSHKeyFile string `json:"ssh_key_file"`
|
||||||
|
HasIncus bool `json:"has_incus"`
|
||||||
|
HasNginx bool `json:"has_nginx"`
|
||||||
|
Status ServerStatus `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container représente un container Incus.
|
||||||
|
type Container struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ServerID int64 `json:"server_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IncusName string `json:"incus_name"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
VpnIP string `json:"vpn_ip,omitempty"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Status ContainerStatus `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// Relation (populé par query)
|
||||||
|
Server *Server `json:"server,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NginxConfig représente une configuration Nginx.
|
||||||
|
type NginxConfig struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ServerID int64 `json:"server_id"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Type NginxConfigType `json:"type"`
|
||||||
|
Template string `json:"template,omitempty"`
|
||||||
|
Upstream string `json:"upstream,omitempty"`
|
||||||
|
SSLEnabled bool `json:"ssl_enabled"`
|
||||||
|
ConfigContent string `json:"config_content,omitempty"`
|
||||||
|
Status NginxConfigStatus `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// Relation (populé par query)
|
||||||
|
Server *Server `json:"server,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppBinding représente le lien entre une app SOGOMS et l'infrastructure.
|
||||||
|
type AppBinding struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
AppID string `json:"app_id"`
|
||||||
|
ContainerID *int64 `json:"container_id,omitempty"`
|
||||||
|
NginxConfigID *int64 `json:"nginx_config_id,omitempty"`
|
||||||
|
ServerID *int64 `json:"server_id,omitempty"`
|
||||||
|
Type BindingType `json:"type"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
// Relations (populées par query)
|
||||||
|
Container *Container `json:"container,omitempty"`
|
||||||
|
NginxConfig *NginxConfig `json:"nginx_config,omitempty"`
|
||||||
|
Server *Server `json:"server,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerWithContainers représente un serveur avec ses containers.
|
||||||
|
type ServerWithContainers struct {
|
||||||
|
Server
|
||||||
|
Containers []Container `json:"containers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfraOverview représente une vue globale de l'infrastructure.
|
||||||
|
type InfraOverview struct {
|
||||||
|
Servers []ServerWithContainers `json:"servers"`
|
||||||
|
NginxConfigs []NginxConfig `json:"nginx_configs"`
|
||||||
|
AppBindings []AppBinding `json:"app_bindings"`
|
||||||
|
}
|
||||||
306
internal/infra/nginx.go
Normal file
306
internal/infra/nginx.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// NginxSitesAvailable est le répertoire des sites disponibles.
|
||||||
|
NginxSitesAvailable = "/etc/nginx/sites-available"
|
||||||
|
// NginxSitesEnabled est le répertoire des sites activés.
|
||||||
|
NginxSitesEnabled = "/etc/nginx/sites-enabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NginxSiteInfo représente les infos d'un site Nginx.
|
||||||
|
type NginxSiteInfo struct {
|
||||||
|
Name string
|
||||||
|
Enabled bool
|
||||||
|
Config string
|
||||||
|
HasSSL bool
|
||||||
|
Domains []string
|
||||||
|
Upstream string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNginxConfig teste la configuration Nginx.
|
||||||
|
func (c *SSHClient) TestNginxConfig(ctx context.Context) error {
|
||||||
|
result, err := c.Exec(ctx, "nginx -t")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("nginx test: %w", err)
|
||||||
|
}
|
||||||
|
// nginx -t écrit sur stderr même en cas de succès
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return fmt.Errorf("nginx config invalid: %s", result.Stderr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReloadNginx recharge la configuration Nginx.
|
||||||
|
func (c *SSHClient) ReloadNginx(ctx context.Context) error {
|
||||||
|
// D'abord tester la config
|
||||||
|
if err := c.TestNginxConfig(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.Exec(ctx, "systemctl reload nginx")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("nginx reload: %w", err)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return fmt.Errorf("nginx reload failed: %s", result.Stderr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestartNginx redémarre Nginx.
|
||||||
|
func (c *SSHClient) RestartNginx(ctx context.Context) error {
|
||||||
|
result, err := c.Exec(ctx, "systemctl restart nginx")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("nginx restart: %w", err)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return fmt.Errorf("nginx restart failed: %s", result.Stderr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NginxStatus retourne le statut de Nginx.
|
||||||
|
func (c *SSHClient) NginxStatus(ctx context.Context) (string, error) {
|
||||||
|
result, err := c.Exec(ctx, "systemctl is-active nginx")
|
||||||
|
if err != nil {
|
||||||
|
return "unknown", nil
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(result.Stdout), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListNginxSites liste les sites Nginx configurés.
|
||||||
|
func (c *SSHClient) ListNginxSites(ctx context.Context) ([]NginxSiteInfo, error) {
|
||||||
|
// Lister sites-available
|
||||||
|
result, err := c.Exec(ctx, fmt.Sprintf("ls -1 %s", NginxSitesAvailable))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list sites-available: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return nil, fmt.Errorf("list sites-available failed: %s", result.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lister sites-enabled
|
||||||
|
enabledResult, err := c.Exec(ctx, fmt.Sprintf("ls -1 %s", NginxSitesEnabled))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list sites-enabled: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledSet := make(map[string]bool)
|
||||||
|
for _, name := range strings.Split(enabledResult.Stdout, "\n") {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name != "" {
|
||||||
|
enabledSet[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sites []NginxSiteInfo
|
||||||
|
for _, name := range strings.Split(result.Stdout, "\n") {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" || name == "default" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
site := NginxSiteInfo{
|
||||||
|
Name: name,
|
||||||
|
Enabled: enabledSet[name],
|
||||||
|
}
|
||||||
|
sites = append(sites, site)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sites, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNginxSiteConfig récupère la config d'un site.
|
||||||
|
func (c *SSHClient) GetNginxSiteConfig(ctx context.Context, name string) (string, error) {
|
||||||
|
path := filepath.Join(NginxSitesAvailable, name)
|
||||||
|
content, err := c.ReadFile(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read site config: %w", err)
|
||||||
|
}
|
||||||
|
return string(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteNginxSiteConfig écrit la config d'un site.
|
||||||
|
func (c *SSHClient) WriteNginxSiteConfig(ctx context.Context, name string, config string) error {
|
||||||
|
path := filepath.Join(NginxSitesAvailable, name)
|
||||||
|
return c.WriteFile(ctx, path, []byte(config), "644")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableNginxSite active un site (crée le lien symbolique).
|
||||||
|
func (c *SSHClient) EnableNginxSite(ctx context.Context, name string) error {
|
||||||
|
src := filepath.Join(NginxSitesAvailable, name)
|
||||||
|
dst := filepath.Join(NginxSitesEnabled, name)
|
||||||
|
|
||||||
|
// Vérifier que le site existe
|
||||||
|
exists, err := c.FileExists(ctx, src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("site %s does not exist", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer le lien
|
||||||
|
result, err := c.Exec(ctx, fmt.Sprintf("ln -sf %s %s", src, dst))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("enable site: %w", err)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return fmt.Errorf("enable site failed: %s", result.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableNginxSite désactive un site (supprime le lien symbolique).
|
||||||
|
func (c *SSHClient) DisableNginxSite(ctx context.Context, name string) error {
|
||||||
|
path := filepath.Join(NginxSitesEnabled, name)
|
||||||
|
|
||||||
|
result, err := c.Exec(ctx, fmt.Sprintf("rm -f %s", path))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("disable site: %w", err)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return fmt.Errorf("disable site failed: %s", result.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNginxSite supprime un site (désactive puis supprime).
|
||||||
|
func (c *SSHClient) DeleteNginxSite(ctx context.Context, name string) error {
|
||||||
|
// D'abord désactiver
|
||||||
|
if err := c.DisableNginxSite(ctx, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puis supprimer
|
||||||
|
path := filepath.Join(NginxSitesAvailable, name)
|
||||||
|
result, err := c.Exec(ctx, fmt.Sprintf("rm -f %s", path))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete site: %w", err)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return fmt.Errorf("delete site failed: %s", result.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployNginxSite déploie un site complet (écrire, activer, recharger).
|
||||||
|
func (c *SSHClient) DeployNginxSite(ctx context.Context, name string, config string) error {
|
||||||
|
// Écrire la config
|
||||||
|
if err := c.WriteNginxSiteConfig(ctx, name, config); err != nil {
|
||||||
|
return fmt.Errorf("write config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activer le site
|
||||||
|
if err := c.EnableNginxSite(ctx, name); err != nil {
|
||||||
|
return fmt.Errorf("enable site: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recharger Nginx
|
||||||
|
if err := c.ReloadNginx(ctx); err != nil {
|
||||||
|
// En cas d'erreur, désactiver le site
|
||||||
|
c.DisableNginxSite(ctx, name)
|
||||||
|
return fmt.Errorf("reload nginx: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateNginxProxyConfig génère une config proxy standard.
|
||||||
|
func GenerateNginxProxyConfig(domain, upstream string, ssl bool) string {
|
||||||
|
var config strings.Builder
|
||||||
|
|
||||||
|
if ssl {
|
||||||
|
// Redirect HTTP to HTTPS
|
||||||
|
config.WriteString(fmt.Sprintf(`server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name %s;
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
`, domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main server block
|
||||||
|
if ssl {
|
||||||
|
config.WriteString(fmt.Sprintf(`server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name %s;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/%s/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/%s/privkey.pem;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_cache shared:SSL:50m;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass %s;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, domain, domain, domain, upstream))
|
||||||
|
} else {
|
||||||
|
config.WriteString(fmt.Sprintf(`server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name %s;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass %s;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, domain, upstream))
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestSSLCertificate demande un certificat Let's Encrypt.
|
||||||
|
func (c *SSHClient) RequestSSLCertificate(ctx context.Context, domain, email string) error {
|
||||||
|
cmd := fmt.Sprintf("certbot certonly --nginx -d %s --non-interactive --agree-tos -m %s", domain, email)
|
||||||
|
result, err := c.Exec(ctx, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("certbot: %w", err)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return fmt.Errorf("certbot failed: %s", result.Stderr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckSSLCertificate vérifie si un certificat SSL existe.
|
||||||
|
func (c *SSHClient) CheckSSLCertificate(ctx context.Context, domain string) (bool, error) {
|
||||||
|
path := fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", domain)
|
||||||
|
return c.FileExists(ctx, path)
|
||||||
|
}
|
||||||
469
internal/infra/repository.go
Normal file
469
internal/infra/repository.go
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Servers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// CreateServer crée un nouveau serveur.
|
||||||
|
func (db *DB) CreateServer(s *Server) error {
|
||||||
|
now := time.Now()
|
||||||
|
result, err := db.Exec(`
|
||||||
|
INSERT INTO servers (name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
s.Name, s.Host, nullString(s.VpnIP), s.SSHPort, s.SSHUser, s.SSHKeyFile,
|
||||||
|
boolToInt(s.HasIncus), boolToInt(s.HasNginx), s.Status, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert server: %w", err)
|
||||||
|
}
|
||||||
|
id, _ := result.LastInsertId()
|
||||||
|
s.ID = id
|
||||||
|
s.CreatedAt = now
|
||||||
|
s.UpdatedAt = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServer récupère un serveur par ID.
|
||||||
|
func (db *DB) GetServer(id int64) (*Server, error) {
|
||||||
|
s := &Server{}
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT id, name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at
|
||||||
|
FROM servers WHERE id = ?`, id).Scan(
|
||||||
|
&s.ID, &s.Name, &s.Host, &s.VpnIP, &s.SSHPort, &s.SSHUser, &s.SSHKeyFile,
|
||||||
|
&s.HasIncus, &s.HasNginx, &s.Status, &s.CreatedAt, &s.UpdatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get server: %w", err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServerByName récupère un serveur par nom.
|
||||||
|
func (db *DB) GetServerByName(name string) (*Server, error) {
|
||||||
|
s := &Server{}
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT id, name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at
|
||||||
|
FROM servers WHERE name = ?`, name).Scan(
|
||||||
|
&s.ID, &s.Name, &s.Host, &s.VpnIP, &s.SSHPort, &s.SSHUser, &s.SSHKeyFile,
|
||||||
|
&s.HasIncus, &s.HasNginx, &s.Status, &s.CreatedAt, &s.UpdatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get server by name: %w", err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListServers retourne tous les serveurs.
|
||||||
|
func (db *DB) ListServers() ([]Server, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT id, name, host, vpn_ip, ssh_port, ssh_user, ssh_key_file, has_incus, has_nginx, status, created_at, updated_at
|
||||||
|
FROM servers ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list servers: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var servers []Server
|
||||||
|
for rows.Next() {
|
||||||
|
var s Server
|
||||||
|
if err := rows.Scan(&s.ID, &s.Name, &s.Host, &s.VpnIP, &s.SSHPort, &s.SSHUser, &s.SSHKeyFile,
|
||||||
|
&s.HasIncus, &s.HasNginx, &s.Status, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan server: %w", err)
|
||||||
|
}
|
||||||
|
servers = append(servers, s)
|
||||||
|
}
|
||||||
|
return servers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateServer met à jour un serveur.
|
||||||
|
func (db *DB) UpdateServer(s *Server) error {
|
||||||
|
s.UpdatedAt = time.Now()
|
||||||
|
_, err := db.Exec(`
|
||||||
|
UPDATE servers SET name=?, host=?, vpn_ip=?, ssh_port=?, ssh_user=?, ssh_key_file=?,
|
||||||
|
has_incus=?, has_nginx=?, status=?, updated_at=? WHERE id=?`,
|
||||||
|
s.Name, s.Host, nullString(s.VpnIP), s.SSHPort, s.SSHUser, s.SSHKeyFile,
|
||||||
|
boolToInt(s.HasIncus), boolToInt(s.HasNginx), s.Status, s.UpdatedAt, s.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update server: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateServerStatus met à jour le statut d'un serveur.
|
||||||
|
func (db *DB) UpdateServerStatus(id int64, status ServerStatus) error {
|
||||||
|
_, err := db.Exec(`UPDATE servers SET status=?, updated_at=? WHERE id=?`,
|
||||||
|
status, time.Now(), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update server status: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteServer supprime un serveur.
|
||||||
|
func (db *DB) DeleteServer(id int64) error {
|
||||||
|
_, err := db.Exec(`DELETE FROM servers WHERE id=?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete server: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Containers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// CreateContainer crée un nouveau container.
|
||||||
|
func (db *DB) CreateContainer(c *Container) error {
|
||||||
|
now := time.Now()
|
||||||
|
result, err := db.Exec(`
|
||||||
|
INSERT INTO containers (server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
c.ServerID, c.Name, c.IncusName, nullString(c.IP), nullString(c.VpnIP), c.Image, c.Status, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert container: %w", err)
|
||||||
|
}
|
||||||
|
id, _ := result.LastInsertId()
|
||||||
|
c.ID = id
|
||||||
|
c.CreatedAt = now
|
||||||
|
c.UpdatedAt = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContainer récupère un container par ID.
|
||||||
|
func (db *DB) GetContainer(id int64) (*Container, error) {
|
||||||
|
c := &Container{}
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT id, server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at
|
||||||
|
FROM containers WHERE id = ?`, id).Scan(
|
||||||
|
&c.ID, &c.ServerID, &c.Name, &c.IncusName, &c.IP, &c.VpnIP, &c.Image, &c.Status, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get container: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListContainersByServer retourne les containers d'un serveur.
|
||||||
|
func (db *DB) ListContainersByServer(serverID int64) ([]Container, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT id, server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at
|
||||||
|
FROM containers WHERE server_id = ? ORDER BY name`, serverID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list containers: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var containers []Container
|
||||||
|
for rows.Next() {
|
||||||
|
var c Container
|
||||||
|
if err := rows.Scan(&c.ID, &c.ServerID, &c.Name, &c.IncusName, &c.IP, &c.VpnIP, &c.Image, &c.Status, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan container: %w", err)
|
||||||
|
}
|
||||||
|
containers = append(containers, c)
|
||||||
|
}
|
||||||
|
return containers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllContainers retourne tous les containers.
|
||||||
|
func (db *DB) ListAllContainers() ([]Container, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT id, server_id, name, incus_name, ip, vpn_ip, image, status, created_at, updated_at
|
||||||
|
FROM containers ORDER BY server_id, name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list all containers: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var containers []Container
|
||||||
|
for rows.Next() {
|
||||||
|
var c Container
|
||||||
|
if err := rows.Scan(&c.ID, &c.ServerID, &c.Name, &c.IncusName, &c.IP, &c.VpnIP, &c.Image, &c.Status, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan container: %w", err)
|
||||||
|
}
|
||||||
|
containers = append(containers, c)
|
||||||
|
}
|
||||||
|
return containers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateContainer met à jour un container.
|
||||||
|
func (db *DB) UpdateContainer(c *Container) error {
|
||||||
|
c.UpdatedAt = time.Now()
|
||||||
|
_, err := db.Exec(`
|
||||||
|
UPDATE containers SET server_id=?, name=?, incus_name=?, ip=?, vpn_ip=?, image=?, status=?, updated_at=?
|
||||||
|
WHERE id=?`,
|
||||||
|
c.ServerID, c.Name, c.IncusName, nullString(c.IP), nullString(c.VpnIP), c.Image, c.Status, c.UpdatedAt, c.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update container: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateContainerStatus met à jour le statut d'un container.
|
||||||
|
func (db *DB) UpdateContainerStatus(id int64, status ContainerStatus) error {
|
||||||
|
_, err := db.Exec(`UPDATE containers SET status=?, updated_at=? WHERE id=?`,
|
||||||
|
status, time.Now(), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update container status: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteContainer supprime un container.
|
||||||
|
func (db *DB) DeleteContainer(id int64) error {
|
||||||
|
_, err := db.Exec(`DELETE FROM containers WHERE id=?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete container: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NginxConfigs
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// CreateNginxConfig crée une nouvelle config Nginx.
|
||||||
|
func (db *DB) CreateNginxConfig(n *NginxConfig) error {
|
||||||
|
now := time.Now()
|
||||||
|
result, err := db.Exec(`
|
||||||
|
INSERT INTO nginx_configs (server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
n.ServerID, n.Domain, n.Type, nullString(n.Template), nullString(n.Upstream),
|
||||||
|
boolToInt(n.SSLEnabled), nullString(n.ConfigContent), n.Status, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert nginx config: %w", err)
|
||||||
|
}
|
||||||
|
id, _ := result.LastInsertId()
|
||||||
|
n.ID = id
|
||||||
|
n.CreatedAt = now
|
||||||
|
n.UpdatedAt = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNginxConfig récupère une config Nginx par ID.
|
||||||
|
func (db *DB) GetNginxConfig(id int64) (*NginxConfig, error) {
|
||||||
|
n := &NginxConfig{}
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
|
||||||
|
FROM nginx_configs WHERE id = ?`, id).Scan(
|
||||||
|
&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
|
||||||
|
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get nginx config: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNginxConfigByDomain récupère une config par domaine.
|
||||||
|
func (db *DB) GetNginxConfigByDomain(serverID int64, domain string) (*NginxConfig, error) {
|
||||||
|
n := &NginxConfig{}
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
|
||||||
|
FROM nginx_configs WHERE server_id = ? AND domain = ?`, serverID, domain).Scan(
|
||||||
|
&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
|
||||||
|
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get nginx config by domain: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListNginxConfigsByServer retourne les configs d'un serveur.
|
||||||
|
func (db *DB) ListNginxConfigsByServer(serverID int64) ([]NginxConfig, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
|
||||||
|
FROM nginx_configs WHERE server_id = ? ORDER BY domain`, serverID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list nginx configs: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var configs []NginxConfig
|
||||||
|
for rows.Next() {
|
||||||
|
var n NginxConfig
|
||||||
|
if err := rows.Scan(&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
|
||||||
|
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan nginx config: %w", err)
|
||||||
|
}
|
||||||
|
configs = append(configs, n)
|
||||||
|
}
|
||||||
|
return configs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllNginxConfigs retourne toutes les configs.
|
||||||
|
func (db *DB) ListAllNginxConfigs() ([]NginxConfig, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT id, server_id, domain, type, template, upstream, ssl_enabled, config_content, status, created_at, updated_at
|
||||||
|
FROM nginx_configs ORDER BY server_id, domain`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list all nginx configs: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var configs []NginxConfig
|
||||||
|
for rows.Next() {
|
||||||
|
var n NginxConfig
|
||||||
|
if err := rows.Scan(&n.ID, &n.ServerID, &n.Domain, &n.Type, &n.Template, &n.Upstream,
|
||||||
|
&n.SSLEnabled, &n.ConfigContent, &n.Status, &n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan nginx config: %w", err)
|
||||||
|
}
|
||||||
|
configs = append(configs, n)
|
||||||
|
}
|
||||||
|
return configs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNginxConfig met à jour une config.
|
||||||
|
func (db *DB) UpdateNginxConfig(n *NginxConfig) error {
|
||||||
|
n.UpdatedAt = time.Now()
|
||||||
|
_, err := db.Exec(`
|
||||||
|
UPDATE nginx_configs SET server_id=?, domain=?, type=?, template=?, upstream=?,
|
||||||
|
ssl_enabled=?, config_content=?, status=?, updated_at=? WHERE id=?`,
|
||||||
|
n.ServerID, n.Domain, n.Type, nullString(n.Template), nullString(n.Upstream),
|
||||||
|
boolToInt(n.SSLEnabled), nullString(n.ConfigContent), n.Status, n.UpdatedAt, n.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update nginx config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNginxConfig supprime une config.
|
||||||
|
func (db *DB) DeleteNginxConfig(id int64) error {
|
||||||
|
_, err := db.Exec(`DELETE FROM nginx_configs WHERE id=?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete nginx config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AppBindings
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// CreateAppBinding crée un nouveau binding.
|
||||||
|
func (db *DB) CreateAppBinding(b *AppBinding) error {
|
||||||
|
now := time.Now()
|
||||||
|
result, err := db.Exec(`
|
||||||
|
INSERT INTO app_bindings (app_id, container_id, nginx_config_id, server_id, type, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
b.AppID, nullInt64(b.ContainerID), nullInt64(b.NginxConfigID), nullInt64(b.ServerID), b.Type, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert app binding: %w", err)
|
||||||
|
}
|
||||||
|
id, _ := result.LastInsertId()
|
||||||
|
b.ID = id
|
||||||
|
b.CreatedAt = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAppBindings retourne les bindings d'une app.
|
||||||
|
func (db *DB) ListAppBindings(appID string) ([]AppBinding, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT id, app_id, container_id, nginx_config_id, server_id, type, created_at
|
||||||
|
FROM app_bindings WHERE app_id = ?`, appID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list app bindings: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var bindings []AppBinding
|
||||||
|
for rows.Next() {
|
||||||
|
var b AppBinding
|
||||||
|
if err := rows.Scan(&b.ID, &b.AppID, &b.ContainerID, &b.NginxConfigID, &b.ServerID, &b.Type, &b.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan app binding: %w", err)
|
||||||
|
}
|
||||||
|
bindings = append(bindings, b)
|
||||||
|
}
|
||||||
|
return bindings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAppBinding supprime un binding.
|
||||||
|
func (db *DB) DeleteAppBinding(id int64) error {
|
||||||
|
_, err := db.Exec(`DELETE FROM app_bindings WHERE id=?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete app binding: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAppBindingsByApp supprime tous les bindings d'une app.
|
||||||
|
func (db *DB) DeleteAppBindingsByApp(appID string) error {
|
||||||
|
_, err := db.Exec(`DELETE FROM app_bindings WHERE app_id=?`, appID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete app bindings: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func nullString(s string) interface{} {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullInt64(i *int64) interface{} {
|
||||||
|
if i == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *i
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Aggregate queries
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GetInfraOverview retourne une vue complète de l'infrastructure.
|
||||||
|
func (db *DB) GetInfraOverview() (*InfraOverview, error) {
|
||||||
|
servers, err := db.ListServers()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
overview := &InfraOverview{
|
||||||
|
Servers: make([]ServerWithContainers, len(servers)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, s := range servers {
|
||||||
|
overview.Servers[i].Server = s
|
||||||
|
containers, err := db.ListContainersByServer(s.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
overview.Servers[i].Containers = containers
|
||||||
|
}
|
||||||
|
|
||||||
|
overview.NginxConfigs, err = db.ListAllNginxConfigs()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return overview, nil
|
||||||
|
}
|
||||||
335
internal/infra/ssh.go
Normal file
335
internal/infra/ssh.go
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
// Package infra gère l'infrastructure (serveurs, containers, nginx).
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSHClient représente une connexion SSH à un serveur.
|
||||||
|
type SSHClient struct {
|
||||||
|
server *Server
|
||||||
|
client *ssh.Client
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHPool gère un pool de connexions SSH.
|
||||||
|
type SSHPool struct {
|
||||||
|
clients map[int64]*SSHClient
|
||||||
|
mu sync.RWMutex
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHResult représente le résultat d'une commande SSH.
|
||||||
|
type SSHResult struct {
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
ExitCode int
|
||||||
|
Duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSSHPool crée un nouveau pool SSH.
|
||||||
|
func NewSSHPool(timeout time.Duration) *SSHPool {
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
return &SSHPool{
|
||||||
|
clients: make(map[int64]*SSHClient),
|
||||||
|
timeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect établit une connexion SSH à un serveur.
|
||||||
|
func (p *SSHPool) Connect(server *Server) (*SSHClient, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
// Vérifier si déjà connecté
|
||||||
|
if client, ok := p.clients[server.ID]; ok {
|
||||||
|
if client.isAlive() {
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
// Connexion morte, la supprimer
|
||||||
|
client.Close()
|
||||||
|
delete(p.clients, server.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lire la clé SSH
|
||||||
|
keyData, err := os.ReadFile(server.SSHKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read ssh key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signer, err := ssh.ParsePrivateKey(keyData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse ssh key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config SSH
|
||||||
|
config := &ssh.ClientConfig{
|
||||||
|
User: server.SSHUser,
|
||||||
|
Auth: []ssh.AuthMethod{
|
||||||
|
ssh.PublicKeys(signer),
|
||||||
|
},
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: gérer les known_hosts
|
||||||
|
Timeout: p.timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connexion
|
||||||
|
addr := fmt.Sprintf("%s:%d", server.Host, server.SSHPort)
|
||||||
|
sshClient, err := ssh.Dial("tcp", addr, config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ssh dial %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &SSHClient{
|
||||||
|
server: server,
|
||||||
|
client: sshClient,
|
||||||
|
}
|
||||||
|
p.clients[server.ID] = client
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get récupère un client existant ou nil.
|
||||||
|
func (p *SSHPool) Get(serverID int64) *SSHClient {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
return p.clients[serverID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect ferme la connexion à un serveur.
|
||||||
|
func (p *SSHPool) Disconnect(serverID int64) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
if client, ok := p.clients[serverID]; ok {
|
||||||
|
client.Close()
|
||||||
|
delete(p.clients, serverID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseAll ferme toutes les connexions.
|
||||||
|
func (p *SSHPool) CloseAll() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
for id, client := range p.clients {
|
||||||
|
client.Close()
|
||||||
|
delete(p.clients, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAlive vérifie si la connexion est active.
|
||||||
|
func (c *SSHClient) isAlive() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.client == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test rapide avec une session
|
||||||
|
session, err := c.client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
session.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close ferme la connexion.
|
||||||
|
func (c *SSHClient) Close() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.client != nil {
|
||||||
|
err := c.client.Close()
|
||||||
|
c.client = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exec exécute une commande sur le serveur distant.
|
||||||
|
func (c *SSHClient) Exec(ctx context.Context, cmd string) (*SSHResult, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.client == nil {
|
||||||
|
return nil, fmt.Errorf("ssh client not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := c.client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
session.Stdout = &stdout
|
||||||
|
session.Stderr = &stderr
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Exécuter avec timeout
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- session.Run(cmd)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
session.Signal(ssh.SIGKILL)
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case err := <-done:
|
||||||
|
result := &SSHResult{
|
||||||
|
Stdout: stdout.String(),
|
||||||
|
Stderr: stderr.String(),
|
||||||
|
Duration: time.Since(start),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*ssh.ExitError); ok {
|
||||||
|
result.ExitCode = exitErr.ExitStatus()
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("exec command: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecSimple exécute une commande et retourne stdout.
|
||||||
|
func (c *SSHClient) ExecSimple(ctx context.Context, cmd string) (string, error) {
|
||||||
|
result, err := c.Exec(ctx, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return "", fmt.Errorf("command failed (exit %d): %s", result.ExitCode, result.Stderr)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(result.Stdout), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile écrit un fichier sur le serveur distant via cat.
|
||||||
|
func (c *SSHClient) WriteFile(ctx context.Context, path string, content []byte, mode string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.client == nil {
|
||||||
|
return fmt.Errorf("ssh client not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := c.client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
// Utiliser cat pour écrire le fichier
|
||||||
|
stdin, err := session.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stdin pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := fmt.Sprintf("cat > %s && chmod %s %s", path, mode, path)
|
||||||
|
if err := session.Start(cmd); err != nil {
|
||||||
|
return fmt.Errorf("start command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := stdin.Write(content); err != nil {
|
||||||
|
return fmt.Errorf("write content: %w", err)
|
||||||
|
}
|
||||||
|
stdin.Close()
|
||||||
|
|
||||||
|
if err := session.Wait(); err != nil {
|
||||||
|
return fmt.Errorf("wait command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFile lit un fichier depuis le serveur distant.
|
||||||
|
func (c *SSHClient) ReadFile(ctx context.Context, path string) ([]byte, error) {
|
||||||
|
result, err := c.Exec(ctx, fmt.Sprintf("cat %s", path))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return nil, fmt.Errorf("read file failed: %s", result.Stderr)
|
||||||
|
}
|
||||||
|
return []byte(result.Stdout), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileExists vérifie si un fichier existe.
|
||||||
|
func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) {
|
||||||
|
result, err := c.Exec(ctx, fmt.Sprintf("test -e %s && echo yes || echo no", path))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(result.Stdout) == "yes", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SFTP retourne un client SFTP pour des opérations de fichiers avancées.
|
||||||
|
// Note: nécessite github.com/pkg/sftp si on veut un vrai SFTP.
|
||||||
|
// Pour l'instant on utilise des commandes shell.
|
||||||
|
|
||||||
|
// CopyFile copie un fichier local vers le serveur distant.
|
||||||
|
func (c *SSHClient) CopyFile(ctx context.Context, localPath, remotePath string) error {
|
||||||
|
content, err := os.ReadFile(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read local file: %w", err)
|
||||||
|
}
|
||||||
|
return c.WriteFile(ctx, remotePath, content, "644")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyFrom copie un fichier du serveur distant vers local.
|
||||||
|
func (c *SSHClient) CopyFrom(ctx context.Context, remotePath, localPath string) error {
|
||||||
|
content, err := c.ReadFile(ctx, remotePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(localPath, content, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamExec exécute une commande et stream la sortie.
|
||||||
|
func (c *SSHClient) StreamExec(ctx context.Context, cmd string, stdout, stderr io.Writer) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.client == nil {
|
||||||
|
return fmt.Errorf("ssh client not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := c.client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
session.Stdout = stdout
|
||||||
|
session.Stderr = stderr
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- session.Run(cmd)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
session.Signal(ssh.SIGKILL)
|
||||||
|
return ctx.Err()
|
||||||
|
case err := <-done:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
2
sogoms.svg
Normal file
2
sogoms.svg
Normal 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 |
Reference in New Issue
Block a user